Merge pull request 'test(staging): feature-level verification of closed issues + seeded test users' (#119) from feat/staging-issue-verification into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s

Reviewed-on: #119
This commit is contained in:
shad0w 2026-06-24 06:46:17 +00:00
commit cf69292be3
30 changed files with 995 additions and 0 deletions

1
App/.gitignore vendored
View file

@ -13,6 +13,7 @@
# Testing
/coverage
/playwright-report
/playwright-report-staging
/test-results
/blob-report

View file

@ -0,0 +1,43 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright config for verifying closed issues against a RUNNING staging instance
* (pm2 `ppms-staging`, port 3200 on pms1), reached over an SSH tunnel:
*
* ssh -N -L 3200:localhost:3200 shad0w@<pms1>
* PLAYWRIGHT_BASE_URL=http://localhost:3200 \
* pnpm exec playwright test --config playwright.staging.config.ts
*
* Unlike playwright.config.ts this does NOT start a local dev server it drives the
* already-deployed staging build. Login uses the seeded `@pelagia.local` test users
* (prisma/seed-test-users.ts), so no production credentials are required.
*
* Staging runs `next dev`, so the first hit on a route compiles on demand and can be
* slow timeouts are deliberately generous and workers default to 1 to keep the
* shared staging DB state predictable across specs.
*/
export default defineConfig({
testDir: "./tests/staging",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 1,
workers: 1,
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report-staging" }]],
timeout: 90_000,
expect: { timeout: 20_000 },
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3200",
trace: "retain-on-failure",
navigationTimeout: 45_000,
actionTimeout: 20_000,
},
projects: [
{
name: "chromium",
// Use a system browser channel (Google Chrome) so the suite does not depend on
// the bundled chrome-headless-shell download. Override with PW_CHANNEL=msedge
// if Chrome is unavailable. Both ship on a standard Windows install.
use: { ...devices["Desktop Chrome"], channel: process.env.PW_CHANNEL ?? "chrome" },
},
],
});

View file

@ -0,0 +1,89 @@
/**
* Seed deterministic, credential-capable TEST USERS into a database.
*
* Why this exists
* ---------------
* `pelagia_test` (the staging / autofix DB) is a daily mirror of production, so it
* only contains real `@pelagiamarine.com` users most are SSO-only (no password)
* and none have a password we know. That makes it impossible to log into the
* staging instance (port 3200) with the credentials provider to run end-to-end
* feature tests.
*
* This script upserts one **known-password** user per `Role` (using the throwaway
* `@pelagia.local` domain, which never exists in prod, so there is zero collision
* with real accounts). Credentials intentionally mirror
* `tests/e2e/helpers/login.ts` so the same Playwright specs run locally and against
* staging unchanged.
*
* Safety
* ------
* - Idempotent: upsert keyed on the (unique) email; re-running only refreshes the
* password hash / role / isActive.
* - `employeeId` uses a `TEST-*` prefix so it can never clash with a real
* production employee id carried over by the mirror.
* - Only ever creates the `@pelagia.local` users below it touches no prod rows.
*
* Usage
* -----
* DATABASE_URL="postgresql://.../pelagia_test" pnpm tsx prisma/seed-test-users.ts
*
* It is wired into `automation/refresh-test-db.sh` so these accounts are recreated
* automatically after every daily refresh of `pelagia_test`.
*/
import { PrismaClient, Role } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
/**
* One login per role/flow that the closed-issue feature tests exercise.
* Passwords match tests/e2e/helpers/login.ts (do not change one without the other).
*/
const TEST_USERS: Array<{
employeeId: string;
email: string;
name: string;
password: string;
role: Role;
}> = [
{ employeeId: "TEST-TECH", email: "tech@pelagia.local", name: "Test Technical", password: "tech1234", role: Role.TECHNICAL },
{ employeeId: "TEST-MANNING",email: "manning@pelagia.local", name: "Test Manning", password: "manning1234", role: Role.MANNING },
{ employeeId: "TEST-ACCT", email: "accounts@pelagia.local", name: "Test Accounts", password: "accounts1234", role: Role.ACCOUNTS },
{ employeeId: "TEST-MGR", email: "manager@pelagia.local", name: "Test Manager", password: "manager1234", role: Role.MANAGER },
{ employeeId: "TEST-SUPER", email: "superuser@pelagia.local", name: "Test Superuser", password: "super1234", role: Role.SUPERUSER },
{ employeeId: "TEST-AUDIT", email: "auditor@pelagia.local", name: "Test Auditor", password: "audit1234", role: Role.AUDITOR },
{ employeeId: "TEST-ADMIN", email: "admin@pelagia.local", name: "Test Admin", password: "admin1234", role: Role.ADMIN },
{ employeeId: "TEST-SITE", email: "site@pelagia.local", name: "Test Site Staff", password: "site1234", role: Role.SITE_STAFF},
];
async function main() {
console.log(`Seeding ${TEST_USERS.length} test users...`);
for (const u of TEST_USERS) {
const passwordHash = await bcrypt.hash(u.password, 12);
await prisma.user.upsert({
where: { email: u.email },
// Keep an existing test account in sync (refresh the hash / role / active flag)
// but never overwrite its employeeId once created.
update: { name: u.name, passwordHash, role: u.role, isActive: true },
create: {
employeeId: u.employeeId,
email: u.email,
name: u.name,
passwordHash,
role: u.role,
isActive: true,
},
});
console.log(`${u.email.padEnd(28)} ${u.role}`);
}
console.log("Test users ready.");
}
main()
.catch((e) => {
console.error("seed-test-users failed:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Foundation check: the staging instance is reachable and every seeded test user
* can authenticate with the credentials provider. If this fails, none of the
* per-issue specs can run fix the seed (prisma/seed-test-users.ts) or the tunnel.
*/
test.describe("staging smoke", () => {
test("login page renders the staging build", async ({ page }) => {
await page.goto("/login");
await expect(page.getByLabel(/email address/i)).toBeVisible();
await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
});
for (const [name, creds] of Object.entries(USERS)) {
test(`seeded user ${name} can log in`, async ({ page }) => {
await login(page, creds);
await expect(page).not.toHaveURL(/\/login/);
});
}
});

View file

@ -0,0 +1,34 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Crewing epics (#75 Requisitions, #76 Candidates, #79 Crew records, #81 Leave &
* Attendance, #83 Office verification, #86 Reference data/admin) feature-flagged
* behind NEXT_PUBLIC_CREWING_ENABLED, which is "true" on staging.
*
* These are smoke checks that each epic's primary surface renders for an authorised
* role (the deep state-machine flows pipeline #77, onboarding #78, PPE #80,
* appraisal #82, sign-off #85 are covered by the existing integration suites noted
* in Docs/TESTING.md). Render-without-redirect is the proof the shipped feature is
* live on staging.
*/
const PAGES: Array<{ issue: string; name: string; path: string; user: keyof typeof USERS; heading: RegExp }> = [
{ issue: "#75", name: "Requisitions", path: "/crewing/requisitions", user: "MANAGER", heading: /requisition/i },
{ issue: "#76", name: "Candidates", path: "/crewing/candidates", user: "MANAGER", heading: /candidate/i },
{ issue: "#79", name: "Crew records", path: "/crewing/crew", user: "MANAGER", heading: /crew/i },
{ issue: "#81", name: "Leave", path: "/crewing/leave", user: "MANAGER", heading: /leave/i },
{ issue: "#81", name: "Attendance", path: "/crewing/attendance", user: "MANAGER", heading: /attendance/i },
{ issue: "#83", name: "Verification", path: "/crewing/verification", user: "MANNING", heading: /verif/i },
{ issue: "#86", name: "Ranks", path: "/admin/ranks", user: "MANAGER", heading: /rank/i },
{ issue: "#86", name: "Crew admin", path: "/admin/crew", user: "MANAGER", heading: /crew/i },
];
for (const p of PAGES) {
test(`${p.issue} ${p.name} surface renders on staging (${p.path})`, async ({ page }) => {
await login(page, USERS[p.user]);
await page.goto(p.path);
await expect(page, `should not redirect away from ${p.path}`).toHaveURL(new RegExp(p.path.replace(/\//g, "\\/")));
await expect(page.getByRole("heading", { name: p.heading }).first()).toBeVisible();
});
}

View file

@ -0,0 +1,127 @@
/**
* Runtime fixture lookups for the staging verification suite.
*
* PO ids in `pelagia_test` change on every daily refresh, so specs must not hard-code
* them. These helpers anchor each spec on a real row that currently exists in the
* staging DB (read-only), keeping the suite stable across refreshes. They use the
* SAME `DATABASE_URL` the Playwright run is given (the SSH tunnel to pelagia_test).
*
* This is test *setup* only every assertion still runs against the live UI.
*/
import { PrismaClient } from "@prisma/client";
let _db: PrismaClient | null = null;
export function db(): PrismaClient {
if (!_db) _db = new PrismaClient();
return _db;
}
export async function closeDb() {
if (_db) await _db.$disconnect();
_db = null;
}
// Mirrors lib/utils.ts POST_APPROVAL_STATUSES — the statuses that count as "approved
// and beyond" for spend/approval trackers (issues #12, #32, #50).
export const POST_APPROVAL_STATUSES = [
"MGR_APPROVED",
"SENT_FOR_PAYMENT",
"PARTIALLY_PAID",
"PAID_DELIVERED",
"PARTIALLY_CLOSED",
"CLOSED",
] as const;
/** First day of the current month in local time (matches the dashboard query). */
export function startOfMonth(now = new Date()): Date {
return new Date(now.getFullYear(), now.getMonth(), 1);
}
/** Mirror of lib/utils.ts formatDate → e.g. "Jun 11, 2026". */
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(
new Date(date),
);
}
export async function approvedPo() {
return db().purchaseOrder.findFirst({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } },
orderBy: { approvedAt: "desc" },
select: { id: true, poNumber: true, status: true, approvedAt: true, poDate: true },
});
}
/**
* An approved-or-later PO with NO explicit poDate, so its detail "PO Date" must fall
* back to the approval date the exact case issue #5 is about.
*/
export async function approvedPoNoPoDate() {
return db().purchaseOrder.findFirst({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null }, poDate: null },
orderBy: { approvedAt: "desc" },
select: { id: true, poNumber: true, status: true, approvedAt: true },
});
}
export async function closedPo() {
return db().purchaseOrder.findFirst({
where: { status: "CLOSED" },
select: { id: true, poNumber: true },
});
}
/** An approved-or-later PO whose vendor has a contact email (issue #14). */
export async function poWithVendorEmail() {
return db().purchaseOrder.findFirst({
where: {
status: { in: [...POST_APPROVAL_STATUSES] },
vendor: { contacts: { some: { email: { not: null } } } },
},
select: { id: true, poNumber: true, status: true },
});
}
/** A PO that has at least one line item with a non-empty description (issue #8). */
export async function poWithLineItemDescription() {
const li = await db().pOLineItem.findFirst({
where: { description: { not: null }, AND: [{ description: { not: "" } }] },
select: { description: true, po: { select: { id: true, poNumber: true, status: true } } },
});
return li ? { ...li.po, description: li.description as string } : null;
}
/** All CLOSED PO numbers, to assert the manager sees the full closed set (issue #6). */
export async function closedPoNumbers() {
const rows = await db().purchaseOrder.findMany({
where: { status: "CLOSED" },
select: { poNumber: true },
});
return rows.map((r) => r.poNumber);
}
/** A PO that has at least one uploaded document, to exercise attachment grouping (#10). */
export async function poWithDocuments() {
const doc = await db().pODocument.findFirst({
select: { po: { select: { id: true, poNumber: true } } },
});
return doc?.po ?? null;
}
export async function totalPoCount() {
return db().purchaseOrder.count();
}
/** Count of POs approved in the current month, regardless of current status (issues #12/#32). */
export async function approvedThisMonthCount() {
return db().purchaseOrder.count({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { gte: startOfMonth() } },
});
}
/** A vendor that has a non-null vendorId code, for search-by-code specs (#57/#109). */
export async function vendorWithCode() {
return db().vendor.findFirst({
where: { vendorId: { not: null } },
select: { name: true, vendorId: true },
});
}

View file

@ -0,0 +1,39 @@
/**
* Shared helpers for the staging closed-issue verification suite.
*
* Credentials mirror prisma/seed-test-users.ts (seeded into pelagia_test) and the
* dev-seed users in tests/e2e/helpers/login.ts.
*/
import { type Page, expect } from "@playwright/test";
export interface Credentials {
email: string;
password: string;
}
export const USERS = {
TECH: { email: "tech@pelagia.local", password: "tech1234" },
MANNING: { email: "manning@pelagia.local", password: "manning1234" },
ACCOUNTS: { email: "accounts@pelagia.local", password: "accounts1234" },
MANAGER: { email: "manager@pelagia.local", password: "manager1234" },
SUPERUSER: { email: "superuser@pelagia.local", password: "super1234" },
AUDITOR: { email: "auditor@pelagia.local", password: "audit1234" },
ADMIN: { email: "admin@pelagia.local", password: "admin1234" },
SITE: { email: "site@pelagia.local", password: "site1234" },
} satisfies Record<string, Credentials>;
/** Log in via the credentials provider and wait until off the /login page. */
export async function login(page: Page, creds: Credentials): Promise<void> {
await page.goto("/login");
await page.getByLabel(/email address/i).fill(creds.email);
await page.getByLabel(/password/i).fill(creds.password);
// The staging login page has two buttons: "Sign in with Microsoft" (SSO) and the
// credentials "Sign in" submit. Target the exact credentials submit.
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page).not.toHaveURL(/\/login/, { timeout: 30_000 });
}
/** Log out via the header control (best-effort; ignores if already logged out). */
export async function logout(page: Page): Promise<void> {
await page.context().clearCookies();
}

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #4 Submitter can set an optional PO date (back/forward-datable).
* Fix: a `poDate` date input on the PO create/edit forms.
*/
test("#4 PO create form exposes an optional, free-to-set PO Date field", async ({ page }) => {
await login(page, USERS.TECH);
await page.goto("/po/new");
const poDate = page.locator('input[name="poDate"]');
await expect(poDate).toBeVisible();
await expect(poDate).toHaveAttribute("type", "date");
await expect(poDate).not.toHaveAttribute("required", "");
// It accepts a back-dated value and a forward-dated value.
await poDate.fill("2024-01-15");
await expect(poDate).toHaveValue("2024-01-15");
await poDate.fill("2030-12-31");
await expect(poDate).toHaveValue("2030-12-31");
});

View file

@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { approvedPoNoPoDate, formatDate, closeDb } from "./fixtures";
/**
* Issue #5 Once a PO is approved, the PO date shown is the approval date
* (when the submitter did not set an explicit poDate). Display rule:
* poDisplayDate = po.poDate ?? po.approvedAt ?? po.createdAt.
*/
test.afterAll(closeDb);
test("#5 approved PO detail shows the approval date as the PO Date", async ({ page }) => {
const po = await approvedPoNoPoDate();
test.skip(!po, "no approved PO without an explicit poDate in staging data");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await expect(page.getByText(po!.poNumber)).toBeVisible();
// The "PO Date" detail row should render the approval date.
const expected = formatDate(po!.approvedAt!);
const row = page.getByText("PO Date", { exact: true }).locator("xpath=ancestor::*[1]");
await expect(row).toContainText(expected);
});

View file

@ -0,0 +1,35 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { closedPoNumbers, closeDb } from "./fixtures";
/**
* Issue #6 Closed PO list filters.
* - MANAGER: the "Closed Purchase Orders" view shows ALL closed POs.
* - Submitter: the view shows ONLY CLOSED (no APPROVED leaking in).
* Route: /my-orders.
*/
test.afterAll(closeDb);
test("#6 manager sees ALL closed POs on /my-orders", async ({ page }) => {
const closed = await closedPoNumbers();
test.skip(closed.length === 0, "no closed POs in staging data");
await login(page, USERS.MANAGER);
await page.goto("/my-orders");
await expect(page.getByRole("heading", { name: "Closed Purchase Orders" })).toBeVisible();
// The manager sees the FULL closed set (not just their own): every closed PO number
// is present, and no APPROVED status leaks into this view.
for (const poNumber of closed) {
await expect(page.getByText(poNumber).first()).toBeVisible();
}
await expect(page.getByText("Approved", { exact: true })).toHaveCount(0);
});
test("#6 submitter's closed view excludes APPROVED POs", async ({ page }) => {
await login(page, USERS.TECH);
await page.goto("/my-orders");
await expect(page.getByRole("heading", { name: "Closed Purchase Orders" })).toBeVisible();
// The bug was APPROVED POs showing here; assert none do.
await expect(page.getByText("Approved", { exact: true })).toHaveCount(0);
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { poWithLineItemDescription, closeDb } from "./fixtures";
/**
* Issue #8 The exported PO must include the line item's optional description.
* The export route renders the printable PO at /api/po/[id]/export?format=pdf;
* we fetch it with the logged-in session and assert the description text is present.
*/
test.afterAll(closeDb);
test("#8 exported PO contains the line-item description", async ({ page }) => {
const po = await poWithLineItemDescription();
test.skip(!po, "no PO with a line-item description in staging data");
await login(page, USERS.MANAGER);
// page.request shares the authenticated browser cookies.
const res = await page.request.get(`/api/po/${po!.id}/export?format=pdf`);
expect(res.ok()).toBeTruthy();
const body = await res.text();
expect(body).toContain(po!.description);
});

View file

@ -0,0 +1,25 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { poWithDocuments, closeDb } from "./fixtures";
/**
* Issue #10 PO detail shows ALL attachments, grouped by type
* (Submission / Payment / Delivery). Requires a PO that actually has documents;
* if the staging mirror has none, the spec skips (documented data limitation).
*/
test.afterAll(closeDb);
test("#10 PO detail groups attachments by type", async ({ page }) => {
const po = await poWithDocuments();
test.skip(!po, "no PO with uploaded documents in staging data");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await expect(page.getByText(po!.poNumber)).toBeVisible();
// The attachments region renders at least one of the typed group headings.
const groups = page.getByText(
/SUBMISSION DOCUMENTS|PAYMENT DOCUMENTS|DELIVERY RECEIPTS|OTHER ATTACHMENTS/i,
);
await expect(groups.first()).toBeVisible();
});

View file

@ -0,0 +1,18 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #104 /history is paginated with an items-per-page dropdown.
*/
test("#104 history has an items-per-page dropdown that drives a perPage query param", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/history");
const perPage = page.locator("#perPage");
await expect(perPage).toBeVisible();
// Options include the configured page sizes.
await expect(perPage.locator('option[value="50"]')).toBeAttached();
await perPage.selectOption("50");
await expect(page).toHaveURL(/perPage=50/);
});

View file

@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { vendorWithCode, closeDb } from "./fixtures";
/**
* Issue #109 the New PO screen vendor field is a searchable combobox that matches
* by vendor name AND code (mirrors the items search).
*/
test.afterAll(closeDb);
test("#109 new PO vendor field is a searchable combobox (name + code)", async ({ page }) => {
const vendor = await vendorWithCode();
test.skip(!vendor, "no vendor with a code in staging data");
await login(page, USERS.MANAGER);
await page.goto("/po/new");
// Open the vendor combobox (trigger shows the "No vendor selected" placeholder).
await page.getByText("No vendor selected").click();
const search = page.getByPlaceholder(/Search by name or code/i);
await expect(search).toBeVisible();
// Searching by the vendor's CODE surfaces the vendor by name — proving code search.
await search.fill(vendor!.vendorId!);
await expect(page.getByText(vendor!.name, { exact: false }).first()).toBeVisible();
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #11 Admin-managed Terms & Conditions catalogue feeding a dynamic PO editor.
* - Admin surface at /admin/terms.
* - The PO create form renders a Terms & Conditions editor.
*/
test("#11 admin Terms & Conditions page renders for a manager", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/admin/terms");
await expect(page).not.toHaveURL(/\/login/);
await expect(page.getByRole("heading", { name: /terms.*conditions/i }).first()).toBeVisible();
});
test("#11 new PO form includes a Terms & Conditions editor", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/po/new");
await expect(page.getByText(/terms.*conditions/i).first()).toBeVisible();
// The dynamic editor offers an "Add term" affordance.
await expect(page.getByRole("button", { name: /add term/i })).toBeVisible();
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { approvedThisMonthCount, closeDb } from "./fixtures";
/**
* Issue #12 Manager dashboard 'Total approved this month' was stuck at 0 / not
* updating. Fix: the card counts every PO approved this month (any post-approval
* status). We assert the rendered value matches the DB-computed count, proving it
* reflects real data rather than 0.
*/
test.afterAll(closeDb);
test("#12 'Approved This Month' card shows the correct, live count", async ({ page }) => {
const expected = await approvedThisMonthCount();
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
const card = page.locator("a,div", { has: page.getByText("Approved This Month", { exact: true }) }).first();
await expect(card).toBeVisible();
await expect(card).toContainText(String(expected));
});

View file

@ -0,0 +1,20 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #13 Accounts dashboard should have a 'Payments completed this month' card
* (analogous to the manager's monthly approval card).
*
* VERIFICATION RESULT: NOT FIXED on staging. The Accounts dashboard currently shows
* only "Ready for Payment" and "Payment Queue Value" there is no
* payments-completed-this-month card. This is marked test.fail() so the suite stays
* green while clearly recording the gap; if the card is later added, this test will
* start passing and flag that the annotation should be removed.
*/
test.fail(true, "Issue #13 not implemented: no 'payments completed this month' card on the Accounts dashboard");
test("#13 Accounts dashboard shows a 'Payments completed this month' card", async ({ page }) => {
await login(page, USERS.ACCOUNTS);
await page.goto("/dashboard");
await expect(page.getByText(/payments? completed this month/i)).toBeVisible();
});

View file

@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { poWithVendorEmail, closeDb } from "./fixtures";
/**
* Issue #14 'Email to vendor' option becomes available once a PO is approved (and
* after payment), when the vendor has a contact email. We assert the button is
* present on such a PO. (The underlying PDF/Outlook pipeline depends on PdfService
* env config; this verifies the user-facing affordance exists.)
*/
test.afterAll(closeDb);
test("#14 approved PO with a vendor email shows the 'Email to vendor' button", async ({ page }) => {
const po = await poWithVendorEmail();
test.skip(!po, "no approved PO with a vendor contact email in staging data");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await expect(page.getByText(po!.poNumber)).toBeVisible();
await expect(page.getByRole("button", { name: /email to vendor/i })).toBeVisible();
});

View file

@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #19 Place of Delivery is a dropdown (admin-managed delivery locations),
* not free text. The PO forms render <select name="placeOfDelivery">, and the admin
* surface lives at /admin/delivery-locations.
*/
test("#19 PO form Place of Delivery is a <select> dropdown", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/po/new");
const select = page.locator('select[name="placeOfDelivery"]');
await expect(select).toBeVisible();
});
test("#19 admin delivery-locations page renders for a manager", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/admin/delivery-locations");
await expect(page).not.toHaveURL(/\/login/);
await expect(page.getByRole("heading", { name: /delivery location/i }).first()).toBeVisible();
});

View file

@ -0,0 +1,18 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issues #24 / #40 change the sign-out control's tooltip from 'Sign out' to
* 'Log out'. Both were pipeline / button-simulation TEST issues.
*
* VERIFICATION RESULT: NOT FIXED on staging. The header logout control still uses
* title="Sign out". Marked test.fail() to record the gap without failing the suite;
* if the copy is changed to 'Log out' this test will pass and flag the annotation.
*/
test.fail(true, "Issues #24/#40 not implemented: logout tooltip is still 'Sign out'");
test("#24/#40 logout control tooltip reads 'Log out'", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
await expect(page.locator('[title="Log out"]')).toBeVisible();
});

View file

@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { totalPoCount, closeDb } from "./fixtures";
/**
* Issue #41 the dashboard 'Total Purchase Orders' card shows the correct count.
* Issue #26 that stat card is clickable and links to the history page.
* Both are verified on the generic dashboard (AUDITOR role).
*/
test.afterAll(closeDb);
test("#41 'Total Purchase Orders' card shows the correct unfiltered count", async ({ page }) => {
const expected = await totalPoCount();
await login(page, USERS.AUDITOR);
await page.goto("/dashboard");
const card = page.getByRole("link").filter({ hasText: "Total Purchase Orders" });
await expect(card).toBeVisible();
await expect(card).toContainText(String(expected));
});
test("#26 'Total Purchase Orders' card links to the history page", async ({ page }) => {
await login(page, USERS.AUDITOR);
await page.goto("/dashboard");
await page.getByRole("link").filter({ hasText: "Total Purchase Orders" }).click();
await expect(page).toHaveURL(/\/history/);
});

View file

@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #31 PO history allows selecting MULTIPLE statuses (OR-ed). The status
* filter is a checkbox dropdown; applying two statuses yields two `status` query
* params.
*/
test("#31 history status filter supports multiple OR-ed statuses", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/history");
// Open the status dropdown (button label is "All statuses" / "N statuses").
await page.getByRole("button", { name: /statuses/i }).click();
await page.getByRole("checkbox", { name: "Closed" }).check();
await page.getByRole("checkbox", { name: "Approved" }).check();
await page.getByRole("button", { name: "Apply" }).click();
await expect(page).toHaveURL(/status=CLOSED/);
await expect(page).toHaveURL(/status=MGR_APPROVED/);
});

View file

@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #32 the manager 'Approved This Month' card counts POs approved this month
* regardless of their current status, and clicking it opens history pre-filtered by
* approval date (?approvedFrom=YYYY-MM-01).
*/
test("#32 'Approved This Month' card links to history filtered by approval date", async ({ page }) => {
const now = new Date();
const expectedParam = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
const card = page.getByRole("link").filter({ hasText: "Approved This Month" });
await expect(card).toBeVisible();
await card.click();
await expect(page).toHaveURL(new RegExp(`/history\\?approvedFrom=${expectedParam}`));
});

View file

@ -0,0 +1,15 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #44 the PO line-item unit dropdown must offer months and year(s)
* (previously only days/short units).
*/
test("#44 line-item unit dropdown includes month and year options", async ({ page }) => {
await login(page, USERS.TECH);
await page.goto("/po/new");
// The line-items editor renders at least one unit <select>; assert the new options.
await expect(page.locator('option[value="month"]').first()).toBeAttached();
await expect(page.locator('option[value="year"]').first()).toBeAttached();
});

View file

@ -0,0 +1,18 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #50 the manager approved-spend card uses the rupee symbol and compact
* Indian formatting ( with L / Cr / K), not a dollar sign.
*/
test("#50 'Total Approved Spend' card shows ₹ with compact L/Cr/K formatting", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
const card = page.locator("div").filter({ has: page.getByText("Total Approved Spend", { exact: true }) }).first();
await expect(card).toBeVisible();
// ₹ present, no dollar sign, value matches the compact format (₹2 Cr / ₹49 L / ₹75 K / ₹500).
await expect(card).toContainText("₹");
await expect(card).not.toContainText("$");
await expect(card).toContainText(/₹\s?-?\d[\d.,]*\s?(Cr|L|K)?/);
});

View file

@ -0,0 +1,38 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { approvedPo, closeDb } from "./fixtures";
/**
* Issue #53 Managers can cancel a PO via a confirmation modal that requires a
* reason and typing the word "cancel". This spec verifies the affordance and the
* type-to-confirm guard WITHOUT actually cancelling (non-destructive on staging
* data): it confirms the submit button stays disabled until "cancel" is typed, then
* closes the modal.
*/
test.afterAll(closeDb);
test("#53 Cancel PO modal enforces type-'cancel'-to-confirm", async ({ page }) => {
const po = await approvedPo();
test.skip(!po, "no approved PO in staging data to exercise the cancel control");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await page.getByRole("button", { name: "Cancel PO" }).click();
const submit = page.getByRole("button", { name: "Cancel this PO" });
await expect(submit).toBeVisible();
await expect(submit).toBeDisabled();
// A reason alone is not enough.
await page.getByPlaceholder(/Duplicate order/i).fill("Verification test — not a real cancellation");
await expect(submit).toBeDisabled();
// Typing the confirmation word enables it.
await page.getByPlaceholder("cancel").fill("cancel");
await expect(submit).toBeEnabled();
// Back out without cancelling.
await page.getByRole("button", { name: "Keep PO" }).click();
await expect(submit).toBeHidden();
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { vendorWithCode, closeDb } from "./fixtures";
/**
* Issue #57 /catalogue/vendors (formerly /inventory/vendors) is searchable by
* vendor id/code, and the id is shown next to the name.
*/
test.afterAll(closeDb);
test("#57 vendors are searchable by vendor id, with the id shown next to the name", async ({ page }) => {
const vendor = await vendorWithCode();
test.skip(!vendor, "no vendor with a vendorId code in staging data");
await login(page, USERS.MANAGER);
await page.goto("/catalogue/vendors");
await page.getByPlaceholder(/Search by name, ID, GSTIN/i).fill(vendor!.vendorId!);
// The matching vendor row shows both the name and the id badge.
await expect(page.getByText(vendor!.name).first()).toBeVisible();
await expect(page.getByText(vendor!.vendorId!, { exact: false }).first()).toBeVisible();
});

View file

@ -0,0 +1,31 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #96 sidebar section headings (Purchasing / Crewing / Administration) are
* collapsible, collapsed by default, and act as a single-open accordion (opening one
* collapses the others).
*/
test("#96 sidebar sections are collapsible and single-open", async ({ page }) => {
await login(page, USERS.MANAGER);
// /dashboard is not inside any section, so all sections start collapsed.
await page.goto("/dashboard");
const purchasing = page.getByRole("button", { name: "Purchasing" });
const crewing = page.getByRole("button", { name: "Crewing" });
await expect(purchasing).toBeVisible();
await expect(crewing).toBeVisible();
// Collapsed by default.
await expect(purchasing).toHaveAttribute("aria-expanded", "false");
await expect(crewing).toHaveAttribute("aria-expanded", "false");
// Opening Purchasing expands it.
await purchasing.click();
await expect(purchasing).toHaveAttribute("aria-expanded", "true");
// Opening Crewing collapses Purchasing (single-open accordion).
await crewing.click();
await expect(crewing).toHaveAttribute("aria-expanded", "true");
await expect(purchasing).toHaveAttribute("aria-expanded", "false");
});

132
Docs/TESTING.md Normal file
View file

@ -0,0 +1,132 @@
# Testing
This repo has three test tiers (see `App/CLAUDE.md` → Commands):
| Tier | Tool | Scope | Command |
|---|---|---|---|
| Unit | Vitest (jsdom) | pure functions / components | `pnpm test` |
| Integration | Vitest (node + real DB) | server actions against a Postgres DB | `pnpm test:integration` |
| E2E (local) | Playwright | full UI against a local `pnpm dev` | `pnpm test:e2e` |
This document covers a fourth, purpose-built tier:
## Staging closed-issue verification (`App/tests/staging/`)
A **feature-level** Playwright suite that drives the **running staging instance**
(`pm2 ppms-staging`, port 3200 on pms1 — see [`../automation/README.md`](../automation/README.md) → *Staging*)
to verify that every closed portal issue is actually fixed on the deployed build.
Unlike the local E2E suite it does not start a dev server; it logs in and clicks
through the real staging app, exactly as a user would.
### Why a dedicated tier
Staging runs against `pelagia_test`, a daily mirror of production. That mirror only
contains real `@pelagiamarine.com` users — most are SSO-only and none have a password
we know — so the credentials login can't be used for automated testing. To solve this
without touching production, the refresh seeds **deterministic test users** (one per
role) with known passwords.
### Test users (seeding)
[`App/prisma/seed-test-users.ts`](../App/prisma/seed-test-users.ts) idempotently
upserts one credential-capable login per role on the throwaway `@pelagia.local`
domain (no collision with real accounts):
| Email | Password | Role |
|---|---|---|
| `tech@pelagia.local` | `tech1234` | TECHNICAL |
| `manning@pelagia.local` | `manning1234` | MANNING |
| `accounts@pelagia.local` | `accounts1234` | ACCOUNTS |
| `manager@pelagia.local` | `manager1234` | MANAGER |
| `superuser@pelagia.local` | `super1234` | SUPERUSER |
| `auditor@pelagia.local` | `audit1234` | AUDITOR |
| `admin@pelagia.local` | `admin1234` | ADMIN |
| `site@pelagia.local` | `site1234` | SITE_STAFF |
[`automation/refresh-test-db.sh`](../automation/refresh-test-db.sh) runs this seed
automatically after every daily refresh of `pelagia_test`, so the logins always exist
on staging. To seed manually (e.g. before a one-off run):
```bash
DATABASE_URL="postgresql://…/pelagia_test" pnpm tsx prisma/seed-test-users.ts
```
### Running the suite
From a machine that can reach pms1, open SSH tunnels to the staging app **and** the
DB (the suite reads a few fixture ids straight from `pelagia_test` so it stays stable
across the daily refresh):
```bash
ssh -N -L 3200:localhost:3200 -L 15432:localhost:5432 shad0w@<pms1>
```
Then, from `App/`:
```bash
PLAYWRIGHT_BASE_URL=http://localhost:3200 \
DATABASE_URL="postgresql://pelagia_user:…@localhost:15432/pelagia_test" \
pnpm exec playwright test --config playwright.staging.config.ts
```
- `PLAYWRIGHT_BASE_URL` — the staging app (default `http://localhost:3200`).
- `DATABASE_URL` — the tunnelled staging DB, used only for read-only fixture lookups
(which approved/closed PO to open, expected counts). Every assertion runs against
the live UI.
### What each script verifies (issue → script map)
One spec file per issue; the filename is the mapping. `SKIP` means the staging data
currently has no row to exercise the case (the spec self-skips with a message).
| Issue | Script | Verifies | Result |
|---|---|---|---|
| — | `00-smoke.spec.ts` | staging reachable + all seeded users can log in | PASS |
| #4 | `issue-04-po-date-field.spec.ts` | optional, back/forward-datable PO Date field on the PO form | PASS |
| #5 | `issue-05-approved-date-as-po-date.spec.ts` | approved PO detail shows the approval date as the PO Date | PASS |
| #6 | `issue-06-closed-list-filters.spec.ts` | manager sees all CLOSED POs; submitter's Closed view excludes APPROVED | PASS |
| #8 | `issue-08-export-includes-description.spec.ts` | exported PO includes the line-item optional description | PASS |
| #10 | `issue-10-attachments-grouped.spec.ts` | PO detail groups attachments by type (Submission/Payment/Delivery) | SKIP (no attachment data on staging) |
| #11 | `issue-11-terms-catalogue.spec.ts` | admin T&C catalogue page + dynamic PO terms editor | PASS |
| #12 | `issue-12-approved-this-month-card.spec.ts` | manager 'Approved This Month' card shows the correct live count (was stuck at 0) | PASS |
| #13 | `issue-13-payments-this-month-card.spec.ts` | accounts 'Payments completed this month' card | **KNOWN FAIL — not implemented on staging** |
| #14 | `issue-14-email-to-vendor.spec.ts` | 'Email to vendor' button on an approved PO with a vendor email | PASS |
| #19 | `issue-19-place-of-delivery-dropdown.spec.ts` | Place of Delivery is a dropdown + admin delivery-locations page | PASS |
| #24/#40 | `issue-24-40-logout-tooltip.spec.ts` | logout tooltip reads 'Log out' | **KNOWN FAIL — still 'Sign out' (these were pipeline test issues)** |
| #26/#41 | `issue-26-41-total-po-card.spec.ts` | 'Total Purchase Orders' card count correct (#41) + links to history (#26) | PASS |
| #31 | `issue-31-history-multi-status.spec.ts` | PO history filter accepts multiple OR-ed statuses | PASS |
| #32 | `issue-32-approved-month-clickthrough.spec.ts` | 'Approved This Month' card links to history filtered by approval date | PASS |
| #44 | `issue-44-line-item-units.spec.ts` | line-item unit dropdown includes months and year(s) | PASS |
| #50 | `issue-50-rupee-compact-format.spec.ts` | approved-spend card uses ₹ with compact L/Cr formatting | PASS |
| #53 | `issue-53-cancel-po-modal.spec.ts` | manager Cancel-PO modal with type-'cancel'-to-confirm guard | PASS |
| #57 | `issue-57-vendor-search-catalogue.spec.ts` | /catalogue/vendors searchable by vendor id, id shown next to name | PASS |
| #96 | `issue-96-sidebar-collapsible.spec.ts` | sidebar sections collapsible, collapsed by default, single-open | PASS |
| #104 | `issue-104-history-pagination.spec.ts` | /history items-per-page pagination | PASS |
| #109 | `issue-109-new-po-vendor-search.spec.ts` | new-PO vendor field is a searchable combobox (name + code) | PASS |
| #75/#76/#79/#81/#83/#86 | `crewing-epics.spec.ts` | each crewing epic's primary surface renders for an authorised role | PASS |
### Issues not covered by a staging spec (and why)
| Issue | Reason |
|---|---|
| #1 | "Add CHANGELOG.md" — pipeline bootstrap; verified by the file existing at the repo root, not a runtime feature. |
| #3, #42 | Explicit pipeline / token test issues ("no action needed; close without a fix"). |
| #7 | Inventory-on-approval — the inventory surface is gated by `NEXT_PUBLIC_INVENTORY_ENABLED`, which is **false** on staging, so it is not UI-verifiable there. Covered by the `tests/integration` inventory tests. |
| #17 | GST CAPTCHA extraction — depends on the live external GST portal (GstService); not deterministically testable. |
| #18 | "Prompt to save draft on navigate-away" — a client-side `beforeunload` guard; not reliably driveable in headless Playwright. |
| Crewing deep flows (#77 pipeline, #78 onboarding, #80 PPE, #82 appraisal, #85 sign-off) | State-machine flows covered by the existing integration suites (`tests/integration/applications.test.ts`, `onboarding.test.ts`, `appraisal.test.ts`, `signoff.test.ts`, etc.). The staging suite smoke-checks the epic surfaces render. |
### Findings (verification result)
Running the suite against staging surfaced **two closed issues that are not actually
fixed** on the current build:
- **#13** — the Accounts dashboard has no "Payments completed this month" card (only
"Ready for Payment" and "Payment Queue Value").
- **#24 / #40** — the logout control tooltip still reads "Sign out", not "Log out".
(Both were pipeline / button-simulation test issues, likely closed without a code
change.)
These are encoded as `test.fail()` specs so the suite stays green while the gaps are
recorded; if either is fixed later, its spec flips to passing and flags the
annotation for removal.

View file

@ -74,3 +74,21 @@ if [ -n "$MIG_DIR" ]; then
else
log "No master checkout with migrations found; skipping migrate (test DB has prod schema only)."
fi
# Seed deterministic, credential-capable TEST USERS (one per role) so the staging
# instance can be logged into for end-to-end feature verification. The prod mirror
# only carries real @pelagiamarine.com users (mostly SSO-only, no known password),
# which makes credential login — and therefore the Playwright closed-issue suite
# (App/tests/staging/) — impossible. These @pelagia.local accounts never exist in
# prod, so there is no collision; the seed is idempotent (upsert by email).
# See App/tests/staging/ and Docs/TESTING.md.
if [ -n "$MIG_DIR" ] && [ -f "$MIG_DIR/prisma/seed-test-users.ts" ]; then
log "Seeding test users into $TEST_DB ..."
if ( cd "$MIG_DIR" && DATABASE_URL="$TEST_URL" pnpm tsx prisma/seed-test-users.ts ) >/tmp/seed-test-users.log 2>&1; then
log "Test users seeded."
else
log "WARNING: test-user seed failed; see /tmp/seed-test-users.log"; tail -5 /tmp/seed-test-users.log
fi
else
log "Skipping test-user seed (no checkout with prisma/seed-test-users.ts)."
fi