test(collection-confirm): update page tests for PoCard layout

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 01:33:31 +02:00
parent f7d3032438
commit 1edaf36023

View file

@ -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<string, unknown>) => {
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<Record<string, unknown>>;
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<ConfirmRow> & { 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> = {}): 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(<Page />);
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(<Page />);
// 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(<Page />);
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(<Page />);
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(<Page />);
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(<Page />);
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(<Page />);
// 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(<Page />);
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(<Page />);
// 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(<Page />);
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(<Page />);
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(<Page />);
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(<Page />);
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) }),
});
});
});