258 lines
13 KiB
TypeScript
258 lines
13 KiB
TypeScript
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> = {}): 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> = {}): 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.");
|
|
});
|
|
});
|