test(repairs-app): pin the book-in session header and the two-step photo contract

This commit is contained in:
Dave 2026-11-06 18:33:29 +02:00
parent 0d9848e90e
commit c4e97c3cd5
3 changed files with 271 additions and 19 deletions

View file

@ -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: () => <div data-testid="customer-search" />,
}));
/*
* 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: () => <div data-testid="supplier-search" />,
}));
/** 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<string, string>;
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<string, string>).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(<BookInForm />);
// 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(<BookInForm />);
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();
});
});

View file

@ -69,7 +69,17 @@ export function BookInForm() {
});
const [lookupsLoaded, setLookupsLoaded] = useState(false);
const [lookupsLoading, setLookupsLoading] = useState(false);
const [lookupsError, setLookupsError] = useState<string | null>(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<string | null>(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<Lookups>;
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"}
</Button>
)}
{lookupsError ? (
{lookupsError?.which === "customer" ? (
<p className="rounded-md border border-destructive/40 p-3 text-sm">
{lookupsError}
{lookupsError.message}
</p>
) : null}
</div>
@ -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"}
</Button>
)}
{lookupsError?.which === "supplier" ? (
<p className="rounded-md border border-destructive/40 p-3 text-sm">
{lookupsError.message}
</p>
) : null}
<p className="text-sm text-muted-foreground">
Optional — the office can pick the supplier later.
</p>

View file

@ -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;