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 { 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 { 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 { 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 { 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}"`); } }); });