WFDASH-WEBAPP/e2e/create-flows.spec.ts
SJCoetz 4444842ef5 feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components

Co-authored-by: Orca <help@stably.ai>
2026-08-03 09:33:45 +02:00

331 lines
14 KiB
TypeScript

import { test, expect, type Page } from "@playwright/test";
import { POCKETBASE_URL, HAS_AUTH_FIXTURE } from "./_helpers";
/**
* Sheet create-flow regression tests.
*
* Background
* ----------
* Before the footer-pin fix, every sheet-based create form (company,
* customer, supplier, quote) had its `Create` button inside the
* scrollable form area. When the form was taller than the sheet's
* visible region — which is easy to hit on a default laptop viewport
* with the customer form, and almost guaranteed on the quote form
* with line items — the Create button scrolled below the visible
* area. A click at that position fell through to the sheet's
* backdrop, which dismissed the form without saving. No toast, no
* error, no record.
*
* The fix pins the SheetFooter to the bottom of the sheet via flex
* (`form flex-1 flex flex-col min-h-0` + `SheetFooter shrink-0
* bg-popover`), so the button is always inside the viewport.
*
* What this suite guards
* ----------------------
* Each test:
* 1. Forces a small viewport (800x600) so the form is reliably
* taller than the sheet — the same shape that triggered the
* original bug.
* 2. Asserts the Create button is in the viewport BEFORE clicking.
* Without the fix the button's bounding box would be at
* y > 600 and this assertion would fail.
* 3. Clicks the button (the actual regression we're guarding).
* 4. Verifies the record was created in PocketBase.
* 5. Cleans up via the admin API.
*
* Naming
* ------
* Every record uses a `PI-E2E-*` prefix so the test data is easy to
* spot and clean up if a teardown is missed.
*/
const TEST_EMAIL = process.env.E2E_TEST_EMAIL;
const TEST_PASSWORD = process.env.E2E_TEST_PASSWORD;
const skipReason = "E2E_TEST_EMAIL / E2E_TEST_PASSWORD not set — skipping create-flow specs";
let adminToken: string | null = null;
test.beforeAll(async () => {
if (!HAS_AUTH_FIXTURE) return;
// The test user is the superuser, so this token has full admin
// access — used for verification + cleanup, never for the create
// itself (that always goes through the UI to exercise the form).
const res = await fetch(`${POCKETBASE_URL}/api/collections/_superusers/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: TEST_EMAIL, password: TEST_PASSWORD }),
});
if (!res.ok) {
throw new Error(`admin auth failed: ${res.status} ${await res.text()}`);
}
adminToken = (await res.json() as { token: string }).token;
});
async function countByFilter(collection: string, filter: string): Promise<number> {
if (!adminToken) return 0;
const res = await fetch(
`${POCKETBASE_URL}/api/collections/${collection}/records?filter=${encodeURIComponent(filter)}`,
{ headers: { Authorization: adminToken } },
);
if (!res.ok) return 0;
const data = (await res.json()) as { totalItems?: number };
return data.totalItems ?? 0;
}
async function deleteByFilter(collection: string, filter: string): Promise<void> {
if (!adminToken) return;
const listRes = await fetch(
`${POCKETBASE_URL}/api/collections/${collection}/records?filter=${encodeURIComponent(filter)}`,
{ headers: { Authorization: adminToken } },
);
if (!listRes.ok) return;
const list = (await listRes.json()) as { items?: Array<{ id: string }> };
await Promise.all(
(list.items ?? []).map((item) =>
fetch(`${POCKETBASE_URL}/api/collections/${collection}/records/${item.id}`, {
method: "DELETE",
headers: { Authorization: adminToken! },
}),
),
);
}
async function login(page: Page): Promise<void> {
await page.goto("/login");
await page.getByLabel(/email/i).fill(TEST_EMAIL!);
await page.getByLabel(/password/i).fill(TEST_PASSWORD!);
await page.getByRole("button", { name: /sign in|log in|login/i }).click();
await page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 10_000 });
}
/**
* The core regression check: the submit button must be inside the
* 800x600 viewport. Without the footer-pin fix, every form in this
* suite would land the button at y > 600 and this assertion would
* fire before we ever get to the actual create.
*/
async function expectSubmitInViewport(
page: Page,
name: string | RegExp,
viewportHeight: number,
): Promise<void> {
const btn = page.getByRole("button", { name });
await expect(btn, `${name} button should be visible after opening the form`).toBeVisible();
const box = await btn.boundingBox();
expect(box, `${name} button must have a bounding box`).toBeTruthy();
expect(
box!.y + box!.height,
`${name} must be inside the ${viewportHeight}px viewport ` +
`(was at y=${box!.y}, h=${box!.height}); if this fails the footer ` +
`has likely fallen back into the scrollable form area`,
).toBeLessThanOrEqual(viewportHeight);
}
test.afterAll(async () => {
// Safety-net cleanup. If a worker was killed mid-test (e.g. test
// timeout) the per-test `finally` blocks may not have run. Sweep
// any PI-E2E-* records here so a flaky test run doesn't leave
// test data behind.
await deleteByFilter("quote_items", `quote.quote_number~"PI-E2E"`);
await deleteByFilter("quotes", `rfq_number~"PI-E2E"`);
await deleteByFilter("customers", `customer~"PI-E2E"`);
await deleteByFilter("suppliers", `suppliername~"PI-E2E"`);
await deleteByFilter("companies", `company~"PI-E2E"`);
});
test.describe("Sheet create flows — regression: Create button pinned in viewport", () => {
// Force a small viewport so the form is reliably taller than the
// sheet. With a default 1280x720 viewport the bug would only
// reproduce on the quote form with multiple line items, which
// would make the suite flaky.
test.use({ viewport: { width: 800, height: 600 } });
test("company: click Create persists a new company", async ({ page }) => {
test.skip(!HAS_AUTH_FIXTURE, skipReason);
const stamp = Date.now();
const name = `PI-E2E-COMPANY-${stamp}`;
try {
await login(page);
await page.goto("/companies");
await page.getByRole("button", { name: "Add Company" }).click();
await expectSubmitInViewport(page, "Create Company", 600);
await page.getByPlaceholder("e.g. Acme Corp").fill(name);
await page.getByPlaceholder("Physical or postal address").fill("E2E Test Address, Gauteng");
await page.getByPlaceholder("accounts@acme.co.za").fill("e2e@pi-corb.test");
await page.getByPlaceholder("e.g. 011 555 0100").fill("011 555 0100");
await page.getByRole("button", { name: "Create Company" }).click();
await expect(page.getByRole("heading", { name: "New Company" })).toBeHidden({ timeout: 15_000 });
expect(await countByFilter("companies", `company="${name}"`)).toBe(1);
} finally {
await deleteByFilter("companies", `company="${name}"`);
}
});
test("customer: click Create persists a new customer linked to a company", async ({ page }) => {
test.skip(!HAS_AUTH_FIXTURE, skipReason);
const stamp = Date.now();
const companyName = `PI-E2E-CUST-CO-${stamp}`;
const customerName = `PI-E2E-CUSTOMER-${stamp}`;
try {
// Seed a company to link the customer to. The customer form's
// company picker loads all companies on open, so this needs to
// exist BEFORE we open the form.
const setupRes = await fetch(`${POCKETBASE_URL}/api/collections/companies/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: adminToken! },
body: JSON.stringify({ company: companyName, coordinates: { lon: 0, lat: 0 } }),
});
if (!setupRes.ok) {
throw new Error(`company seed failed: ${setupRes.status} ${await setupRes.text()}`);
}
await login(page);
await page.goto("/customers");
await page.getByRole("button", { name: "Add Customer" }).click();
await expectSubmitInViewport(page, "Create Customer", 600);
await page.getByPlaceholder("Full name or business name").fill(customerName);
await page.getByPlaceholder("email@example.com").fill("e2e-cust@pi-corb.test");
await page.getByPlaceholder("Phone number").fill("011 555 0100");
// Open the Company picker and select the seeded company. The
// CompanySearch is a cmdk-based combobox; typing in the search
// filters the list to the matching option.
await page.getByRole("button", { name: "Company" }).click();
await page.getByPlaceholder("Search companies...").fill(companyName);
await page
.getByRole("option")
.filter({ hasText: companyName })
.first()
.click();
await page.getByRole("button", { name: "Create Customer" }).click();
await expect(page.getByRole("heading", { name: "New Customer" })).toBeHidden({ timeout: 15_000 });
expect(await countByFilter("customers", `customer="${customerName}"`)).toBe(1);
} finally {
await deleteByFilter("customers", `customer="${customerName}"`);
await deleteByFilter("companies", `company="${companyName}"`);
}
});
test("supplier: click Create persists a new supplier", async ({ page }) => {
test.skip(!HAS_AUTH_FIXTURE, skipReason);
const stamp = Date.now();
const name = `PI-E2E-SUPPLIER-${stamp}`;
try {
await login(page);
await page.goto("/suppliers");
await page.getByRole("button", { name: "Add Supplier" }).click();
await expectSubmitInViewport(page, "Create Supplier", 600);
await page.getByPlaceholder("e.g. West Rand Industrial").fill(name);
await page.getByPlaceholder("sales@supplier.co.za").fill("e2e@pi-supplies.test");
await page.getByPlaceholder("Primary contact number").fill("011 555 0200");
await page.getByPlaceholder("Physical or collection address").fill("E2E Collection Address");
await page.getByPlaceholder("Comma-separated tags (e.g. bolts, paint, PPE)").fill("PI-E2E, fasteners");
await page.getByPlaceholder("e.g. 16:30").fill("16:30");
await page.getByRole("button", { name: "Create Supplier" }).click();
await expect(page.getByRole("heading", { name: "New Supplier" })).toBeHidden({ timeout: 15_000 });
expect(await countByFilter("suppliers", `suppliername="${name}"`)).toBe(1);
} finally {
await deleteByFilter("suppliers", `suppliername="${name}"`);
}
});
test("quote: click Create persists a new quote with a line item", async ({ page }) => {
test.skip(!HAS_AUTH_FIXTURE, skipReason);
const stamp = Date.now();
const companyName = `PI-E2E-Q-CO-${stamp}`;
const customerName = `PI-E2E-Q-CUST-${stamp}`;
const rfq = `PI-E2E-RFQ-${stamp}`;
let quoteId: string | null = null;
try {
// Seed a company + customer for the quote. The contact picker
// loads all of them on open, so these must exist first.
const companyRes = await fetch(`${POCKETBASE_URL}/api/collections/companies/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: adminToken! },
body: JSON.stringify({ company: companyName, coordinates: { lon: 0, lat: 0 } }),
});
if (!companyRes.ok) {
throw new Error(`company seed failed: ${companyRes.status} ${await companyRes.text()}`);
}
const companyId = ((await companyRes.json()) as { id: string }).id;
const customerRes = await fetch(`${POCKETBASE_URL}/api/collections/customers/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: adminToken! },
body: JSON.stringify({
customer: customerName,
company: companyId,
dateadded: new Date().toISOString(),
coordinates: { lon: 0, lat: 0 },
}),
});
if (!customerRes.ok) {
throw new Error(`customer seed failed: ${customerRes.status} ${await customerRes.text()}`);
}
await login(page);
await page.goto("/quotes");
await page.getByRole("button", { name: "New Quote" }).click();
await expectSubmitInViewport(page, "Create Quote", 600);
// Select the seeded customer via the combined customer-or-company
// contact picker. The picker is a cmdk combobox; typing in the
// search filters by both company and customer name.
await page.getByRole("button", { name: "Select customer or company" }).click();
await page.getByPlaceholder("Search customer or company...").fill(customerName);
await page
.getByRole("option")
.filter({ hasText: customerName })
.first()
.click();
await page.getByPlaceholder("Optional reference number").fill(rfq);
// Add a line item. The quote form starts empty — without at
// least one item with a description, submit is blocked.
await page.getByRole("button", { name: "Add Item" }).click();
await page.locator('textarea[id^="description-"]').first().fill(
`PI-E2E TEST WELDING RODS 3.2mm (5KG) — ${stamp}`,
);
await page.locator('input[id^="qty-"]').first().fill("5");
await page.getByRole("button", { name: "Create Quote" }).click();
// Quote submit is heavier than the others: it calls
// /api/v1/quotes/next-number, then creates the quote record, then
// creates the line item, then runs any per-item work. On a cold
// dev server the first request can take ~10s while Next compiles
// the API route, so 20s is the safe upper bound.
await expect(page.getByRole("heading", { name: "New Quote" })).toBeHidden({ timeout: 20_000 });
// Verify the quote was created with the right RFQ + customer link.
const quotes = (await (await fetch(
`${POCKETBASE_URL}/api/collections/quotes/records?filter=${encodeURIComponent(`rfq_number="${rfq}"`)}`,
{ headers: { Authorization: adminToken! } },
)).json()) as { items: Array<{ id: string; customer: string; company: string }> };
expect(quotes.items.length, "expected exactly one quote with the test RFQ").toBe(1);
quoteId = quotes.items[0].id;
} finally {
// Delete in reverse FK order: quote items → quote → customer → company.
if (quoteId) {
await deleteByFilter("quote_items", `quote="${quoteId}"`);
await deleteByFilter("quotes", `rfq_number="${rfq}"`);
}
await deleteByFilter("customers", `customer="${customerName}"`);
await deleteByFilter("companies", `company="${companyName}"`);
}
});
});