import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import type { ChangeOrder, PacketRead } from "../api/types"; import { DEPARTMENTS, makeMe, renderApp } from "../test/auth"; import { callsTo, mockFetch } from "../test/fetchMock"; import { makePacket } from "../test/packets"; const CLARA = makeMe(); const LEO = makeMe({ user: { display_name: "Leo Lead", sam_account: "clerk.leo" }, roles: ["clerical_supervisor"] }); function visualDraft(over: Partial = {}): PacketRead { return makePacket({ source_kind: "visual", source: { kind: "visual", batch: 1 }, drawing: "M00543", rev: "2", drawing_state: "needed", flags: ["rev_mismatch"], flag: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3", visual: { appr: { ok: true, rev: 3, vrev: "2", name: "M00543 r3.pdf", note: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3" }, staged_rev: { rev: 3, name: "M00543 r3.pdf" }, partspecs: ["Grind welds flush.", "Paint after test."], folded: [{ wo: "L900001", seq: 30, op: "MFG" }], }, can: ["edit_header", "edit_document_set", "edit_bom", "remove"], blocked: {}, ...over, }); } function feedCo(over: Partial = {}): ChangeOrder { return { id: 51, co_no: 2, origin: "visual", review: "pending", minor: false, hold: false, ecn: "VISUAL", reason: "VISUAL changed", issued_at: "2026-10-16T08:00:00Z", issued_by: null, items: [], required: [], verified: {}, vis: { from_dwg: "M00543", from_rev: "1", to_dwg: "M00543", to_rev: "2", dwg_change: true, changes: ["L900001 qty 2 -> 3"] }, waiting: null, staged: { asset_id: "as-2", name: "M00543 r2.pdf", rev: "2", dwg: "M00543", pages: 1 }, target_fp: "fp", ...over, }; } function inShop(co: ChangeOrder): PacketRead { return makePacket({ status: "in_progress", source_kind: "visual", source: { kind: "visual" }, drawing_state: "attached", flags: [], change_orders: [co], open_cos: [co.co_no], can: [], blocked: {}, holder: "03 Weld Parts", }); } function base(packet: PacketRead, me = CLARA) { return { "/api/auth/me": { body: me }, "/api/departments": { body: DEPARTMENTS }, "/api/packets/1": { body: packet }, "/api/packets/1/log": { body: [] }, }; } async function sign(secret = "pw-1") { const sheet = await screen.findByRole("dialog", { name: "Sign" }); fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: secret } }); fireEvent.click(within(sheet).getByRole("button", { name: "Sign" })); await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument()); } describe("Packet page, VISUAL box", () => { it("shows the flag, the planner instructions and folded operations; Clerical sees no rev approve", async () => { mockFetch(base(visualDraft())); renderApp("/packets/1"); expect(await screen.findByText("Made from VISUAL - drawing not attached yet.")).toBeInTheDocument(); expect(screen.getByText(/M-Files: Rev mismatch: VISUAL calls rev 2/)).toBeInTheDocument(); expect(screen.getByText("A Clerical supervisor can approve rev 3 for this packet.")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Approve rev 3 for this packet" })).not.toBeInTheDocument(); expect(screen.getByText("Planner instructions from VISUAL (2)")).toBeInTheDocument(); expect(screen.getByText("Grind welds flush.")).toBeInTheDocument(); expect(screen.getByText("Also on these work orders: L900001 seq 30 MFG")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Upload drawing" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Use VISUAL's BOM again" })).not.toBeInTheDocument(); }); it("a Clerical supervisor approves rev N with a reason and a signature", async () => { const fetch = mockFetch({ ...base(visualDraft(), LEO), "POST /api/packets/1/rev-approve": { body: visualDraft({ version: 4, flags: [], flag: null, drawing_state: "attached" }) }, }); renderApp("/packets/1"); fireEvent.click(await screen.findByRole("button", { name: "Approve rev 3 for this packet" })); const ask = await screen.findByRole("dialog", { name: "Approve rev 3 for M00543" }); fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Build to rev 3 per Drafting" } }); fireEvent.click(within(ask).getByRole("button", { name: "Approve and attach" })); expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Approve the M-Files revision")).toBeInTheDocument(); await sign(); expect(callsTo(fetch, "POST", "/api/packets/1/rev-approve")).toEqual([ { reason: "Build to rev 3 per Drafting", sign: { factor: "password", secret: "pw-1" } }, ]); expect(await screen.findByText("Rev 3 approved and attached.")).toBeInTheDocument(); }); it("says when the rev VISUAL calls is attached although M-Files has retired it", async () => { const page = { n: 1, type: "drawing", asset_id: "as-r2", pn: 1, dwg: "M00543", rev: "2", void: null, url: "/_blob/as-r2" }; const attached = visualDraft({ flags: [], flag: null, drawing_state: "attached", pages: [page] as unknown as PacketRead["pages"], visual: { appr: { ok: true, rev: 3, vrev: "2", name: "M00543 r3.pdf", note: "" }, vault: { asset_id: "as-r2", name: "M00543 r2.pdf", rev: 2, controlled: "Retired" }, }, }); mockFetch({ ...base(attached, LEO) }); renderApp("/packets/1"); expect(await screen.findByText("Rev 2 attached as VISUAL calls it")).toBeInTheDocument(); expect(screen.getByText(/retired in M-Files, which has rev 3 as Effective/)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Approve rev/ })).not.toBeInTheDocument(); }); it("explains why the rev VISUAL calls is not attached when its status is not Effective or Retired", async () => { const draft = visualDraft({ visual: { ...visualDraft().visual, called: { rev: "2", controlled: "Obsolete", refused: true } } }); mockFetch({ ...base(draft, LEO) }); renderApp("/packets/1"); expect(await screen.findByText(/VISUAL calls rev 2, which is obsolete in M-Files/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Approve rev 3 for this packet" })).toBeInTheDocument(); }); it("uploads a hand BOM as multipart with the signature fields", async () => { const fetch = mockFetch({ ...base(visualDraft()), "POST /api/packets/1/hand/bom": { body: visualDraft({ version: 4, visual: { manual_bom: { name: "bom.csv", reason: "No materials yet", lines: 3, by: "cf284ce9-e7a6-45d0-bba6-d46b752d553d", by_name: "Clara Clerk" } } }) }, }); renderApp("/packets/1"); fireEvent.click(await screen.findByRole("button", { name: "Upload BOM" })); const sheet = await screen.findByRole("dialog", { name: "Upload the BOM" }); const file = new File(["Item,Qty,Description\n1,2,Plate\n"], "bom.csv", { type: "text/csv" }); fireEvent.change(within(sheet).getByLabelText("File (XLSX or CSV, up to 25 MB)"), { target: { files: [file] } }); fireEvent.change(within(sheet).getByLabelText("Why by hand (kept on the packet)"), { target: { value: "No materials yet" } }); fireEvent.click(within(sheet).getByRole("button", { name: "Upload and sign" })); await sign("pw-9"); const call = fetch.mock.calls.find(([u, init]) => String(u) === "/api/packets/1/hand/bom" && init?.method === "POST"); const fd = call?.[1]?.body as FormData; expect(fd).toBeInstanceOf(FormData); expect(fd.get("reason")).toBe("No materials yet"); expect(fd.get("sign_factor")).toBe("password"); expect(fd.get("sign_secret")).toBe("pw-9"); expect((fd.get("file") as File).name).toBe("bom.csv"); expect(await screen.findByRole("button", { name: "Use VISUAL's BOM again" })).toBeInTheDocument(); expect(screen.getByText("BOM uploaded by hand:", { exact: false })).toBeInTheDocument(); // The line names the person, never the stored user id. expect(screen.getByText(/BOM uploaded by hand:.*Clara Clerk/)).toBeInTheDocument(); expect(screen.queryByText(/cf284ce9/)).not.toBeInTheDocument(); }); it("uploads a hand drawing: the asset first, then the attach with reason and sign", async () => { const fetch = mockFetch({ ...base(visualDraft()), "POST /api/assets": { status: 201, body: { id: "as-9", mime: "application/pdf", size: 9, sha256: "00", pages: 1, original_name: "d.pdf", url: "/_blob/as-9" } }, "POST /api/packets/1/hand/drawing": { body: visualDraft({ version: 4, flags: ["hand_drawing"] }) }, }); renderApp("/packets/1"); fireEvent.click(await screen.findByRole("button", { name: "Upload drawing" })); const sheet = await screen.findByRole("dialog", { name: "Upload the drawing" }); fireEvent.change(within(sheet).getByLabelText("File (PDF, up to 25 MB)"), { target: { files: [new File(["%PDF-1.4"], "d.pdf", { type: "application/pdf" })] } }); fireEvent.change(within(sheet).getByLabelText("Why by hand (kept on the packet)"), { target: { value: "Drafting sent it" } }); fireEvent.click(within(sheet).getByRole("button", { name: "Upload and sign" })); await sign(); expect(callsTo(fetch, "POST", "/api/packets/1/hand/drawing")).toEqual([ { asset_id: "as-9", reason: "Drafting sent it", sign: { factor: "password", secret: "pw-1" } }, ]); }); }); describe("Packet page, Changes tab", () => { it("Clerical approves a staged feed change order with a signature", async () => { const co = feedCo(); const fetch = mockFetch({ ...base(inShop(co)), "POST /api/packets/1/change-orders/2/approve": { body: inShop({ ...co, review: "approved", approved_by: { id: "u", display_name: "Clara Clerk" }, required: ["03"] }) }, }); renderApp("/packets/1"); expect(await screen.findByText(/Change order CO-2: a change from VISUAL is waiting for your approval/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Review change" })); const sec = await screen.findByRole("region", { name: "Change order CO-2" }); expect(within(sec).getByText("M00543 Rev 1 -> Rev 2 + 1 other change")).toBeInTheDocument(); expect(within(sec).getByText("L900001 qty 2 -> 3")).toBeInTheDocument(); expect(within(sec).getByRole("link", { name: "Open the new drawing" })).toHaveAttribute("href", "/_blob/as-2"); fireEvent.click(within(sec).getByRole("button", { name: "Approve - update the shop packet" })); expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Approve a change order")).toBeInTheDocument(); await sign(); expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/approve")).toEqual([{ sign: { factor: "password", secret: "pw-1" } }]); expect(await screen.findByText("Change order CO-2 approved. The shop packet is updated.")).toBeInTheDocument(); expect(await screen.findByRole("table", { name: "CO-2 verification" })).toBeInTheDocument(); }); it("Approve is disabled with the reason while the drawing waits; reject and hold post", async () => { const co = feedCo({ staged: null, waiting: { dwg: "M00543", rev: "2" } }); const fetch = mockFetch({ ...base(inShop(co)), "POST /api/packets/1/change-orders/2/hold": { body: inShop({ ...co, hold: true }) }, "POST /api/packets/1/change-orders/2/reject": { body: inShop({ ...co, hold: true, review: "rejected", rejected_reason: "Not for this unit" }) }, }); renderApp("/packets/1"); fireEvent.click(await screen.findByRole("tab", { name: /Changes/ })); const sec = await screen.findByRole("region", { name: "Change order CO-2" }); expect(within(sec).getByRole("button", { name: "Approve - update the shop packet" })).toBeDisabled(); expect(within(sec).getByText("Waits on that rev in M-Files.")).toHaveClass("why"); expect(within(sec).getByText(/That rev isn't released in M-Files yet/)).toBeInTheDocument(); fireEvent.click(within(sec).getByRole("button", { name: "Hold the shop packet now" })); expect(await within(sec).findByRole("button", { name: "Release the hold" })).toBeInTheDocument(); expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/hold")).toEqual([{ hold: true }]); fireEvent.click(within(sec).getByRole("button", { name: "Reject" })); const ask = await screen.findByRole("dialog", { name: "Reject change order CO-2" }); fireEvent.change(within(ask).getByLabelText("Reason"), { target: { value: "Not for this unit" } }); fireEvent.click(within(ask).getByRole("button", { name: "Reject" })); await sign(); expect(callsTo(fetch, "POST", "/api/packets/1/change-orders/2/reject")).toEqual([ { reason: "Not for this unit", sign: { factor: "password", secret: "pw-1" } }, ]); }); it("shows the co_not_staged answer as the callout text", async () => { const co = feedCo(); mockFetch({ ...base(inShop(co)), "POST /api/packets/1/change-orders/2/approve": { status: 409, body: { detail: "The new drawing has not been fetched from M-Files yet.", code: "co_not_staged" }, }, }); renderApp("/packets/1"); fireEvent.click(await screen.findByRole("button", { name: "Review change" })); fireEvent.click(await screen.findByRole("button", { name: "Approve - update the shop packet" })); await sign(); expect(await screen.findByRole("alert")).toHaveTextContent("The new drawing has not been fetched from M-Files yet."); }); });