diff --git a/src/app/(app)/collection-confirm/page.test.tsx b/src/app/(app)/collection-confirm/page.test.tsx index 8d97e15..dc3564c 100644 --- a/src/app/(app)/collection-confirm/page.test.tsx +++ b/src/app/(app)/collection-confirm/page.test.tsx @@ -1,319 +1,380 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { fireEvent, render, screen, waitFor, cleanup } from "@testing-library/react"; - -vi.mock("@/lib/pocketbase", () => ({ - getPocketBase: () => ({ - collection: (name: string) => { - // Per-collection mocks: the union query in `load()` now reads - // BOTH `order_items` (customer) and `stock_purchase_items` - // (stock). The default test data is all customer rows; tests - // that need a stock row mutate `stockRowsState` to push a - // `collected` line in. We keep `stock_purchase_items` and - // `order_items` reads lazy so a per-test change to either - // state is reflected. - if (name === "stock_purchase_items") { - return { - // Mirrors the page's load() filter `status = "collected"` - // — only collected lines are returned. Lines in any - // other state (`collection_pool`, `assigned_to_driver`, - // `booked`) are filtered out, matching the live PB - // query. - getFullList: vi.fn(async () => - stockRowsState.lines.filter((l) => l.status === "collected"), - ), - // Used by the "Book to stock" flow to look up the line - // (stock_item + qty) at booking time. Reads from - // `stockRowsState.lines` so the test can shape the - // returned line. - getOne: vi.fn(async (id: string) => { - const line = stockRowsState.lines.find((l) => l.id === id); - if (!line) throw new Error(`Unknown stock line ${id}`); - return { - id: line.id, - stock_item: "si-1", - quantity: line.order_qty ?? 50, - quantity_received: 0, - cost_price: 12.5, - }; - }), - update: vi.fn(async (id: string, patch: Record) => { - const idx = stockRowsState.lines.findIndex((l) => l.id === id); - if (idx >= 0) stockRowsState.lines[idx] = { ...stockRowsState.lines[idx], ...patch }; - return { id, ...patch }; - }), - }; - } - if (name === "stock_items") { - return { - getOne: vi.fn(async () => ({ id: "si-1", current_qty: 0 })), - update: vi.fn(async () => ({})), - // The surplus-book flow queries the active stock_items - // list to auto-pick a target. The picker test relies - // on this returning an empty list (which falls through - // to the "no match" path and opens the picker). The - // book-to-stock test doesn't need it but the mock has - // to provide it to avoid the surplus-book PICKER - // breaking. - getFullList: vi.fn(async () => []), - }; - } - if (name === "stock_ledger") { - return { create: vi.fn(async () => ({ id: "ledger-1" })) }; - } - if (name === "stock_purchases") { - return { - getOne: vi.fn(async () => ({ status: "sent" })), - // For the auto-derive: this is the list of sibling lines - // we check to decide if the parent PO should flip to - // `received`. The test sets it to "all booked" to verify - // the auto-derive fires. - getFullList: vi.fn(async () => stockRowsState.parentSiblings), - update: vi.fn(async () => ({})), - }; - } - return { - getFullList: vi.fn(async () => [ - { - id: "row-no-surplus", - description: "Wheel hub kit", - order_qty: 10, - qty_collected: 9, - item_status: "collected", - problem_flag: false, - notes: "", - drv_collected_at: "2026-07-07T10:00:00.000Z", - order_item_attachements: ["invoice-1.pdf"], - expand: { - purchase_order: { id: "po-1", po_number: "PO-1", expand: { supplier: { id: "sup-1", suppliername: "Alpha Bearings" } } }, - order: { id: "ord-1", order_number: "SO-1", expand: { company: { company: "Anglo" }, customer: { customer: "North Pit" } } }, - }, - }, - { - id: "row-with-surplus", - description: "20mm cable ties", - order_qty: 50, - qty_collected: 60, // 10 to stock - item_status: "collected", - problem_flag: false, - notes: "", - drv_collected_at: "2026-07-07T10:00:00.000Z", - order_item_attachements: [], - expand: { - purchase_order: { id: "po-1", po_number: "PO-1", expand: { supplier: { id: "sup-1", suppliername: "Alpha Bearings" } } }, - order: { id: "ord-2", order_number: "SO-2", expand: { company: { company: "Anglo" }, customer: { customer: "North Pit" } } }, - }, - }, - { - id: "row-already-booked", - description: "M5 bolts", - order_qty: 100, - qty_collected: 110, - item_status: "collected", - problem_flag: false, - notes: "", - drv_collected_at: "2026-07-07T10:00:00.000Z", - surplus_qty: 10, - stock_booked_from: "stock-1", - order_item_attachements: [], - expand: { - purchase_order: { id: "po-1", po_number: "PO-1", expand: { supplier: { id: "sup-1", suppliername: "Alpha Bearings" } } }, - order: { id: "ord-3", order_number: "SO-3", expand: { company: { company: "Anglo" }, customer: { customer: "North Pit" } } }, - }, - }, - ]), - }; - }, - }), -})); - -// Shared state for the union query so a test can shape the stock -// side by mutating this object. The `vi.mock` factory above reads -// from this same object via closure — by the time the factory runs -// the binding is established, and PB reads happen lazily inside the -// test runs. -const stockRowsState: { - lines: Array>; - parentSiblings: Array<{ id: string; status?: string }>; -} = { - lines: [], - parentSiblings: [], -}; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import Page from "./page"; +import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; + +/* --------------------------------------------------------------------------- + * Test data + * ------------------------------------------------------------------------ */ + +const SUPPLIER = { id: "sup-1", suppliername: "Alpha Bearings" }; +const PO_NUMBER = "PO-1"; +const ORDER_EXPAND = { + company: { company: "Anglo" }, + customer: { customer: "North Pit" }, +}; + +function makeRow(overrides: Partial & { id: string }): ConfirmRow { + const { id, ...rest } = overrides; + return { + id, + sku: overrides.sku ?? `SKU-${id}`, + description: overrides.description ?? `Row ${id}`, + order_qty: overrides.order_qty ?? 10, + qty_collected: overrides.qty_collected ?? 10, + cost_price: overrides.cost_price ?? 45, + item_status: "collected", + problem_flag: false, + notes: "", + drv_collected_at: "2026-07-07T10:00:00.000Z", + source: "customer", + order_item_attachements: overrides.order_item_attachements ?? [], + expand: { + purchase_order: { + id: "po-1", + po_number: PO_NUMBER, + expand: { supplier: SUPPLIER }, + }, + order: { + id: `ord-${id}`, + order_number: `SO-${id}`, + expand: { ...ORDER_EXPAND }, + }, + }, + ...rest, + }; +} + +const rowWheel = makeRow({ + id: "row-1", + sku: "HUB-KIT", + description: "Wheel hub kit", + order_qty: 10, + qty_collected: 9, + cost_price: 45, + order_item_attachements: ["invoice-1.pdf"], +}); + +const rowTies = makeRow({ + id: "row-2", + sku: "CABLE-TIES", + description: "20mm cable ties", + order_qty: 50, + qty_collected: 60, + cost_price: 12, + order_item_attachements: [], +}); + +const baseRows: ConfirmRow[] = [rowWheel, rowTies]; + +/* --------------------------------------------------------------------------- + * Fetch helpers + * ------------------------------------------------------------------------ */ + +function jsonResponse(body: unknown, init: Partial = {}): Response { + return { + ok: true, + status: 200, + json: async () => body, + ...init, + } as Response; +} + +/** + * Extract the URL string from any fetch `input` shape (string, URL, + * or Request). The page always passes a string URL. + */ +function fetchUrl(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +/** + * Default fetch handler: returns `rows` for the collection-confirm GET and + * `{ ok: true }` JSON for everything else (cost edits, confirm submissions). + * Tests that need a different shape override the spy directly. + */ +function buildFetchSpy(rows: ConfirmRow[] = baseRows) { + return vi.spyOn(window, "fetch").mockImplementation(async (input) => { + const url = fetchUrl(input); + if (url === "/api/v1/collection-confirm") { + return jsonResponse(rows); + } + if (typeof url === "string" && url.startsWith("/api/v1/order-items/")) { + return jsonResponse({ ok: true }); + } + return jsonResponse({}); + }); +} + +/* --------------------------------------------------------------------------- + * Tests + * ------------------------------------------------------------------------ */ describe("CollectionConfirmPage", () => { beforeEach(() => { - vi.spyOn(window, "fetch").mockResolvedValue({ - ok: true, - json: async () => [], - } as Response); - // Reset the per-test stock row state so one test can't leak - // into the next. The default is empty (no stock rows) — tests - // that need a stock line push one in via `stockRowsState.lines.push(...)`. - stockRowsState.lines = []; - stockRowsState.parentSiblings = []; + buildFetchSpy(); }); - afterEach(() => { - cleanup(); - }); - - it("renders collected rows with checkboxes and a Return to pool action", async () => { - render(); - await waitFor(() => expect(screen.getByText("Wheel hub kit")).toBeInTheDocument()); - // One checkbox per row; the test data has 3 rows. - expect(screen.getAllByLabelText("Select item").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByRole("button", { name: /return to pool/i }).length).toBeGreaterThan(0); - }); - - it("shows the 'X to stock' chip + confirm button when a row has positive surplus headroom", async () => { - render(); - // Wait for the surplus row to render. - await waitFor(() => expect(screen.getByText("20mm cable ties")).toBeInTheDocument()); - // Chip text matches the brief: "X to stock". - expect(screen.getByText("10 to stock")).toBeInTheDocument(); - // Confirm button with explicit aria-label. - expect( - screen.getByRole("button", { name: /book 10 to stock/i }), - ).toBeInTheDocument(); - }); - - it("does NOT show the chip for rows where collected <= customer qty", async () => { - render(); - await waitFor(() => expect(screen.getByText("Wheel hub kit")).toBeInTheDocument()); - // The hub kit row has no surplus; only the surplus + booked rows - // should render the chip text. The "Book to stock" button is its - // own element so we look at the chip / pill specifically. - expect(screen.getByText("10 to stock")).toBeInTheDocument(); - expect(screen.getByText(/booked 10 to stock/i)).toBeInTheDocument(); - // No chip for the hub kit row. - expect(screen.queryByText(/wheel hub kit.*to stock/i)).not.toBeInTheDocument(); - }); - - it("shows the 'Booked N to stock' pill for already-booked rows", async () => { - render(); - await waitFor(() => expect(screen.getByText("M5 bolts")).toBeInTheDocument()); - // The booked pill renders a static label; clicking it re-opens the picker. - expect(screen.getByText(/booked 10 to stock/i)).toBeInTheDocument(); - }); - - it("the picker shows a Create new tab and pre-fills the form from the order_item", async () => { - // The order_items have no `supplier` flat field, so the auto-pick - // helper returns null and the picker opens whenever the operator - // clicks "Book N to stock". The first such row is the cable-ties - // row (qty_collected=60, order_qty=50, headroom=10). - render(); - const bookButtons = await screen.findAllByRole("button", { - name: /book 10 to stock/i, - }); - fireEvent.click(bookButtons[0]); - // The picker opens; the pick tab is the default. - expect( - await screen.findByRole("button", { name: /pick existing/i }), - ).toBeInTheDocument(); - // Switch to the create tab. - fireEvent.click(screen.getByRole("button", { name: /create new/i })); - // The form renders with the name pre-filled from the order_item - // description. (The cable-ties row's description is "20mm cable ties".) - const nameInput = await screen.findByLabelText(/name/i); - expect(nameInput).toHaveValue("20mm cable ties"); - }); - - it("does not show the Stock badge on customer rows (the test data is all customer)", async () => { - // With the empty `stock_purchase_items` mock, every row is - // `source: "customer"` so no Stock badge should render. - render(); - await screen.findByText("Wheel hub kit"); - expect(screen.queryByText(/^Stock$/)).not.toBeInTheDocument(); - }); - - // ── Stock-line book-to-stock flow (v4 driver integration) ── - // - // The collection-confirm page now reads from BOTH order_items - // and stock_purchase_items. A `collected` stock line shows a - // "Book to stock" button; clicking it runs the audit-before-cache - // flow (ledger row → cache increment → line `booked` → parent - // auto-derive to `received` if all siblings are `booked`). - it("renders the Stock badge + Book to stock button for a collected stock line", async () => { - stockRowsState.lines = [ - { - id: "stock-line-1", - description: "10mm bolts (box of 100)", - order_qty: 50, - quantity: 50, - quantity_received: 0, - cost_price: 12.5, - status: "collected", - stock_purchase_id: "sp-1", - expand: { - purchase: { - id: "sp-1", - purchase_number: "PO26071601STK", - expand: { - supplier: { id: "sup-1", suppliername: "Afriq Lube" }, - }, - }, - stock_item: { id: "si-1", sku: "BOLT-10", name: "10mm bolts" }, - }, - }, - ]; - + it("loads rows via /api/v1/collection-confirm and renders one PoCard per PO", async () => { + const fetchSpy = vi.spyOn(window, "fetch"); render(); - // Wait for the stock line to render. + // PoCard renders after the fetch resolves. await waitFor(() => - expect(screen.getByText("10mm bolts (box of 100)")).toBeInTheDocument(), + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), ); - // Stock badge is rendered. The badge is a span with the - // uppercase text "Stock" inside a `Boxes` icon — query by - // text content. (There's also a `data-od-id="row-stock-badge"` - // on the span; we use the text query for portability.) - expect(screen.getByText(/^Stock$/)).toBeInTheDocument(); + // Initial GET went to the right endpoint with the right headers. + expect(fetchSpy).toHaveBeenCalledWith( + "/api/v1/collection-confirm", + expect.objectContaining({ + headers: { "content-type": "application/json" }, + }), + ); - // The Book to stock button + "ready to book" chip text are - // both present. - expect(screen.getByText("50 ready to book")).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /book 50 to stock/i }), - ).toBeInTheDocument(); + // Both rows are rendered inside the PoCard. + expect(screen.getByText("Wheel hub kit")).toBeInTheDocument(); + expect(screen.getByText("20mm cable ties")).toBeInTheDocument(); - // The PO link should show the stock PO with the STK suffix. - // Use `getAllByText` since the PO may render in multiple - // places (header eyebrow + body link). - expect(screen.getAllByText(/PO26071601STK/).length).toBeGreaterThan(0); + // PO header includes the supplier name. + expect(screen.getByTestId("po-card-title")).toHaveTextContent( + "PO PO-1 — Alpha Bearings", + ); }); - it("does not render a collected stock line in 'booked' state (filtered out by load)", async () => { - // The page's load() filter is `status = "collected"`, so a - // `booked` line shouldn't appear in the list. Verify the - // filter is in effect by pushing a `booked` line. - stockRowsState.lines = [ - { - id: "stock-line-1", - description: "10mm bolts (box of 100)", - order_qty: 50, - quantity: 50, - quantity_received: 50, - cost_price: 12.5, - status: "booked", - stock_purchase_id: "sp-1", - expand: { - purchase: { - id: "sp-1", - purchase_number: "PO26071601STK", - expand: { supplier: { id: "sup-1", suppliername: "Afriq Lube" } }, - }, - stock_item: { id: "si-1", sku: "BOLT-10", name: "10mm bolts" }, - }, - }, + it("renders a photo strip chip per distinct filename; clicking opens the file via window.open", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const photoRows: ConfirmRow[] = [ + makeRow({ + id: "row-photo-1", + description: "Hub photo row", + order_item_attachements: ["invoice-1.pdf"], + }), + makeRow({ + id: "row-photo-2", + description: "Ties photo row", + order_item_attachements: ["invoice-2.pdf"], + }), ]; + buildFetchSpy(photoRows); + + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), + ); + + // Two chips, one per filename. + expect( + screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Invoice 2 invoice-2\.pdf/i }), + ).toBeInTheDocument(); + + // Clicking a chip opens the file-stream URL in a new tab. + await user.click( + screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }), + ); + expect(openSpy).toHaveBeenCalledWith( + "/api/v1/files/order_items/row-photo-1/invoice-1.pdf", + "_blank", + "noopener,noreferrer", + ); + }); + + it("renders the photo strip empty state when no rows in the PO have attachments", async () => { + const noPhotoRows: ConfirmRow[] = [ + makeRow({ id: "row-empty-1", order_item_attachements: [] }), + makeRow({ id: "row-empty-2", order_item_attachements: [] }), + ]; + buildFetchSpy(noPhotoRows); render(); - // Customer rows render normally; the booked stock line is - // filtered out. - await waitFor(() => screen.getByText("Wheel hub kit")); - expect(screen.queryByText("10mm bolts (box of 100)")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), + ); + + expect(screen.getByText(/No invoice photo attached/i)).toBeInTheDocument(); + // No chip rendered when there are no attachments. + expect(screen.queryByRole("button", { name: /^Invoice \d+/i })).not.toBeInTheDocument(); + }); + + it("clicking the cost pencil swaps the cell to an input pre-filled with the current cost", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), + ); + + // Wheel hub kit cost is 45 → displayed as R45.00. + expect(screen.getByText("R45.00")).toBeInTheDocument(); + + // Click the pencil for row-1. + await user.click(screen.getByTestId("cost-edit-row-1")); + + // The input appears and is pre-filled with the current cost. + const input = screen.getByTestId("cost-input-row-1"); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue(45); + }); + + it("pressing Enter on the cost input POSTs /api/v1/order-items/{id}/cost and updates the cell", async () => { + const fetchSpy = vi.spyOn(window, "fetch"); + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), + ); + + // Reset spy after the initial GET to isolate the cost POST. + fetchSpy.mockClear(); + + await user.click(screen.getByTestId("cost-edit-row-1")); + const input = screen.getByTestId("cost-input-row-1"); + await user.clear(input); + await user.type(input, "55.50"); + await user.keyboard("{Enter}"); + + // The cost endpoint was called with the right URL + method + headers. + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + "/api/v1/order-items/row-1/cost", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/json" }, + }), + ), + ); + + // Verify the parsed body shape — using `expect.stringContaining` / + // asymmetric matchers inside JSON.stringify round-trips poorly, so + // we parse the body back out and assert on the structured shape. + const costCall = fetchSpy.mock.calls.find(([arg]) => { + return fetchUrl(arg) === "/api/v1/order-items/row-1/cost"; + }); + expect(costCall).toBeDefined(); + const costBody = JSON.parse(String((costCall![1] as RequestInit).body)); + expect(costBody).toEqual({ + cost_price: 55.5, + actor: expect.objectContaining({ id: expect.any(String) }), + }); + + // Cell now renders the new cost (R55.50). + await waitFor(() => + expect(screen.getByText("R55.50")).toBeInTheDocument(), + ); + }); + + it("pressing Esc on the cost input reverts without calling the endpoint", async () => { + const fetchSpy = vi.spyOn(window, "fetch"); + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), + ); + + fetchSpy.mockClear(); + + await user.click(screen.getByTestId("cost-edit-row-1")); + const input = screen.getByTestId("cost-input-row-1"); + await user.clear(input); + await user.type(input, "999"); + await user.keyboard("{Escape}"); + + // No POST to the cost endpoint. + const costCalls = fetchSpy.mock.calls.filter(([arg]) => + fetchUrl(arg).includes("/cost"), + ); + expect(costCalls).toHaveLength(0); + + // Input has been removed, original cost (R45.00) is back on the cell. + expect(screen.queryByTestId("cost-input-row-1")).not.toBeInTheDocument(); + expect(screen.getByText("R45.00")).toBeInTheDocument(); + }); + + it("submits the confirm dialog with cost overrides and POSTs /api/v1/order-items/confirm", async () => { + const fetchSpy = vi.spyOn(window, "fetch"); + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), + ); + + // 1) Apply a cost override on row-1 (Enter triggers the cost POST + + // records the override in local state for the confirm submit). + await user.click(screen.getByTestId("cost-edit-row-1")); + const input = screen.getByTestId("cost-input-row-1"); + await user.clear(input); + await user.type(input, "60"); + await user.keyboard("{Enter}"); + await waitFor(() => + expect(screen.getByText("R60.00")).toBeInTheDocument(), + ); + + // 2) Open the confirm dialog by clicking the PoCard's Confirm button. + fetchSpy.mockClear(); + await user.click(screen.getByTestId("po-card-confirm-PO-1")); + + // Dialog title is rendered. Scope to the dialog so we don't collide + // with the PoCard button label "Confirm for invoicing". + const dialog = await screen.findByRole("dialog"); + await waitFor(() => + expect( + within(dialog).getByRole("heading", { name: /Confirm for invoicing/i }), + ).toBeInTheDocument(), + ); + + // Summary line shows "1 cost override applied". + expect( + within(dialog).getByText(/1 cost override applied/i), + ).toBeInTheDocument(); + + // 3) Fill in the invoice date + number and submit. + const dateInput = within(dialog).getByLabelText(/Supplier invoice date/i); + await user.clear(dateInput); + await user.type(dateInput, "2026-08-03"); + + const numberInput = within(dialog).getByLabelText(/Supplier invoice number/i); + await user.type(numberInput, "INV-2026-08-001"); + + // 4) Click the Confirm button in the dialog footer. + await user.click( + within(dialog).getByRole("button", { name: /^Confirm 2 items$/i }), + ); + + // 5) The confirm endpoint was called with the right URL + method + headers. + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + "/api/v1/order-items/confirm", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/json" }, + }), + ), + ); + + // Verify the parsed body shape. + const confirmCall = fetchSpy.mock.calls.find( + ([arg]) => fetchUrl(arg) === "/api/v1/order-items/confirm", + ); + expect(confirmCall).toBeDefined(); + const body = JSON.parse(String((confirmCall![1] as RequestInit).body)); + expect(body).toEqual({ + itemIds: ["row-1", "row-2"], + invoice: { date: "2026-08-03", number: "INV-2026-08-001" }, + costOverrides: { "row-1": 60 }, + actor: expect.objectContaining({ id: expect.any(String) }), + }); }); });