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;