From c4e97c3cd5bbee0ee6c4d54bcfc4da8941649b47 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 6 Nov 2026 18:33:29 +0200 Subject: [PATCH] test(repairs-app): pin the book-in session header and the two-step photo contract --- .../repairs-app/book-in-form.test.tsx | 234 +++++++++++++++++- .../repairs-app/book-in-form.tsx | 42 +++- .../repairs-app/repair-board.test.tsx | 14 ++ 3 files changed, 271 insertions(+), 19 deletions(-) diff --git a/src/app/(repairs-app)/repairs-app/book-in-form.test.tsx b/src/app/(repairs-app)/repairs-app/book-in-form.test.tsx index 8015807..0f41ccd 100644 --- a/src/app/(repairs-app)/repairs-app/book-in-form.test.tsx +++ b/src/app/(repairs-app)/repairs-app/book-in-form.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { BookInForm } from "./book-in-form"; +import type { Customer, Supplier } from "@/lib/types"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), @@ -10,17 +11,162 @@ vi.mock("@/lib/pocketbase", () => ({ getPocketBase: () => ({ authStore: { token: "t" } }), })); -vi.mock("@/components/ui/customer-search", () => ({ - CustomerSearch: () =>
, -})); +/* + * The customer and supplier pickers are NOT mocked here. + * + * The request contract has to be proven end to end: the operator picks a + * real customer, the form sends that customer's relation id, and the + * repair and its photos go up with the browser session attached. A stub + * picker would let the form send an id no picker can ever produce, so the + * real CustomerSearch / SupplierSearch are used. + */ -vi.mock("@/components/ui/supplier-search", () => ({ - SupplierSearch: () =>
, -})); +/** One customer the picker can find. It has an email — one without is a placeholder. */ +const CUSTOMERS = [ + { + id: "cust-1", + customer: "Borer Plant", + email: "site@borerplant.co.za", + number: "010 555 0001", + company: "co-borer", + expand: { company: { id: "co-borer", company: "Borer Plant (Pty) Ltd" } }, + }, +] as unknown as Customer[]; + +const SUPPLIERS = [ + { + id: "sup-1", + suppliername: "Bolt & Nut", + email: "trade@boltandnut.co.za", + suppliernumber: "S-100", + supply_tags: "welding", + }, +] as unknown as Supplier[]; + +type Call = { url: string; method: string; init: RequestInit | undefined }; + +let calls: Call[] = []; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** + * A fetch that records what was asked for, so the REQUEST can be + * asserted — not just that something was sent. + */ +function mockFetch() { + calls = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, method: init?.method ?? "GET", init }); + if (url.includes("/files/photos")) { + return jsonResponse({ id: "rep-1" }); + } + if (url.includes("/api/v1/repairs/form-data")) { + return jsonResponse({ customers: CUSTOMERS, suppliers: SUPPLIERS }); + } + if (url.endsWith("/api/v1/repairs")) { + return jsonResponse({ id: "rep-1", repair_number: "R-0007" }, 201); + } + return jsonResponse({ error: "not found" }, 404); + }), + ); +} + +/** A header value, whatever shape the caller used to send it. */ +function headerValue( + init: RequestInit | undefined, + name: string +): string | undefined { + const headers = init?.headers; + if (!headers) return undefined; + if (typeof (headers as Headers).get === "function") { + return (headers as Headers).get(name) ?? undefined; + } + const record = headers as Record; + const key = Object.keys(record).find( + (k) => k.toLowerCase() === name.toLowerCase() + ); + return key === undefined ? undefined : record[key]; +} + +/** Every header name on the request, lowercased. */ +function headerNames(init: RequestInit | undefined): string[] { + const headers = init?.headers; + if (!headers) return []; + if (typeof (headers as Headers).get === "function") { + return Array.from((headers as Headers).keys()).map((h) => h.toLowerCase()); + } + return Object.keys(headers as Record).map((h) => + h.toLowerCase() + ); +} + +function callTo(urlPart: string, method?: string): Call | undefined { + return calls.find( + (c) => c.url.includes(urlPart) && (method === undefined || c.method === method) + ); +} + +/** + * Book a machine in the way the operator does: pick the customer, say + * what the machine is, take a photo, press the button. + */ +async function bookInMachine() { + render(); + + // The lists load when the operator asks for them. + fireEvent.click(screen.getByRole("button", { name: /choose the customer/i })); + fireEvent.click(await screen.findByRole("button", { name: "Customer" })); + fireEvent.click(await screen.findByRole("option", { name: /borer plant/i })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Customer" })).toHaveTextContent( + /borer plant/i + ) + ); + + fireEvent.change(screen.getByLabelText(/what is the machine/i), { + target: { value: "Welding machine" }, + }); + fireEvent.change(screen.getByLabelText(/serial number/i), { + target: { value: "WM-9" }, + }); + fireEvent.change( + screen.getByLabelText(/what does the customer say is wrong/i), + { target: { value: "Will not strike" } } + ); + fireEvent.change(screen.getByLabelText(/photos of the machine/i), { + target: { + files: [new File(["img"], "machine.jpg", { type: "image/jpeg" })], + }, + }); + + fireEvent.click(screen.getByRole("button", { name: /^book in$/i })); + + await waitFor( + () => expect(callTo("/files/photos")).toBeDefined(), + { timeout: 2000 } + ); +} describe("BookInForm", () => { beforeEach(() => { vi.unstubAllGlobals(); + mockFetch(); + // Base UI's popover measures its trigger. + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); }); it("labels every field in plain words", () => { @@ -65,4 +211,80 @@ describe("BookInForm", () => { await waitFor(() => expect(post).not.toHaveBeenCalled()); expect(screen.getByText(/at least one photo/i)).toBeTruthy(); }); + + it("sends the browser session on every request it makes", async () => { + // Task 1 made all three endpoints session-gated. This is the screen + // that WRITES, so without the header the repairs person cannot book a + // machine in at all. + await bookInMachine(); + + const lookup = callTo("/api/v1/repairs/form-data"); + const create = callTo("/api/v1/repairs", "POST"); + const upload = callTo("/files/photos"); + + expect(lookup).toBeDefined(); + expect(create).toBeDefined(); + expect(upload).toBeDefined(); + expect(headerValue(lookup?.init, "authorization")).toBe("Bearer t"); + expect(headerValue(create?.init, "authorization")).toBe("Bearer t"); + expect(headerValue(upload?.init, "authorization")).toBe("Bearer t"); + }); + + it("creates the repair with the fields and no photos, then uploads the photo", async () => { + await bookInMachine(); + + const create = callTo("/api/v1/repairs", "POST"); + const body = JSON.parse(String(create?.init?.body)) as Record< + string, + unknown + >; + + // The create carries the fields — and NO photos. That field holds the + // filenames of files uploaded already, never the bytes. + expect(Object.keys(body).sort()).toEqual([ + "customer", + "fault_description", + "item_description", + "serial_no", + "supplier", + "supplier_scope", + ]); + expect(body.photos).toBeUndefined(); + // The customer is a relation id, not a name. + expect(body.customer).toBe("cust-1"); + expect(body.item_description).toBe("Welding machine"); + expect(body.serial_no).toBe("WM-9"); + expect(body.fault_description).toBe("Will not strike"); + + // The photo goes up on its own, as multipart, under the field name the + // route reads. + const upload = callTo("/files/photos"); + expect(upload?.method).toBe("POST"); + expect(upload?.url).toBe("/api/v1/repairs/rep-1/files/photos"); + const form = upload?.init?.body as FormData; + expect(form).toBeInstanceOf(FormData); + const photo = form.get("photos"); + expect(photo).toBeInstanceOf(File); + expect((photo as File).name).toBe("machine.jpg"); + // No Content-Type — the browser sets the multipart boundary, and a + // content type set by hand breaks it. + expect(headerNames(upload?.init)).not.toContain("content-type"); + }); + + it("says which list failed to load", async () => { + // The operator pressed ONE button. "Could not load the customer list" + // on the supplier button answers the wrong question. + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}", { status: 500 })), + ); + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose the supplier/i })); + + expect( + await screen.findByText(/could not load the supplier list/i), + ).toBeTruthy(); + expect(screen.queryByText(/could not load the customer list/i)).toBeNull(); + }); }); diff --git a/src/app/(repairs-app)/repairs-app/book-in-form.tsx b/src/app/(repairs-app)/repairs-app/book-in-form.tsx index 9688e71..d97bec4 100644 --- a/src/app/(repairs-app)/repairs-app/book-in-form.tsx +++ b/src/app/(repairs-app)/repairs-app/book-in-form.tsx @@ -69,7 +69,17 @@ export function BookInForm() { }); const [lookupsLoaded, setLookupsLoaded] = useState(false); const [lookupsLoading, setLookupsLoading] = useState(false); - const [lookupsError, setLookupsError] = useState(null); + /** + * Which list failed to load, and what to say about it. + * + * The operator pressed ONE button, so the message names the list that + * actually failed — "the customer list" and "the supplier list" are + * two different asks and the error belongs beside the ask. + */ + const [lookupsError, setLookupsError] = useState<{ + which: "customer" | "supplier"; + message: string; + } | null>(null); /** * Set the moment the repair exists. If a photo then fails to upload @@ -78,15 +88,16 @@ export function BookInForm() { */ const [createdRepairId, setCreatedRepairId] = useState(null); - async function loadLookups() { + async function loadLookups(which: "customer" | "supplier") { setLookupsLoading(true); setLookupsError(null); + const listName = which === "customer" ? "customer list" : "supplier list"; try { const res = await fetch("/api/v1/repairs/form-data", { headers: authHeaders(), }); if (!res.ok) { - throw new Error("Could not load the customer list."); + throw new Error("Could not load the " + listName + "."); } const data = (await res.json()) as Partial; setLookups({ @@ -94,12 +105,12 @@ export function BookInForm() { suppliers: data.suppliers ?? [], }); setLookupsLoaded(true); - } catch (err: unknown) { - setLookupsError( - err instanceof Error - ? err.message - : "Could not load the customer list.", - ); + } catch { + // Say which list failed, not what went wrong underneath. + setLookupsError({ + which, + message: "Could not load the " + listName + ".", + }); } finally { setLookupsLoading(false); } @@ -221,14 +232,14 @@ export function BookInForm() { variant="outline" className="min-h-[56px] w-full text-base" disabled={lookupsLoading} - onClick={() => void loadLookups()} + onClick={() => void loadLookups("customer")} > {lookupsLoading ? "Loading…" : "Choose the customer"} )} - {lookupsError ? ( + {lookupsError?.which === "customer" ? (

- {lookupsError} + {lookupsError.message}

) : null}
@@ -299,11 +310,16 @@ export function BookInForm() { variant="outline" className="min-h-[56px] w-full text-base" disabled={lookupsLoading} - onClick={() => void loadLookups()} + onClick={() => void loadLookups("supplier")} > {lookupsLoading ? "Loading…" : "Choose the supplier"} )} + {lookupsError?.which === "supplier" ? ( +

+ {lookupsError.message} +

+ ) : null}

Optional — the office can pick the supplier later.

diff --git a/src/app/(repairs-app)/repairs-app/repair-board.test.tsx b/src/app/(repairs-app)/repairs-app/repair-board.test.tsx index 24d4fbf..0e65574 100644 --- a/src/app/(repairs-app)/repairs-app/repair-board.test.tsx +++ b/src/app/(repairs-app)/repairs-app/repair-board.test.tsx @@ -270,6 +270,20 @@ describe("RepairBoard", () => { expect(listCalls()[before].url).toContain("search=HP-2"); }); + it("refreshes the counts when the status filter changes", async () => { + // The counts are global, but the status filter is part of what the + // chips say — leaving them stale after a filter change would show a + // count that does not match the list under it. + await renderBoard(); + const countsBefore = countsCalls().length; + + fireEvent.click(chips().getByText(/With supplier/)); + + await waitFor(() => expect(countsCalls().length).toBe(countsBefore + 1), { + timeout: 2000, + }); + }); + it("does not refetch the counts when only the search text changes", async () => { await renderBoard(); const countsBefore = countsCalls().length;