test(repairs-app): pin the book-in session header and the two-step photo contract
This commit is contained in:
parent
0d9848e90e
commit
c4e97c3cd5
3 changed files with 271 additions and 19 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue