WFDASH-WEBAPP/src/lib/server/purchase-order-agent-service.test.ts
Dave 59f245d798 feat(collection-confirm): add stock item edits
- Add new POST API endpoints at `/api/v1/stock-purchase-items/[id]/sku` and `/api/v1/stock-purchase-items/[id]/cost` for updating stock purchase item details
- Update ItemsTable to support cost and SKU edits for all row types, add supplier SKU column and editing for stock rows
- Extend PoCard to pass edit callbacks and add stock-specific invoice upload functionality
- Update all related test suites to cover new editing and UI features
- Adjust invoice upload messaging to differentiate between customer and stock purchase orders
2026-08-14 16:39:46 +02:00

233 lines
No EOL
7.5 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
scanPurchaseOrders,
type ScanPb,
} from "@/lib/server/purchase-order-agent-service";
/**
* Tests for the agent's read-side triage service.
*
* Mock strategy: stub the two `getFullList` paths the service uses
* (per-kind header read + per-PO line-item read) and assert on
* the returned shape. Mocking both at the collection level keeps
* the tests resilient to PB field drift — the service uses
* generic `Record<string, unknown>` access throughout.
*/
interface MockState {
stockHeaders: Array<Record<string, unknown>>;
stockLinesByPurchase: Record<string, Array<Record<string, unknown>>>;
customerHeaders: Array<Record<string, unknown>>;
customerLinesByPurchase: Record<string, Array<Record<string, unknown>>>;
}
function buildMockPb(state: MockState): ScanPb & {
calls: { collection: string; filter: string }[];
} {
const calls: { collection: string; filter: string }[] = [];
const collection = (name: string) => ({
getFullList: async (opts?: {
filter?: string;
expand?: string;
fields?: string;
}) => {
const filter = opts?.filter ?? "";
calls.push({ collection: name, filter });
if (name === "stock_purchases") return state.stockHeaders;
if (name === "purchase_orders") return state.customerHeaders;
if (name === "stock_purchase_items") {
// Filter shape: `purchase = "<id>"`. Match by id literal.
const match = /purchase = "([^"]+)"/.exec(filter);
const id = match?.[1] ?? "";
return state.stockLinesByPurchase[id] ?? [];
}
if (name === "order_items") {
const match = /purchase_order = "([^"]+)"/.exec(filter);
const id = match?.[1] ?? "";
return state.customerLinesByPurchase[id] ?? [];
}
return [];
},
getList: async () => ({
items: [],
totalItems: 0,
page: 1,
perPage: 0,
totalPages: 0,
}),
});
return { collection, calls } as unknown as ScanPb & {
calls: { collection: string; filter: string }[];
};
}
describe("scanPurchaseOrders", () => {
it("returns empty arrays when both kinds have no rows", async () => {
const pb = buildMockPb({
stockHeaders: [],
stockLinesByPurchase: {},
customerHeaders: [],
customerLinesByPurchase: {},
});
const result = await scanPurchaseOrders(pb);
expect(result.total_needing_attention).toBe(0);
expect(result.by_kind).toEqual({ stock: 0, customer: 0 });
expect(result.pos).toEqual([]);
});
it("scans stock_purchases and computes per-line diagnostics", async () => {
const pb = buildMockPb({
stockHeaders: [
{
id: "sp-1",
purchase_number: "PO26080601STK",
status: "sent",
updated: "2026-08-13T10:00:00.000Z",
supplier: "sup-1",
additional_attachments: [],
expand: { supplier: { id: "sup-1", suppliername: "Steel Co" } },
},
],
stockLinesByPurchase: {
"sp-1": [
{ id: "si-1", sku: "B-001", supplier_sku: "EP-BOLT-10", cost_price: 2.5 },
{ id: "si-2", sku: "", supplier_sku: "EP-WASH-08", cost_price: 0 },
],
},
customerHeaders: [],
customerLinesByPurchase: {},
});
const result = await scanPurchaseOrders(pb, { kind: "stock" });
expect(result.pos).toHaveLength(1);
expect(result.pos[0]).toMatchObject({
id: "sp-1",
kind: "stock",
po_number: "PO26080601STK",
status: "sent",
attachment_filenames: [],
diagnostics: {
line_count: 2,
lines_missing_sku: 1,
lines_missing_cost: 1,
lines_missing_supplier_sku: 0,
},
});
});
it("scans purchase_orders (customer POs) when kind is 'customer'", async () => {
const pb = buildMockPb({
stockHeaders: [],
stockLinesByPurchase: {},
customerHeaders: [
{
id: "po-1",
po_number: "PO-2026-08-013",
status: "sent",
updated: "2026-08-13T09:00:00.000Z",
supplier: "sup-2",
additional_attachments: ["inv-1.pdf", "inv-2.pdf"],
expand: { supplier: { id: "sup-2", suppliername: "Acme" } },
},
],
customerLinesByPurchase: {
"po-1": [{ id: "oi-1", sku: "ABC", supplier_sku: "", cost_price: 100 }],
},
});
const result = await scanPurchaseOrders(pb, { kind: "customer" });
expect(result.pos).toHaveLength(1);
expect(result.pos[0]).toMatchObject({
id: "po-1",
kind: "customer",
po_number: "PO-2026-08-013",
attachment_filenames: ["inv-1.pdf", "inv-2.pdf"],
diagnostics: {
line_count: 1,
lines_missing_sku: 0,
lines_missing_cost: 0,
lines_missing_supplier_sku: 1,
},
});
});
it("covers both kinds when kind is 'both' (default)", async () => {
const pb = buildMockPb({
stockHeaders: [
{
id: "sp-1",
purchase_number: "PO26080601STK",
status: "sent",
updated: "2026-08-13T10:00:00.000Z",
supplier: "sup-1",
additional_attachments: [],
expand: { supplier: { id: "sup-1", suppliername: "Steel Co" } },
},
],
stockLinesByPurchase: {
"sp-1": [{ id: "si-1", sku: "", supplier_sku: "", cost_price: 0 }],
},
customerHeaders: [
{
id: "po-1",
po_number: "PO-2026-08-013",
status: "sent",
updated: "2026-08-13T11:00:00.000Z",
supplier: "sup-2",
additional_attachments: [],
expand: { supplier: { id: "sup-2", suppliername: "Acme" } },
},
],
customerLinesByPurchase: {
"po-1": [{ id: "oi-1", sku: "ABC", supplier_sku: "DEF", cost_price: 50 }],
},
});
const result = await scanPurchaseOrders(pb);
expect(result.total_needing_attention).toBe(2);
expect(result.by_kind).toEqual({ stock: 1, customer: 1 });
// Most recently updated first.
expect(result.pos[0]?.id).toBe("po-1");
expect(result.pos[1]?.id).toBe("sp-1");
});
it("normalises stringified additional_attachments into a filename array", async () => {
const pb = buildMockPb({
stockHeaders: [
{
id: "sp-1",
purchase_number: "PO26080601STK",
status: "sent",
updated: "2026-08-13T10:00:00.000Z",
supplier: "sup-1",
// Legacy shape: PB may return a comma-joined string instead
// of a JSON array. The service should still surface them
// so the agent can grep for invoice filenames.
additional_attachments: "inv-1.pdf, inv-2.pdf",
expand: { supplier: { id: "sup-1", suppliername: "Steel Co" } },
},
],
stockLinesByPurchase: { "sp-1": [] },
customerHeaders: [],
customerLinesByPurchase: {},
});
const result = await scanPurchaseOrders(pb, { kind: "stock" });
expect(result.pos[0]?.attachment_filenames).toEqual([
"inv-1.pdf",
"inv-2.pdf",
]);
});
it("filters by status — only 'sent' rows surface when only 'sent' is requested", async () => {
const pb = buildMockPb({
stockHeaders: [],
stockLinesByPurchase: {},
customerHeaders: [],
customerLinesByPurchase: {},
});
// Spy on the first stock_purchases getFullList call to capture
// the filter clause the service emitted.
await scanPurchaseOrders(pb, { status: ["sent"] });
const filterCall = pb.calls.find(
(c) => c.collection === "stock_purchases",
);
expect(filterCall?.filter).toBe('status = "sent"');
});
});