WFDASH-WEBAPP/src/components/order-sprint-process-modal.test.tsx
SJCoetz 995b7ed675 feat(context): swap sprint modal AI Insights for context panel
Co-authored-by: Orca <help@stably.ai>
2026-08-07 02:58:47 +02:00

442 lines
No EOL
14 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { OrderSprintProcessModal } from "./order-sprint-process-modal";
// Mock the client PB so we can assert on the update payload written
// when the operator clicks "Process Item". The modal also reads price
// list + order history in `useEffect` — we stub those with empty
// results so the test focuses purely on the save behaviour.
const { updateMock, getListMock, supplierSearchStub, fetchMock } = vi.hoisted(() => ({
updateMock: vi.fn(),
getListMock: vi.fn(),
supplierSearchStub: vi.fn(),
fetchMock: vi.fn(),
}));
vi.mock("@/lib/pocketbase", () => ({
__esModule: true,
default: () => ({
collection: () => ({
getList: (...args: unknown[]) => getListMock(...args),
update: updateMock,
}),
}),
}));
// Replace SupplierSearch with a plain <select> so we can drive
// supplier selection from tests without rendering the real combobox.
vi.mock("@/components/ui/supplier-search", () => ({
SupplierSearch: ({
value,
onValueChange,
suppliers,
}: {
value: string;
onValueChange: (v: string) => void;
suppliers: Array<{ id: string; suppliername: string }>;
}) => (
<select
aria-label="Supplier"
value={value}
onChange={(e) => onValueChange(e.target.value)}
>
<option value="">Pick…</option>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
{s.suppliername}
</option>
))}
</select>
),
}));
void supplierSearchStub; // silence unused
const baseItem = {
id: "item-1",
description: "HSS drill bit",
quantity: 2,
order_qty: 10,
selling_price: 50,
supplier: "",
cost_price: 0,
markup: 0,
sku: "",
notes: "",
category: "",
po_description: "",
item_status: "pending",
ai_item_insights: null,
};
const suppliers = [
{ id: "sup-1", suppliername: "Steel Co" },
{ id: "sup-2", suppliername: "Bolt Suppliers" },
];
function renderModal(props: Partial<React.ComponentProps<typeof OrderSprintProcessModal>> = {}) {
return render(
<OrderSprintProcessModal
item={baseItem}
suppliers={suppliers as never}
open
onClose={vi.fn()}
onProcessed={vi.fn()}
{...props}
/>,
);
}
async function setField(labelOrPlaceholder: string, value: string) {
// The pricing inputs are wired with htmlFor/id; the other text
// inputs use placeholders. Prefer the label, fall back to the
// placeholder.
let input: HTMLElement;
try {
input = screen.getByLabelText(labelOrPlaceholder, { exact: false });
} catch {
input = screen.getByPlaceholderText(labelOrPlaceholder);
}
fireEvent.change(input, { target: { value } });
}
describe("OrderSprintProcessModal", () => {
beforeEach(() => {
updateMock.mockReset();
updateMock.mockResolvedValue({});
// Order history + price list fetches resolve to empty lists so
// the modal mounts without throwing. We don't assert on them.
getListMock.mockResolvedValue({ items: [], totalItems: 0, totalPages: 1 });
// The context panel + margin-approval endpoint both go through
// the global fetch. Route by URL so each gets a valid body —
// returning the approval payload for the context call would
// crash the panel's noData computation.
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/context")) {
return new Response(
JSON.stringify({
generated_at: "2026-08-07T00:00:00Z",
match: { sku: "", match_kind: "none", match_confidence: 0 },
last_quote: null, last_order: null, last_invoiced: null, stock: null,
suppliers: [], cheapest: null,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response(JSON.stringify({ webhookFired: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
});
it("writes item_status='processed' when supplier + cost + po_description are all set", async () => {
renderModal();
// supplier
fireEvent.change(screen.getByLabelText("Supplier"), {
target: { value: "sup-1" },
});
// cost
await setField("Cost (ZAR)", "25");
// po_description (uses placeholder because there's no <Label htmlFor> wiring)
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "10mm HSS drill bit" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledTimes(1);
});
const [id, payload] = updateMock.mock.calls[0]!;
expect(id).toBe("item-1");
expect(payload).toMatchObject({
supplier: "sup-1",
cost_price: 25,
po_description: "10mm HSS drill bit",
item_status: "processed",
});
});
it("asks for approval and does not process when markup is below 22%", async () => {
const onProcessed = vi.fn();
renderModal({ onProcessed });
fireEvent.change(screen.getByLabelText("Supplier"), {
target: { value: "sup-1" },
});
await setField("Cost (ZAR)", "45");
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "10mm HSS drill bit" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
expect(
screen.getByRole("heading", { name: /manager approval required/i }),
).toBeInTheDocument();
expect(updateMock).not.toHaveBeenCalled();
fireEvent.click(
screen.getByRole("button", { name: /save draft & request approval/i }),
);
await waitFor(() => {
expect(onProcessed).toHaveBeenCalledTimes(1);
});
const approvalCall = fetchMock.mock.calls.find(
(c) => typeof c[0] === "string" && c[0].includes("/request-margin-approval"),
);
expect(approvalCall).toBeDefined();
expect(approvalCall![0]).toBe(
"/api/v1/order-items/item-1/request-margin-approval",
);
expect(JSON.parse(approvalCall![1].body)).toMatchObject({
source: "order_sprint",
draft: {
cost_price: 45,
selling_price: 50,
supplier: "sup-1",
},
});
});
it("keeps item_status='pending' when po_description is empty", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("Supplier"), {
target: { value: "sup-1" },
});
await setField("Cost (ZAR)", "25");
// po_description left blank
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledTimes(1);
});
expect(updateMock.mock.calls[0]![1]).toMatchObject({
item_status: "pending",
});
});
it("keeps item_status='pending' when cost is 0", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("Supplier"), {
target: { value: "sup-1" },
});
await setField("Cost (ZAR)", "0");
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "anything" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledTimes(1);
});
expect(updateMock.mock.calls[0]![1]).toMatchObject({
item_status: "pending",
});
});
it("does not downgrade items that are already past 'processed'", async () => {
renderModal({
item: { ...baseItem, item_status: "po_generated", cost_price: 25, supplier: "sup-1", po_description: "x" },
});
// No supplier / cost / po_description changes needed — those are pre-filled.
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledTimes(1);
});
expect(updateMock.mock.calls[0]![1]).toMatchObject({
item_status: "po_generated",
});
});
it("refuses to save when no supplier is selected", async () => {
renderModal();
await setField("Cost (ZAR)", "25");
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "10mm HSS drill bit" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
// updateMock is not called — the modal gates on supplier first.
await waitFor(() => {
expect(updateMock).not.toHaveBeenCalled();
});
});
it("locks the selling price as read-only (no input is rendered)", () => {
renderModal();
// No <input> for selling — the selling value is rendered as a
// styled div. Operators can't change what came in from the order.
expect(screen.queryByLabelText(/selling \(zar\)/i)).toBeNull();
// The locked value still surfaces so the operator sees margin.
expect(screen.getByText(/R50\.00/)).toBeInTheDocument();
});
it("defaults qty to order_qty and writes it to order_qty on save", async () => {
renderModal();
// Default value comes from item.order_qty (10), not quantity (2).
expect(
(screen.getByLabelText(/po qty/i) as HTMLInputElement).value,
).toBe("10");
// Override and save — verify order_qty is in the payload.
fireEvent.change(screen.getByLabelText(/po qty/i), {
target: { value: "8" },
});
fireEvent.change(screen.getByLabelText("Supplier"), {
target: { value: "sup-1" },
});
await setField("Cost (ZAR)", "25");
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "10mm HSS drill bit" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledTimes(1);
});
expect(updateMock.mock.calls[0]![1]).toMatchObject({
order_qty: 8,
});
});
it("falls back to quantity when order_qty is missing", () => {
renderModal({ item: { ...baseItem, order_qty: undefined, quantity: 5 } });
expect(
(screen.getByLabelText(/po qty/i) as HTMLInputElement).value,
).toBe("5");
});
it("computes markup from cost + selling at save time", async () => {
// baseItem.selling_price = 50. Cost = 25 → markup = ((50-25)/25)*100 = 100.
renderModal();
fireEvent.change(screen.getByLabelText("Supplier"), {
target: { value: "sup-1" },
});
await setField("Cost (ZAR)", "25");
fireEvent.change(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
{ target: { value: "ok" } },
);
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledTimes(1);
});
expect(updateMock.mock.calls[0]![1]).toMatchObject({
markup: 100,
selling_price: 50,
cost_price: 25,
});
});
it("shows the computed markup percentage next to the locked selling", () => {
// baseItem.selling_price = 50; cost starts at 0 → markup shows '—'.
renderModal();
expect(screen.getByLabelText(/markup/i).textContent).toMatch(/—/);
// Set cost → markup should turn into "100.0%".
return setField("Cost (ZAR)", "25").then(() => {
expect(screen.getByLabelText(/markup/i).textContent).toMatch(/100\.0%/);
});
});
it("shows the order description as a read-only hint above the PO description", () => {
renderModal();
// Order description should render above the textarea (read-only).
expect(
screen.getByLabelText(/order description \(read-only\)/i),
).toBeInTheDocument();
expect(
screen.getByLabelText(/order description \(read-only\)/i),
).toHaveTextContent("HSS drill bit");
// The PO description textarea is still editable below it.
expect(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
).toBeInTheDocument();
});
it("shows the resolved supplier name on each Recent Orders row", async () => {
// The history fetch is the FIRST getList call (PB returns the
// most recent matching order_items). Seed it with a row whose
// supplier id maps to "Steel Co" in our suppliers list — the
// row should render that name so the operator can see who
// supplied it last time without expanding the PB relation.
getListMock.mockResolvedValueOnce({
items: [{
id: "hist-1",
description: "HSS drill bit",
sku: "DRL-001",
supplier: "sup-1",
cost_price: 20,
selling_price: 50,
markup: 150,
order: "order-prev",
updated: "2026-07-01",
expand: { order: { order_number: "ORD-PREV" } },
}],
totalItems: 1,
totalPages: 1,
});
// Subsequent fetches (price_list) stay empty.
getListMock.mockResolvedValue({ items: [], totalItems: 0, totalPages: 1 });
renderModal();
await waitFor(() => {
expect(screen.getByText("Steel Co")).toBeInTheDocument();
});
// Order number + cost + selling still show alongside.
expect(screen.getByText("ORD-PREV")).toBeInTheDocument();
expect(screen.getByText(/R20\.00/)).toBeInTheDocument();
});
it("omits the supplier chip when the history row's supplier id is not in the suppliers list", async () => {
getListMock.mockResolvedValueOnce({
items: [{
id: "hist-1",
description: "HSS drill bit",
sku: "DRL-001",
supplier: "sup-deleted",
cost_price: 20,
selling_price: 50,
markup: 150,
order: "order-prev",
updated: "2026-07-01",
expand: { order: { order_number: "ORD-PREV" } },
}],
totalItems: 1,
totalPages: 1,
});
getListMock.mockResolvedValue({ items: [], totalItems: 0, totalPages: 1 });
renderModal();
await waitFor(() => {
expect(screen.getByText("ORD-PREV")).toBeInTheDocument();
});
// The unknown supplier id should not be surfaced as a fallback
// — the chip is omitted entirely so the operator doesn't see a
// stale id. (Both "Steel Co" and "Bolt Suppliers" appear in the
// mocked SupplierSearch <option>s, so we can't use a generic
// negative-text assertion; instead we confirm the unknown id
// is NOT rendered anywhere.)
expect(screen.queryByText("sup-deleted")).toBeNull();
});
});