2026-10-16 09:31:45 -06:00
|
|
|
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
|
import { DEPARTMENTS, makeMe, renderApp } from "../test/auth";
|
|
|
|
|
import { callsTo, mockFetch } from "../test/fetchMock";
|
|
|
|
|
import { makePacket } from "../test/packets";
|
|
|
|
|
|
|
|
|
|
function base(packet = makePacket()) {
|
|
|
|
|
return {
|
|
|
|
|
"/api/auth/me": { body: makeMe() },
|
|
|
|
|
"/api/departments": { body: DEPARTMENTS },
|
|
|
|
|
"/api/packets/1": { body: packet },
|
|
|
|
|
"/api/packets/1/log": { body: [] },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("PacketPage", () => {
|
|
|
|
|
it("shows Send to warehouse disabled with the blocked reason", async () => {
|
|
|
|
|
mockFetch(base());
|
|
|
|
|
renderApp("/packets/1");
|
|
|
|
|
const send = await screen.findByRole("button", { name: "Send to warehouse" });
|
|
|
|
|
expect(send).toBeDisabled();
|
|
|
|
|
expect(screen.getByText("Say whether this packet needs a heat map.")).toHaveClass("why");
|
|
|
|
|
expect(screen.getByRole("heading", { name: "L999001 · WELD-PART" })).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to release");
|
|
|
|
|
expect(screen.queryByText("View only.")).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("enables Send to warehouse when can includes it, and posts the transition with expect", async () => {
|
|
|
|
|
const ready = makePacket({ heat_map: true, can: ["edit_header", "send_to_warehouse"], blocked: {} });
|
|
|
|
|
const fetch = mockFetch({
|
|
|
|
|
...base(ready),
|
|
|
|
|
"POST /api/packets/1/transitions": { body: makePacket({ status: "warehouse", version: 4, can: [], blocked: {}, holder: "Warehouse" }) },
|
|
|
|
|
});
|
|
|
|
|
renderApp("/packets/1");
|
|
|
|
|
const send = await screen.findByRole("button", { name: "Send to warehouse" });
|
|
|
|
|
expect(send).toBeEnabled();
|
|
|
|
|
expect(screen.queryByText("Say whether this packet needs a heat map.")).not.toBeInTheDocument();
|
|
|
|
|
fireEvent.click(send);
|
|
|
|
|
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
|
|
|
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } }]);
|
|
|
|
|
expect(screen.getByText("View only.")).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByRole("button", { name: "Send to warehouse" })).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("heat map chips PATCH with the base value", async () => {
|
|
|
|
|
const fetch = mockFetch({
|
|
|
|
|
...base(),
|
|
|
|
|
"PATCH /api/packets/1": { body: makePacket({ heat_map: true, version: 4 }) },
|
|
|
|
|
});
|
|
|
|
|
renderApp("/packets/1");
|
|
|
|
|
const group = await screen.findByRole("group", { name: "Heat map required?" });
|
|
|
|
|
fireEvent.click(within(group).getByRole("button", { name: "Yes" }));
|
|
|
|
|
await waitFor(() => expect(within(group).getByRole("button", { name: "Yes" })).toHaveAttribute("aria-pressed", "true"));
|
|
|
|
|
expect(callsTo(fetch, "PATCH", "/api/packets/1")).toEqual([{ set: { heat_map: true }, base: { heat_map: null } }]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("attaching a drawing uploads it, then signs and sends sign", async () => {
|
|
|
|
|
const fetch = mockFetch({
|
|
|
|
|
...base(),
|
|
|
|
|
"POST /api/assets": {
|
|
|
|
|
status: 201,
|
|
|
|
|
body: { id: "a1b2", mime: "application/pdf", size: 900, sha256: "00", pages: 1, original_name: "d.pdf", url: "/_blob/a1b2" },
|
|
|
|
|
},
|
|
|
|
|
"POST /api/packets/1/pages": { body: makePacket({ version: 4, drawing_state: "hand" }) },
|
|
|
|
|
});
|
|
|
|
|
renderApp("/packets/1");
|
|
|
|
|
const input = await screen.findByLabelText("Attach drawing (PDF)");
|
|
|
|
|
const file = new File(["%PDF-1.4\n"], "d.pdf", { type: "application/pdf" });
|
|
|
|
|
fireEvent.change(input, { target: { files: [file] } });
|
|
|
|
|
|
|
|
|
|
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
|
|
|
|
expect(within(sheet).getByText("Attach a drawing by hand")).toBeInTheDocument();
|
|
|
|
|
expect(within(sheet).getByText(/You are signing as/)).toHaveTextContent("You are signing as Clara Clerk.");
|
|
|
|
|
expect(callsTo(fetch, "POST", "/api/assets")).toHaveLength(1);
|
|
|
|
|
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "pw-123" } });
|
|
|
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument());
|
|
|
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/pages")).toEqual([
|
|
|
|
|
{ asset_id: "a1b2", type: "drawing", dwg: "CI-DWG-1", rev: "0", sign: { factor: "password", secret: "pw-123" } },
|
|
|
|
|
]);
|
|
|
|
|
expect(await screen.findByText("Drawing attached (1 page).")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("a wrong signature keeps the sheet open with the attempts left", async () => {
|
|
|
|
|
mockFetch({
|
|
|
|
|
...base(),
|
|
|
|
|
"POST /api/assets": { status: 201, body: { id: "a1", mime: "application/pdf", size: 9, sha256: "00", pages: 2, original_name: "d.pdf", url: "/_blob/a1" } },
|
|
|
|
|
"POST /api/packets/1/pages": { status: 403, body: { detail: "Wrong password.", code: "wrong_secret", attempts_left: 2 } },
|
|
|
|
|
});
|
|
|
|
|
renderApp("/packets/1");
|
|
|
|
|
fireEvent.change(await screen.findByLabelText("Attach drawing (PDF)"), { target: { files: [new File(["%PDF"], "d.pdf")] } });
|
|
|
|
|
const sheet = await screen.findByRole("dialog", { name: "Sign" });
|
|
|
|
|
fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "nope" } });
|
|
|
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "Sign" }));
|
|
|
|
|
expect(await within(sheet).findByText("Wrong password.")).toBeInTheDocument();
|
|
|
|
|
expect(within(sheet).getByText("2 attempts left.")).toBeInTheDocument();
|
|
|
|
|
expect(within(sheet).getByText("Three wrong attempts end the session.")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("a stale step shows the server message and reloads the packet", async () => {
|
|
|
|
|
let gets = 0;
|
|
|
|
|
const fetch = mockFetch({
|
|
|
|
|
...base(),
|
|
|
|
|
"/api/packets/1": () => {
|
|
|
|
|
gets++;
|
|
|
|
|
return gets === 1
|
|
|
|
|
? { body: makePacket({ heat_map: true, can: ["send_to_warehouse", "edit_header"], blocked: {} }) }
|
|
|
|
|
: { body: makePacket({ status: "warehouse", heat_map: true, can: [], blocked: {}, holder: "Warehouse" }) };
|
|
|
|
|
},
|
|
|
|
|
"POST /api/packets/1/transitions": {
|
|
|
|
|
status: 409,
|
|
|
|
|
body: { detail: "Someone else already took this step.", code: "stale_step", current: { status: "warehouse", dept: "03", version: 5 } },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
renderApp("/packets/1");
|
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
|
2026-10-16 22:46:56 -06:00
|
|
|
expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
|
2026-10-16 09:31:45 -06:00
|
|
|
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
|
|
|
|
|
expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("asks an admin for the IT reason and retries with it", async () => {
|
|
|
|
|
const admin = makeMe({ roles: ["admin"], screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"] });
|
|
|
|
|
let tries = 0;
|
|
|
|
|
const fetch = mockFetch({
|
|
|
|
|
...base(makePacket({ heat_map: true, can: ["send_to_warehouse"], blocked: {} })),
|
|
|
|
|
"/api/auth/me": { body: admin },
|
|
|
|
|
"POST /api/packets/1/transitions": () =>
|
|
|
|
|
++tries === 1
|
|
|
|
|
? { status: 400, body: { detail: "Give an IT reason.", code: "it_reason_required" } }
|
|
|
|
|
: { body: makePacket({ status: "warehouse", can: [], blocked: {} }) },
|
|
|
|
|
});
|
|
|
|
|
renderApp("/packets/1");
|
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" }));
|
|
|
|
|
const sheet = await screen.findByRole("dialog", { name: "IT reason" });
|
|
|
|
|
fireEvent.change(within(sheet).getByLabelText("Why IT is making this change"), { target: { value: "Clerk is out sick today" } });
|
|
|
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "Continue" }));
|
|
|
|
|
await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"));
|
|
|
|
|
expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([
|
|
|
|
|
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } },
|
|
|
|
|
{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" }, it_reason: "Clerk is out sick today" },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows View only with the holder when the user has no actions", async () => {
|
|
|
|
|
mockFetch({
|
|
|
|
|
...base(makePacket({ can: [], blocked: {}, holder: "Clerical" })),
|
|
|
|
|
"/api/auth/me": { body: makeMe({ roles: ["warehouse"], screens: ["warehouse"] }) },
|
|
|
|
|
});
|
|
|
|
|
renderApp("/packets/1");
|
|
|
|
|
const note = (await screen.findByText("View only.")).closest(".viewonly") as HTMLElement;
|
|
|
|
|
expect(note).toHaveTextContent("This packet is with Clerical.");
|
|
|
|
|
expect(screen.queryByRole("button", { name: /Send/ })).not.toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByRole("group", { name: "Heat map required?" })).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reloads when a changed event names this packet", async () => {
|
|
|
|
|
const sources: Array<{ emit: (t: string, d: unknown) => void }> = [];
|
|
|
|
|
class FakeEventSource {
|
|
|
|
|
listeners = new Map<string, (ev: MessageEvent) => void>();
|
|
|
|
|
constructor() {
|
|
|
|
|
sources.push(this);
|
|
|
|
|
}
|
|
|
|
|
addEventListener(type: string, fn: (ev: MessageEvent) => void) {
|
|
|
|
|
this.listeners.set(type, fn);
|
|
|
|
|
}
|
|
|
|
|
close() {}
|
|
|
|
|
emit(type: string, data: unknown) {
|
|
|
|
|
this.listeners.get(type)?.(new MessageEvent(type, { data: JSON.stringify(data) }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
vi.stubGlobal("EventSource", FakeEventSource);
|
|
|
|
|
const fetch = mockFetch(base());
|
|
|
|
|
renderApp("/packets/1");
|
|
|
|
|
await screen.findByRole("heading", { name: "L999001 · WELD-PART" });
|
|
|
|
|
expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(1);
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
try {
|
|
|
|
|
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 2, version: 9 }));
|
|
|
|
|
act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 1, version: 9 }));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
}
|
|
|
|
|
await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2));
|
|
|
|
|
});
|
|
|
|
|
});
|