270 lines
14 KiB
TypeScript
270 lines
14 KiB
TypeScript
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
|||
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
|
|
import type { PacketRead, PacketSeal, SealView, VcloseCheck } from "../api/types";
|
||
|
|
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
||
|
|
import { callsTo, mockFetch, type Route } from "../test/fetchMock";
|
||
|
|
import { makePacket } from "../test/packets";
|
||
|
|
|
||
|
|
const CLARA = { id: "11111111-1111-1111-1111-111111111111", display_name: "Clara Clerk" };
|
||
|
|
const ANN = { id: "22222222-2222-2222-2222-222222222222", display_name: "Ann Admin" };
|
||
|
|
|
||
|
|
function check(over: Partial<VcloseCheck> = {}): VcloseCheck {
|
||
|
|
return { observed_at: "2026-10-17T07:00:00Z", closed_at: null, open_wos: [], agrees: true, ...over };
|
||
|
|
}
|
||
|
|
|
||
|
|
function seal(over: Partial<PacketSeal> = {}): PacketSeal {
|
||
|
|
return {
|
||
|
|
id: 4,
|
||
|
|
sealed_at: "2026-10-17T10:00:00Z",
|
||
|
|
sealed_by: CLARA,
|
||
|
|
key_id: "k1",
|
||
|
|
signed: true,
|
||
|
|
broken_at: null,
|
||
|
|
broken_by: null,
|
||
|
|
broken_reason: null,
|
||
|
|
open_wos: null,
|
||
|
|
databook: { asset_id: "a-db", url: "/_blob/a-db", download_url: "/_blob/a-db?download=1", pages: 14, size: 90000, file_name: "L999-LotL999001_CI-DWG-1_Manufacturing_Data_Book.pdf" },
|
||
|
|
...over,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Complete, routed to the data book, not confirmed closed in VISUAL. */
|
||
|
|
function toClose(over: Partial<PacketRead> = {}): PacketRead {
|
||
|
|
return makePacket({
|
||
|
|
status: "complete",
|
||
|
|
routed_to: "databook",
|
||
|
|
holder: "Clerical",
|
||
|
|
source_kind: "visual",
|
||
|
|
can: ["set_vclosed"],
|
||
|
|
blocked: { file: ["Close the job in VISUAL first."] },
|
||
|
|
vclose_check: check(),
|
||
|
|
...over,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Complete and confirmed closed: ready for the data book. */
|
||
|
|
function toFile(over: Partial<PacketRead> = {}): PacketRead {
|
||
|
|
return toClose({ vclosed: { at: "2026-10-17T09:00:00Z", by: CLARA }, can: ["file", "set_vclosed"], blocked: {}, ...over });
|
||
|
|
}
|
||
|
|
|
||
|
|
function filed(over: Partial<PacketRead> = {}): PacketRead {
|
||
|
|
return toFile({ status: "filed", version: 9, databook_at: "2026-10-17T10:00:00Z", seal: seal(), seals: 1, can: [], blocked: {}, ...over });
|
||
|
|
}
|
||
|
|
|
||
|
|
function base(packet: PacketRead, extra: Record<string, Route> = {}, roles = ["clerical"]) {
|
||
|
|
return {
|
||
|
|
"/api/auth/me": { body: makeMe({ roles }) },
|
||
|
|
"/api/departments": { body: DEPARTMENTS },
|
||
|
|
"/api/packets/1": { body: packet },
|
||
|
|
"/api/packets/1/log": { body: [] },
|
||
|
|
...extra,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function sealView(valid: boolean | null, s: PacketSeal = seal()): SealView {
|
||
|
|
return { seal: s, valid, seals: [s] };
|
||
|
|
}
|
||
|
|
|
||
|
|
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());
|
||
|
|
}
|
||
|
|
|
||
|
|
const panel = () => screen.findByRole("region", { name: "Close and file" });
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
vi.restoreAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Close and file: ready to close", () => {
|
||
|
|
it("shows the state, the exports, and confirms the close", async () => {
|
||
|
|
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||
|
|
const fetch = mockFetch(base(toClose(), { "POST /api/packets/1/vclosed": { body: toFile({ version: 4 }) } }));
|
||
|
|
renderApp("/packets/1");
|
||
|
|
const box = await panel();
|
||
|
|
expect(box).toHaveTextContent("Back from the shop, ready to close.");
|
||
|
|
const file = within(box).getByRole("button", { name: "File in the data book" });
|
||
|
|
expect(file).toBeDisabled();
|
||
|
|
expect(within(box).getByText("Close the job in VISUAL first.")).toHaveClass("why");
|
||
|
|
|
||
|
|
fireEvent.click(within(box).getByRole("button", { name: "Data book (PDF)" }));
|
||
|
|
expect(open).toHaveBeenCalledWith("/api/packets/1/pdf/databook", "_blank", "noopener");
|
||
|
|
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("href", "/api/packets/1/pdf/databook?download=1");
|
||
|
|
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("download");
|
||
|
|
expect(within(box).getByRole("link", { name: "Traceability (CSV)" })).toHaveAttribute("href", "/api/packets/1/export/traceability.csv");
|
||
|
|
fireEvent.click(within(box).getByRole("button", { name: "Print…" }));
|
||
|
|
expect(await screen.findByRole("dialog", { name: "Print" })).toBeInTheDocument();
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||
|
|
|
||
|
|
fireEvent.click(within(box).getByRole("button", { name: "Job closed in VISUAL" }));
|
||
|
|
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book."));
|
||
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/vclosed")).toEqual([{ closed: true }]);
|
||
|
|
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent(/Job closed in VISUAL .* by Clara Clerk\./);
|
||
|
|
});
|
||
|
|
|
||
|
|
it.each([
|
||
|
|
["the feed saw the job closed", check({ closed_at: "2026-10-17T06:00:00Z", agrees: false }), /^VISUAL shows the job closed since .+\.$/],
|
||
|
|
["work orders still open", check({ open_wos: ["L999001/001", "L999001/002"] }), /^VISUAL still shows 2 work orders open: L999001\/001, L999001\/002\.$/],
|
||
|
|
["one work order open", check({ open_wos: ["L999001/001"] }), /^VISUAL still shows 1 work order open: L999001\/001\.$/],
|
||
|
|
["the feed has not looked", check({ observed_at: null }), /^The feed has not checked VISUAL yet\.$/],
|
||
|
|
])("the VISUAL line when %s", async (_name, c, text) => {
|
||
|
|
mockFetch(base(toClose({ vclose_check: c })));
|
||
|
|
renderApp("/packets/1");
|
||
|
|
const box = await panel();
|
||
|
|
expect(within(box).getByTestId("visual-line")).toHaveTextContent(text);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("a hand packet has no VISUAL line", async () => {
|
||
|
|
mockFetch(base(toClose({ source_kind: "hand", vclose_check: null })));
|
||
|
|
renderApp("/packets/1");
|
||
|
|
const box = await panel();
|
||
|
|
expect(box).toHaveTextContent("Back from the shop, ready to close.");
|
||
|
|
expect(within(box).queryByTestId("visual-line")).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("is not shown before complete or to a role with nothing to do", async () => {
|
||
|
|
mockFetch(base(toClose({ can: [], blocked: {} }), {}, ["warehouse"]));
|
||
|
|
renderApp("/packets/1");
|
||
|
|
await screen.findByRole("tab", { name: /^Materials/ });
|
||
|
|
expect(screen.queryByRole("region", { name: "Close and file" })).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Close and file: ready for the data book", () => {
|
||
|
|
it("files with the signature", async () => {
|
||
|
|
const fetch = mockFetch(
|
||
|
|
base(toFile(), {
|
||
|
|
"POST /api/packets/1/transitions": (req) =>
|
||
|
|
(req.body as { sign?: unknown }).sign
|
||
|
|
? { body: filed() }
|
||
|
|
: { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "File in the data book" } },
|
||
|
|
"/api/packets/1/seal": { body: sealView(true) },
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
renderApp("/packets/1");
|
||
|
|
const box = await panel();
|
||
|
|
expect(box).toHaveTextContent("Ready for the data book.");
|
||
|
|
expect(within(box).getByRole("button", { name: "Not closed in VISUAL, move back" })).toBeEnabled();
|
||
|
|
fireEvent.click(within(box).getByRole("button", { name: "File in the data book" }));
|
||
|
|
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("File in the data book")).toBeInTheDocument();
|
||
|
|
await sign();
|
||
|
|
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed in the data book"));
|
||
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||
|
|
{ action: "file", expect: { status: "complete", dept: "03" } },
|
||
|
|
{ action: "file", expect: { status: "complete", dept: "03" }, sign: { factor: "password", secret: "pw-1" } },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("warns in a callout when VISUAL still shows work orders open", async () => {
|
||
|
|
mockFetch(base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) })));
|
||
|
|
renderApp("/packets/1");
|
||
|
|
const box = await panel();
|
||
|
|
const line = within(box).getByTestId("visual-line");
|
||
|
|
expect(line).toHaveClass("callout", "warn");
|
||
|
|
expect(line).toHaveTextContent("VISUAL still shows 1 work order open: L999001/001.");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("asks on 409 open_work_orders and sends it again with confirm_open_wos", async () => {
|
||
|
|
const msg = "VISUAL still shows 1 work order open on this packet: L999001/001. File anyway?";
|
||
|
|
const fetch = mockFetch(
|
||
|
|
base(toFile({ vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) }), {
|
||
|
|
"POST /api/packets/1/transitions": (req) => {
|
||
|
|
const b = req.body as { sign?: unknown; confirm_open_wos?: boolean };
|
||
|
|
if (!b.confirm_open_wos) return { status: 409, body: { code: "open_work_orders", detail: msg, work_orders: ["L999001/001"] } };
|
||
|
|
if (!b.sign) return { status: 400, body: { code: "signature_required", detail: "This step needs your signature.", meaning: "File in the data book" } };
|
||
|
|
return { body: filed({ seal: seal({ open_wos: ["L999001/001"] }) }) };
|
||
|
|
},
|
||
|
|
"/api/packets/1/seal": { body: sealView(true, seal({ open_wos: ["L999001/001"] })) },
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
renderApp("/packets/1");
|
||
|
|
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
|
||
|
|
const sheet = await screen.findByRole("dialog", { name: "File with open work orders?" });
|
||
|
|
expect(within(sheet).getByText(msg)).toBeInTheDocument();
|
||
|
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "File anyway" }));
|
||
|
|
await sign();
|
||
|
|
await waitFor(() => expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed in the data book"));
|
||
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
||
|
|
{ action: "file", expect: { status: "complete", dept: "03" } },
|
||
|
|
{ action: "file", expect: { status: "complete", dept: "03" }, confirm_open_wos: true },
|
||
|
|
{ action: "file", expect: { status: "complete", dept: "03" }, confirm_open_wos: true, sign: { factor: "password", secret: "pw-1" } },
|
||
|
|
]);
|
||
|
|
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Filed with work orders open in VISUAL: L999001/001.");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("Cancel on the confirm sends nothing more", async () => {
|
||
|
|
const fetch = mockFetch(
|
||
|
|
base(toFile(), {
|
||
|
|
"POST /api/packets/1/transitions": { status: 409, body: { code: "open_work_orders", detail: "VISUAL still shows 1 work order open.", work_orders: ["L999001/001"] } },
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
renderApp("/packets/1");
|
||
|
|
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
|
||
|
|
const sheet = await screen.findByRole("dialog", { name: "File with open work orders?" });
|
||
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||
|
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "File with open work orders?" })).not.toBeInTheDocument());
|
||
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toHaveLength(1);
|
||
|
|
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book.");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Close and file: filed", () => {
|
||
|
|
it("shows the seal intact and opens the filed data book", async () => {
|
||
|
|
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||
|
|
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(true) } }));
|
||
|
|
renderApp("/packets/1");
|
||
|
|
const box = await panel();
|
||
|
|
expect(box).toHaveTextContent(/Filed in the data book .* by Clara Clerk\. The data book has 14 pages\./);
|
||
|
|
expect(await within(box).findByText("Seal intact (key k1)")).toHaveClass("pill", "st-ok");
|
||
|
|
fireEvent.click(within(box).getByRole("button", { name: "Data book (PDF)" }));
|
||
|
|
expect(open).toHaveBeenCalledWith("/_blob/a-db", "_blank", "noopener");
|
||
|
|
expect(within(box).getByRole("link", { name: "Download" })).toHaveAttribute("href", "/_blob/a-db?download=1");
|
||
|
|
expect(within(box).getByRole("link", { name: "Traceability (CSV)" })).toBeInTheDocument();
|
||
|
|
expect(within(box).getByRole("button", { name: "Print…" })).toBeInTheDocument();
|
||
|
|
expect(within(box).queryByRole("button", { name: "Reopen (IT)" })).not.toBeInTheDocument();
|
||
|
|
expect(within(box).queryByRole("button", { name: "File in the data book" })).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("says when the seal does not match", async () => {
|
||
|
|
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(false) } }));
|
||
|
|
renderApp("/packets/1");
|
||
|
|
const box = await panel();
|
||
|
|
const state = await within(box).findByTestId("seal-state");
|
||
|
|
expect(state).toHaveClass("callout", "warn");
|
||
|
|
expect(state).toHaveTextContent("Seal does not match the record.");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("says when the seal cannot be checked", async () => {
|
||
|
|
mockFetch(base(filed(), { "/api/packets/1/seal": { body: sealView(null) } }));
|
||
|
|
renderApp("/packets/1");
|
||
|
|
const box = await panel();
|
||
|
|
expect(await within(box).findByTestId("seal-state")).toHaveTextContent("Seal unknown (key not available)");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("IT reopens with the reason and the signature; the panel keeps the broken seal line", async () => {
|
||
|
|
const reopened = toFile({
|
||
|
|
version: 10,
|
||
|
|
can: ["admin_reopen"],
|
||
|
|
seal: seal({ broken_at: "2026-10-17T11:00:00Z", broken_by: ANN, broken_reason: "wrong MTR filed" }),
|
||
|
|
seals: 1,
|
||
|
|
});
|
||
|
|
const fetch = mockFetch(
|
||
|
|
base(filed({ can: ["admin_reopen"] }), { "/api/packets/1/seal": { body: sealView(true) }, "POST /api/packets/1/reopen": { body: reopened } }, ["admin"]),
|
||
|
|
);
|
||
|
|
renderApp("/packets/1");
|
||
|
|
const box = await panel();
|
||
|
|
fireEvent.click(within(box).getByRole("button", { name: "Reopen (IT)" }));
|
||
|
|
const sheet = await screen.findByRole("dialog", { name: "Reopen a filed packet" });
|
||
|
|
fireEvent.change(within(sheet).getByLabelText("Reason"), { target: { value: "wrong MTR filed" } });
|
||
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "Reopen" }));
|
||
|
|
expect(await within(await screen.findByRole("dialog", { name: "Sign" })).findByText("Reopen a filed packet")).toBeInTheDocument();
|
||
|
|
await sign();
|
||
|
|
await waitFor(() => expect(screen.getByTestId("reopened")).toHaveTextContent(/^Reopened by Ann Admin .+: wrong MTR filed\. The earlier seal is kept as broken\.$/));
|
||
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/reopen")).toEqual([{ reason: "wrong MTR filed", sign: { factor: "password", secret: "pw-1" } }]);
|
||
|
|
expect(screen.getByRole("region", { name: "Close and file" })).toHaveTextContent("Ready for the data book.");
|
||
|
|
});
|
||
|
|
});
|