fix(repairs): pin the transition guard's wiring, test the office pages, and clear the review findings

This commit is contained in:
Dave 2026-11-06 08:46:21 +02:00
parent 283d970cb0
commit 0ff6a2a3a6
6 changed files with 372 additions and 6 deletions

View file

@ -0,0 +1,166 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}));
import Page from "./page";
/* ---------------------------------------------------------------------------
* Test data
* ------------------------------------------------------------------------ */
const REPAIR = {
id: "rep1",
repair_number: "REP-2026-11-001",
customer: "cust1",
company: "co1",
order: "",
supplier: "sup1",
status: "assessing",
item_description: "Welding machine",
fault_description: "Will not strike",
supplier_scope: "Strip and quote the strike mechanism",
notes: "",
serial_no: "SN-12345",
quote_ref: "",
quote_amount: 0,
quote_date: "",
purchase_order_ref: "",
signed_request: "",
photos: [],
quote: [],
purchase_order: [],
repair_request: [],
created: "2026-11-05 09:00:00.000Z",
updated: "2026-11-05 09:00:00.000Z",
expand: {
customer: { id: "cust1", customer: "Acme Mining" },
supplier: { id: "sup1", suppliername: "Alpha Bearings" },
order: null,
},
};
/* ---------------------------------------------------------------------------
* Fetch helpers
* ------------------------------------------------------------------------ */
function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as Response;
}
function fetchUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
function buildFetchSpy() {
return vi.spyOn(window, "fetch").mockImplementation(async (input) => {
const url = fetchUrl(input);
if (url === "/api/v1/repairs/form-data") {
return jsonResponse({ customers: [], suppliers: [], orders: [] });
}
if (url === "/api/v1/repairs/rep1") {
return jsonResponse(REPAIR);
}
return jsonResponse({});
});
}
/* ---------------------------------------------------------------------------
* Tests
* ------------------------------------------------------------------------ */
describe("RepairDetailPage", () => {
beforeEach(() => {
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
window.HTMLElement.prototype.scrollIntoView = vi.fn();
buildFetchSpy();
});
it("shows the shared status label, not the raw status", async () => {
render(<Page params={Promise.resolve({ id: "rep1" })} />);
await waitFor(() =>
expect(screen.getByText("REP-2026-11-001")).toBeInTheDocument(),
);
// "assessing" is the raw PocketBase value — the office reads
// "With supplier", the label from @/lib/repair-status.
expect(screen.getByText("With supplier")).toBeInTheDocument();
expect(screen.queryAllByText("assessing")).toHaveLength(0);
});
it("labels the supplier scope the same way in read mode and edit mode", async () => {
render(<Page params={Promise.resolve({ id: "rep1" })} />);
await waitFor(() =>
expect(screen.getByText("REP-2026-11-001")).toBeInTheDocument(),
);
expect(
screen.getByText("What the supplier must look at"),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Edit" }));
await waitFor(() =>
expect(screen.getByText("What the supplier must look at")).toBeInTheDocument(),
);
});
it("offers every status with the shared label", async () => {
render(<Page params={Promise.resolve({ id: "rep1" })} />);
await waitFor(() =>
expect(screen.getByText("REP-2026-11-001")).toBeInTheDocument(),
);
await userEvent.click(screen.getByRole("button", { name: "Edit" }));
await waitFor(() =>
expect(screen.getByRole("combobox")).toBeInTheDocument(),
);
await userEvent.click(screen.getByRole("combobox"));
await waitFor(() =>
expect(screen.getAllByRole("option").length).toBeGreaterThan(0),
);
const labels = screen.getAllByRole("option").map((o) => o.textContent);
expect(labels).toEqual([
"Booked in",
"With supplier",
"Quote in",
"Approved",
"On the bench",
"Done",
"Cancelled",
]);
});
});

View file

@ -762,7 +762,7 @@ export default function RepairDetailPage({
</div>
<div className="col-span-2">
<DetailRow
label="Supplier to look at"
label="What the supplier must look at"
value={repair.supplier_scope}
/>
</div>

View file

@ -0,0 +1,149 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}));
import Page from "./page";
/* ---------------------------------------------------------------------------
* Test data
* ------------------------------------------------------------------------ */
const REPAIR = {
id: "rep1",
repair_number: "REP-2026-11-001",
customer: "cust1",
company: "co1",
order: "",
supplier: "sup1",
status: "assessing",
item_description: "Welding machine",
fault_description: "Will not strike",
notes: "",
serial_no: "SN-12345",
photos: [],
quote: [],
purchase_order: [],
repair_request: [],
expand: {
customer: { id: "cust1", customer: "Acme Mining" },
supplier: { id: "sup1", suppliername: "Alpha Bearings" },
},
};
/* ---------------------------------------------------------------------------
* Fetch helpers
* ------------------------------------------------------------------------ */
function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as Response;
}
function fetchUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
function buildFetchSpy(items: unknown[] = [REPAIR]) {
return vi.spyOn(window, "fetch").mockImplementation(async (input) => {
const url = fetchUrl(input);
if (url === "/api/v1/repairs/form-data") {
return jsonResponse({ customers: [], suppliers: [] });
}
if (url.startsWith("/api/v1/repairs?")) {
return jsonResponse({
items,
totalItems: items.length,
totalPages: 1,
page: 1,
perPage: 20,
});
}
return jsonResponse({});
});
}
/* ---------------------------------------------------------------------------
* Tests
* ------------------------------------------------------------------------ */
describe("RepairsPage", () => {
beforeEach(() => {
buildFetchSpy();
});
it("shows the shared status label, not the raw status", async () => {
render(<Page />);
await waitFor(() =>
expect(screen.getByRole("table")).toBeInTheDocument(),
);
const table = within(screen.getByRole("table"));
// "assessing" is the raw PocketBase value — the office reads
// "With supplier", the label from @/lib/repair-status.
expect(table.getByText("With supplier")).toBeInTheDocument();
expect(screen.queryAllByText("assessing")).toHaveLength(0);
});
it("labels every status with the shared vocabulary", async () => {
buildFetchSpy([
{ ...REPAIR, id: "rep1", status: "new" },
{ ...REPAIR, id: "rep2", status: "assessing" },
{ ...REPAIR, id: "rep3", status: "quoted" },
{ ...REPAIR, id: "rep4", status: "approved" },
{ ...REPAIR, id: "rep5", status: "in_progress" },
{ ...REPAIR, id: "rep6", status: "completed" },
{ ...REPAIR, id: "rep7", status: "cancelled" },
]);
render(<Page />);
await waitFor(() =>
expect(screen.getByRole("table")).toBeInTheDocument(),
);
const table = within(screen.getByRole("table"));
for (const label of [
"Booked in",
"With supplier",
"Quote in",
"Approved",
"On the bench",
"Done",
"Cancelled",
]) {
expect(table.getByText(label)).toBeInTheDocument();
}
// None of the raw status values leak onto the page.
for (const raw of [
"new",
"assessing",
"quoted",
"approved",
"in_progress",
"completed",
"cancelled",
]) {
expect(screen.queryAllByText(raw)).toHaveLength(0);
}
});
});

View file

@ -24,9 +24,9 @@ function isAllowedField(value: string): value is RepairFileField {
* POST /api/v1/repairs/[id]/files/[field]
*
* Multipart upload: field name = the multi-file repair field
* (photos / quote / purchase_order / delivery_note). The file is
* appended to the existing list (re-PATCHes the row with the old
* filenames + the new one).
* (photos / quote / purchase_order / repair_request / signed_request).
* The file is appended to the existing list (re-PATCHes the row with
* the old filenames + the new one).
*
* For the `quote` field, the repair status is auto-bumped to
* `quoted` if it was `new` or `assessing`.

View file

@ -388,6 +388,56 @@ describe("supplier_scope and signed_request", () => {
});
});
describe("updateRepair status guard", () => {
beforeEach(() => vi.clearAllMocks());
it("rejects an illegal transition and writes nothing", async () => {
const pb = makePb({ ...baseRepair, status: "new" });
mockAdminPb(pb);
// "new" may only go to assessing / quoted / cancelled — the guard
// inside updateRepair is what stops the illegal move.
await expect(
updateRepair("rep1", { status: "completed" }),
).rejects.toThrow(/Cannot transition repair/);
// The guard runs BEFORE the write, so nothing was patched.
expect(pb._update).not.toHaveBeenCalled();
});
it("rejects a move out of a terminal status", async () => {
const pb = makePb({ ...baseRepair, status: "completed" });
mockAdminPb(pb);
await expect(updateRepair("rep1", { status: "new" })).rejects.toThrow(
/Cannot transition repair/,
);
expect(pb._update).not.toHaveBeenCalled();
});
it("allows a legal transition and writes the new status", async () => {
const pb = makePb({ ...baseRepair, status: "new" });
mockAdminPb(pb);
const updated = await updateRepair("rep1", { status: "assessing" });
expect(pb._update).toHaveBeenCalledWith("rep1", { status: "assessing" });
expect(updated.status).toBe("assessing");
});
it("does not guard a status-free update", async () => {
const pb = makePb(baseRepair);
mockAdminPb(pb);
await updateRepair("rep1", { notes: "called the customer" });
expect(pb._update).toHaveBeenCalledWith("rep1", {
notes: "called the customer",
});
});
});
describe("getRepairStatusCounts", () => {
beforeEach(() => vi.clearAllMocks());

View file

@ -58,7 +58,6 @@ import {
} from "./quotes-service";
import {
REPAIR_STATUS,
REPAIR_TRANSITIONS,
assertRepairTransition,
type RepairStatus,
} from "@/lib/repair-status";
@ -631,7 +630,9 @@ export async function linkRepairToOrder(
): Promise<{ order: Order; repair: Repair }> {
const pb = await getAdminPocketBase();
await pb.collection(ORDERS).getOne<Order>(orderId);
const repair = await pb.collection(REPAIRS).getOne<Repair>(repairId);
// The getOne IS the repair-existence check — it throws a 404 when
// the repair does not exist, before either write lands.
await pb.collection(REPAIRS).getOne<Repair>(repairId);
const order = await pb.collection(ORDERS).update<Order>(orderId, {
repair: repairId,