import { test, expect, HAS_AUTH_FIXTURE } from "./_helpers"; import PocketBase from "pocketbase"; import { randomUUID } from "node:crypto"; const POCKETBASE_URL = process.env.E2E_POCKETBASE_URL ?? "http://127.0.0.1:8090"; /** * End-to-end coverage for the Management section: add a Company, then a * Customer linked to that company (via the searchable combobox + rep * users dropdown + lat/lng inputs), then a Supplier. Verifies the new * field shapes (geoPoint + relations) round-trip through the live PB. * * Requires the same auth fixture as `auth.spec.ts` — a seeded * PocketBase user with E2E_TEST_EMAIL / E2E_TEST_PASSWORD. Without * that we skip the whole file rather than silently passing. * * Cleanup: each spec records the ids it created into a per-test * `cleanup` array and deletes them in afterEach. The auth user itself * is created by the existing test fixture (see e2e/_helpers.ts). */ test.describe("management flow", () => { test.skip(!HAS_AUTH_FIXTURE, "E2E_TEST_EMAIL / E2E_TEST_PASSWORD not set — skipping management specs"); // Track ids we create per-test for cleanup. const cleanup: { collection: string; id: string }[] = []; test.afterEach(async ({ request }) => { // Use the Next.js dev server's admin PB via /api/mcp? No — MCP // doesn't expose a delete. Use the test page context: easiest is // to sign in as the admin PB directly. const admin = new PocketBase(POCKETBASE_URL); await admin.admins.authWithPassword( process.env.PB_ADMIN_EMAIL!, process.env.PB_ADMIN_PASSWORD! ); for (const { collection, id } of cleanup.splice(0)) { try { await admin.collection(collection).delete(id); } catch { // Best-effort — already-deleted rows are fine. } } }); test("add company → customer → supplier via the UI", async ({ page }) => { const stamp = randomUUID().slice(0, 8); const companyName = `PW-CO-${stamp}`; const customerName = `PW-CU-${stamp}`; const supplierName = `PW-SU-${stamp}`; // ── Sign in ─────────────────────────────────────────────────── await page.goto("/login"); await page.getByLabel(/email/i).fill(process.env.E2E_TEST_EMAIL!); await page.getByLabel(/password/i).fill(process.env.E2E_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 }); // ── Add Company ────────────────────────────────────────────── await page.goto("/companies"); await expect(page.getByRole("heading", { name: /companies/i })).toBeVisible(); await page.getByRole("button", { name: /add company/i }).click(); // The Sheet form opens. await page.getByLabel(/company name/i).fill(companyName); await page.getByLabel(/address/i).fill("1 PW Test Road"); await page.getByLabel(/vendor number/i).fill(`PW-V-${stamp}`); // Lat / lng inputs — leave empty (PB default {0,0} → UI masks as empty). await page.getByRole("button", { name: /create company/i }).click(); await expect(page.getByText(companyName)).toBeVisible({ timeout: 10_000 }); // Track for cleanup. We re-read the id from PB by filter. const admin = new PocketBase(POCKETBASE_URL); await admin.admins.authWithPassword( process.env.PB_ADMIN_EMAIL!, process.env.PB_ADMIN_PASSWORD! ); const companyRows = await admin.collection("companies").getFullList({ filter: `company = "${companyName}"`, }); expect(companyRows).toHaveLength(1); cleanup.push({ collection: "companies", id: companyRows[0].id }); // ── Add Customer linked to the company ─────────────────────── await page.goto("/customers"); await expect(page.getByRole("heading", { name: /customers/i })).toBeVisible(); await page.getByRole("button", { name: /add customer/i }).click(); await page.getByLabel(/customer name/i).fill(customerName); await page.getByLabel(/^email$/i).fill(`pw-cu-${stamp}@example.com`); await page.getByLabel(/^phone$/i).fill("0123456789"); // Company combobox — opens popover, types to filter, picks the row. await page.getByRole("button", { name: /company/i, exact: true }).click(); await page.getByPlaceholder(/search companies/i).fill(companyName); await page.getByRole("option", { name: new RegExp(companyName) }).click(); // Rep users dropdown — opens, waits for the lazy fetch, picks the // first user. await page.getByRole("button", { name: /user/i, exact: false }).click(); // The combobox shows "Loading reps…" until /api/v1/users/lookup // returns; wait for at least one CommandItem option. await page.waitForSelector('[cmdk-item], [role="option"]', { timeout: 10_000 }); await page.locator('[cmdk-item], [role="option"]').first().click(); // Lat / lng — Johannesburg coords. await page.getByLabel(/latitude/i).fill("-26.2041"); await page.getByLabel(/longitude/i).fill("28.0473"); await page.getByRole("button", { name: /create customer/i }).click(); await expect(page.getByText(customerName)).toBeVisible({ timeout: 10_000 }); const customerRows = await admin.collection("customers").getFullList({ filter: `customer = "${customerName}"`, }); expect(customerRows).toHaveLength(1); const customer = customerRows[0]; cleanup.push({ collection: "customers", id: customer.id }); // Assert the relations + geoPoint persisted correctly. expect(customer.company).toBe(companyRows[0].id); expect(customer.rep).toBeTruthy(); // any real user id expect(customer.coordinates).toEqual({ lon: 28.0473, lat: -26.2041 }); // Customer detail page should render the expanded company name in // the header subtitle. await page.goto(`/customers/${customer.id}`); await expect(page.getByRole("heading", { name: customerName })).toBeVisible(); await expect(page.getByText(companyName)).toBeVisible(); // ── Add Supplier (unchanged fields — sanity check) ─────────── await page.goto("/suppliers"); await page.getByRole("button", { name: /add supplier|new supplier/i }).click(); // The supplier form is a Sheet (older pattern); use placeholder // text to find the inputs. await page.getByPlaceholder(/supplier.*name|name.*supplier/i).first().fill(supplierName).catch(async () => { // Fallback: label-based. await page.getByLabel(/supplier name/i).fill(supplierName); }); await page.getByRole("button", { name: /create supplier|add supplier/i }).click(); await expect(page.getByText(supplierName)).toBeVisible({ timeout: 10_000 }); const supplierRows = await admin.collection("suppliers").getFullList({ filter: `suppliername = "${supplierName}"`, }); expect(supplierRows).toHaveLength(1); cleanup.push({ collection: "suppliers", id: supplierRows[0].id }); }); test("rejects partial coordinates on customer form", async ({ page }) => { await page.goto("/login"); await page.getByLabel(/email/i).fill(process.env.E2E_TEST_EMAIL!); await page.getByLabel(/password/i).fill(process.env.E2E_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 }); await page.goto("/customers"); await page.getByRole("button", { name: /add customer/i }).click(); await page.getByLabel(/customer name/i).fill(`PW-PARTIAL-${randomUUID().slice(0, 6)}`); // Fill only latitude, not longitude. await page.getByLabel(/latitude/i).fill("-26.2041"); await page.getByRole("button", { name: /create customer/i }).click(); // Should NOT save; should surface a validation error. await expect(page.getByText(/enter both latitude and longitude/i)).toBeVisible({ timeout: 5_000, }); // Sheet stays open. await expect(page.getByRole("button", { name: /create customer/i })).toBeVisible(); }); });