Files
PacketStation/frontend/src/packet/closefile.test.tsx
T

267 lines
14 KiB
TypeScript
Raw Normal View History

2026-10-17 23:49:14 -06:00
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("a hand packet: shows the state, the exports, and Clerical confirms the close", async () => {
2026-10-17 23:49:14 -06:00
const open = vi.spyOn(window, "open").mockImplementation(() => null);
const hand = { source_kind: "hand" as const, vclose_check: null };
const fetch = mockFetch(base(toClose(hand), { "POST /api/packets/1/vclosed": { body: toFile({ version: 4, ...hand }) } }));
2026-10-17 23:49:14 -06:00
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent("Back from the shop, ready to close. Close the job in VISUAL, then click below.");
2026-10-17 23:49:14 -06:00
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\./);
expect(screen.getByRole("button", { name: "Not closed in VISUAL, move back" })).toBeEnabled();
});
it("a VISUAL packet waits for the feed: no manual close, File says why it is blocked", async () => {
const why = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
mockFetch(base(toClose({ blocked: { file: [why] }, vclose_check: check({ open_wos: ["L999001/001"] }) })));
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent("Back from the shop, ready to close. Close the job in VISUAL. The packet moves on to the data book when the feed sees it closed.");
expect(within(box).queryByRole("button", { name: "Job closed in VISUAL" })).not.toBeInTheDocument();
expect(within(box).getByRole("button", { name: "File in the data book" })).toBeDisabled();
expect(within(box).getByText(why)).toHaveClass("why");
});
it("a VISUAL packet the feed saw closed is ready for the data book", async () => {
mockFetch(base(toClose({ can: ["file"], blocked: {}, vclose_check: check({ closed_at: "2026-10-17T06:00:00Z", agrees: false }) })));
renderApp("/packets/1");
const box = await panel();
expect(box).toHaveTextContent(/^Ready for the data book\. VISUAL shows the job closed since .+\. Check the data book, then file it\./);
expect(within(box).getByRole("button", { name: "File in the data book" })).toBeEnabled();
expect(within(box).queryByRole("button", { name: "Not closed in VISUAL, move back" })).not.toBeInTheDocument();
2026-10-17 23:49:14 -06:00
});
it.each([
["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\. Job closed in VISUAL .+ by Clara Clerk\./);
// A VISUAL packet: the feed decides, so there is no manual move back.
expect(within(box).queryByRole("button", { name: "Not closed in VISUAL, move back" })).not.toBeInTheDocument();
2026-10-17 23:49:14 -06:00
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("File is blocked while VISUAL shows work orders open, with no second confirmation", async () => {
const why = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
const fetch = mockFetch(base(toFile({ can: [], blocked: { file: [why] }, vclose_check: check({ open_wos: ["L999001/001"], agrees: false }) })));
2026-10-17 23:49:14 -06:00
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.");
const file = within(box).getByRole("button", { name: "File in the data book" });
expect(file).toBeDisabled();
expect(within(box).getByText(why)).toHaveClass("why");
fireEvent.click(file);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toHaveLength(0);
2026-10-17 23:49:14 -06:00
});
it("shows the server's refusal when filing is refused after all", async () => {
const detail = "VISUAL still shows a work order open on this packet. Close it in VISUAL first.";
2026-10-17 23:49:14 -06:00
const fetch = mockFetch(
base(toFile(), { "POST /api/packets/1/transitions": { status: 409, body: { code: "precondition_failed", detail } } }),
2026-10-17 23:49:14 -06:00
);
renderApp("/packets/1");
fireEvent.click(within(await panel()).getByRole("button", { name: "File in the data book" }));
expect(await screen.findByRole("alert")).toHaveTextContent(detail);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "file", expect: { status: "complete", dept: "03" } }]);
2026-10-17 23:49:14 -06:00
});
});
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.");
});
});