442 lines
No EOL
14 KiB
TypeScript
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();
|
|
});
|
|
}); |