Phase 2: core domain, policy engine, packet API, Clerical screen
Migration 0003: projects, aliases, renumbers, units, packets and every child table, change orders, assets, documents, ITPs, station limits, department weld flag. Policy package: Principal, PacketState, Action rules, custody, preconditions, CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept, If-Match row versions, server-assigned numbers, field CAS, version bump, packet log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal. API: /api/packets (header, transitions, work orders, pages, docs, move, remove, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets, /_blob. Frontend: Clerical home, projects and units, packet page with pdf.js and QR codes, hand draft, heat map, send to warehouse, department or Q.C. Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical draft scenario. Verified on docker-1 with the CI stack and the Samba DC. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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.findByRole("alert")).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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user