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

191 lines
9.8 KiB
TypeScript
Raw Normal View History

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" }));
expect(await screen.findByTestId("toast")).toHaveTextContent("Someone else already took this step.");
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));
});
});