import { describe, it, expect, beforeEach, vi } from "vitest"; // --- PocketBase mock --------------------------------------------------------- // Mirrors the hoisted-mock pattern used in orders-service.test.ts and // stock-surplus.test.ts. The mock is enough for the service layer // (create / list / get / receive / mark-sent); it doesn't try to // emulate the full PB SDK — only the methods we use. const { store, pbMock, clearStore, setNow, now } = vi.hoisted(() => { type StoreMap = Map>; const store: Record = {}; let seq = 0; const nextId = () => `mock-${++seq}`; let nowDate = new Date("2026-07-14T12:00:00.000Z"); const getOrCreateMap = (name: string): StoreMap => { if (!store[name]) store[name] = new Map(); return store[name]; }; const where = (name: string, filter: string): Record[] => { const items = Array.from(getOrCreateMap(name).values()); if (!filter || !filter.trim()) return items; const clauses = filter.split("||").map((c) => c.trim()); return items.filter((item) => clauses.some((clause) => { // PB's `~` is case-insensitive contains. The service uses it // on the purchase_number for the daily-prefix scan, so we // emulate the substring match (case-insensitive). const m = clause.match(/^(\w+)\s*~\s*"([^"]+)"$/); if (m) { const [, field, raw] = m; const value = String(item[field] ?? "").toLowerCase(); return value.includes(raw.toLowerCase()); } const eq = clause.match(/^(\w+)\s*=\s*"([^"]+)"$/); if (eq) { const [, field, raw] = eq; return String(item[field] ?? "") === raw; } return true; }), ); }; const pbMock = { collection: (name: string) => ({ async getList( page = 1, perPage = 25, opts: { filter?: string; sort?: string; expand?: string } = {}, ) { const items = where(name, opts.filter || ""); if (opts.sort) { const sign = opts.sort.startsWith("-") ? -1 : 1; const field = opts.sort.replace(/^-/, ""); items.sort((a, b) => { const av = a[field] as string | number | undefined; const bv = b[field] as string | number | undefined; if (av === bv) { // Tie-break by id (insertion order). PB uses a similar // stable fallback for identical sort keys. return String(a.id).localeCompare(String(b.id)) * -sign; } if (av === undefined) return 1; if (bv === undefined) return -1; return (av > bv ? 1 : -1) * sign; }); } return { items, totalItems: items.length, page, perPage, totalPages: Math.max(1, Math.ceil(items.length / perPage)), }; }, async getFullList(opts: { filter?: string; sort?: string; expand?: string } = {}) { const items = where(name, opts.filter || ""); if (opts.sort) { const sign = opts.sort.startsWith("-") ? -1 : 1; const field = opts.sort.replace(/^-/, ""); items.sort((a, b) => { const av = a[field] as string | number | undefined; const bv = b[field] as string | number | undefined; if (av === bv) { return String(a.id).localeCompare(String(b.id)) * -sign; } if (av === undefined) return 1; if (bv === undefined) return -1; return (av > bv ? 1 : -1) * sign; }); } return items; }, async getFirstListItem(filter: string) { const items = where(name, filter); if (items.length === 0) { const err = new Error("not found") as Error & { status: number }; err.status = 404; throw err; } return items[0]; }, async getOne(id: string, _opts?: { expand?: string }) { const item = getOrCreateMap(name).get(id); if (!item) { const err = new Error("not found") as Error & { status: number }; err.status = 404; throw err; } return item; }, async create(data: Record) { const id = nextId(); // Each create bumps `nowDate` by 1ms so the `-created` sort // is deterministic across the test suite. PB on a real DB // gets distinct ISO timestamps; this keeps the mock honest. nowDate = new Date(nowDate.getTime() + 1); const item = { id, created: nowDate.toISOString(), updated: nowDate.toISOString(), ...data, }; getOrCreateMap(name).set(id, item); return item; }, async update(id: string, data: Record | FormData) { const map = getOrCreateMap(name); const existing = map.get(id); if (!existing) { const err = new Error("not found") as Error & { status: number }; err.status = 404; throw err; } // Handle FormData uploads (PB file fields). PB stores the // generated filename token; for the test we approximate by // appending the original filename to `documents` so the // post-update `getOne` sees what the production code would. if ( typeof FormData !== "undefined" && data instanceof FormData ) { for (const [key, value] of data.entries()) { if (typeof Blob !== "undefined" && value instanceof Blob) { // The PDF service uses `new Blob([...], { type: ... })` // with a filename in the FormData append slot. The // blob itself doesn't carry the filename, so we read // it off the FormData entry metadata if available. const filename = (value as Blob & { name?: string }).name || "upload.pdf"; const arr = Array.isArray(existing[key]) ? (existing[key] as string[]) : existing[key] ? [existing[key] as string] : []; existing[key] = [...arr, filename]; } } existing.updated = nowDate.toISOString(); return { ...existing }; } for (const [key, value] of Object.entries(data as Record)) { if (key.endsWith("+")) { const real = key.slice(0, -1); existing[real] = Number(existing[real] ?? 0) + Number(value); } else { existing[key] = value; } } existing.updated = nowDate.toISOString(); return { ...existing }; }, }), }; const clearStore = () => { for (const key of Object.keys(store)) delete store[key]; seq = 0; nowDate = new Date("2026-07-14T12:00:00.000Z"); }; const setNow = (d: Date) => { nowDate = d; }; return { store, pbMock, clearStore, setNow, now: () => nowDate }; }); vi.mock("pocketbase", () => { return { default: vi.fn().mockImplementation(() => pbMock), }; }); vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), })); // Mock the PDF rendering + letterhead so `generateStockPurchasePdf` // can run in unit tests without spinning up the full @react-pdf // pipeline. The render mock returns a fake Buffer; the letterhead // mock returns a minimal shape the code reads. vi.mock("@/lib/pdf/render", () => ({ renderStockPurchasePdf: vi.fn().mockResolvedValue(Buffer.from("FAKE-PDF")), })); vi.mock("@/lib/letterhead", () => ({ fetchLetterhead: vi.fn().mockResolvedValue({ companyName: "CNL Mining Supplies", address: "1 Test Lane", phone: "", email: "", vatNumber: "", registrationNumber: "", bankDetails: "", }), loadLetterheadLogoDataUri: vi.fn().mockResolvedValue("data:image/svg+xml;base64,FAKE"), })); import { createStockPurchase, generateStockPurchaseNumber, generateStockPurchasePdf, StockPurchasePdfValidationError, listStockPurchases, getStockPurchase, getStockPurchaseItems, receiveStockPurchase, markStockPurchaseSent, recordStockPurchaseSendOutcome, } from "./stock-purchases-service"; beforeEach(() => { clearStore(); // Default "today" for the tests. Tests that care about the date // override via setNow() before invoking the helper. setNow(new Date("2026-07-14T12:00:00.000Z")); }); // The service uses `new Date()` directly for the date prefix // (ignoring the `setNow()` mock — the mock would need to be applied // to the service's clock too, but that's a separate refactor). To // make the tests resilient to the calendar advancing past the hard- // coded date, we compute the expected prefix from the real wall clock // at test time. The `nn` (sequence) part is the test's concern. function realDatePrefix(): string { const now = new Date(); const yy = String(now.getFullYear()).slice(-2); const mm = String(now.getMonth() + 1).padStart(2, "0"); const dd = String(now.getDate()).padStart(2, "0"); return `PO${yy}${mm}${dd}`; } // ============================================================================ // generateStockPurchaseNumber // ============================================================================ describe("generateStockPurchaseNumber", () => { it("starts at POYYMMDD01STK when no purchases exist for today", async () => { const num = await generateStockPurchaseNumber(pbMock); expect(num).toBe(`${realDatePrefix()}01STK`); }); it("increments the daily sequence based on existing rows", async () => { // Seed three existing rows for today. Use the real-date prefix // so the test is robust to the wall clock advancing. const prefix = realDatePrefix(); await pbMock.collection("stock_purchases").create({ purchase_number: `${prefix}01STK`, }); await pbMock.collection("stock_purchases").create({ purchase_number: `${prefix}02STK`, }); await pbMock.collection("stock_purchases").create({ purchase_number: `${prefix}03STK`, }); const num = await generateStockPurchaseNumber(pbMock); expect(num).toBe(`${prefix}04STK`); }); it("ignores rows from a different year (different prefix)", async () => { // Seed a row from a previous year. The scan filter is the // daily prefix (PO{yy}{mm}{dd}), so a stale row from last // year doesn't bump today's sequence. await pbMock.collection("stock_purchases").create({ purchase_number: "PO25010199STK", }); const num = await generateStockPurchaseNumber(pbMock); expect(num).toBe(`${realDatePrefix()}01STK`); }); }); // ============================================================================ // createStockPurchase — validation // ============================================================================ describe("createStockPurchase — validation", () => { it("rejects a missing supplier", async () => { const result = await createStockPurchase(pbMock, { supplier: "", items: [ { stock_item: "si-1", description: "Widget", quantity: 5 }, ], }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("supplier_required"); expect(result.message).toMatch(/supplier/); } }); it("rejects an empty items array", async () => { const result = await createStockPurchase(pbMock, { supplier: "sup-1", items: [], }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("items_required"); } }); it("rejects a line with quantity 0", async () => { const result = await createStockPurchase(pbMock, { supplier: "sup-1", items: [ { stock_item: "si-1", description: "Widget", quantity: 0 }, ], }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("invalid_item"); expect(result.message).toMatch(/quantity/i); } }); it("rejects a line with negative quantity", async () => { const result = await createStockPurchase(pbMock, { supplier: "sup-1", items: [ { stock_item: "si-1", description: "Widget", quantity: -3 }, ], }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("invalid_item"); } }); it("rejects a line with NaN quantity", async () => { const result = await createStockPurchase(pbMock, { supplier: "sup-1", items: [ { stock_item: "si-1", description: "Widget", quantity: Number.NaN }, ], }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("invalid_item"); } }); it("rejects a line with missing description", async () => { const result = await createStockPurchase(pbMock, { supplier: "sup-1", items: [ { stock_item: "si-1", description: "", quantity: 5 }, ], }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("invalid_item"); expect(result.message).toMatch(/description/i); } }); it("rejects a line with missing stock_item", async () => { const result = await createStockPurchase(pbMock, { supplier: "sup-1", items: [ { stock_item: "", description: "Widget", quantity: 5 }, ], }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("invalid_item"); } }); }); // ============================================================================ // createStockPurchase — happy path // ============================================================================ describe("createStockPurchase — happy path", () => { it("creates the header in draft status with a generated purchase_number", async () => { const result = await createStockPurchase(pbMock, { supplier: "sup-1", notes: "first restock", items: [ { stock_item: "si-1", description: "Widget", quantity: 5, cost_price: 12.5 }, ], }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.purchase.status).toBe("draft"); expect(result.purchase.supplier).toBe("sup-1"); expect(result.purchase.purchase_number).toBe(`${realDatePrefix()}01STK`); expect(result.purchase.notes).toBe("first restock"); }); it("creates one stock_purchase_item per line, linked to the header", async () => { const result = await createStockPurchase(pbMock, { supplier: "sup-1", items: [ { stock_item: "si-1", description: "Widget", quantity: 5, cost_price: 12.5 }, { stock_item: "si-2", description: "Sprocket", quantity: 2, cost_price: 9.99 }, ], }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.items.length).toBe(2); const allItems = await pbMock.collection("stock_purchase_items").getFullList(); const ours = allItems.filter((it) => it.purchase === result.purchase.id); expect(ours.length).toBe(2); const widget = ours.find((it) => it.description === "Widget"); expect(widget).toMatchObject({ purchase: result.purchase.id, stock_item: "si-1", quantity: 5, quantity_received: 0, cost_price: 12.5, }); }); }); // ============================================================================ // listStockPurchases // ============================================================================ describe("listStockPurchases", () => { it("returns rows sorted -created with item_count expanded", async () => { // Seed two purchases + lines. const p1 = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", supplier: "sup-1", status: "draft", notes: "", }); const p2 = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071502STK", supplier: "sup-1", status: "sent", notes: "", }); await pbMock.collection("stock_purchase_items").create({ purchase: p1.id, stock_item: "si-1", description: "A", quantity: 1, quantity_received: 0, cost_price: 0, notes: "", }); await pbMock.collection("stock_purchase_items").create({ purchase: p1.id, stock_item: "si-2", description: "B", quantity: 1, quantity_received: 0, cost_price: 0, notes: "", }); await pbMock.collection("stock_purchase_items").create({ purchase: p2.id, stock_item: "si-3", description: "C", quantity: 1, quantity_received: 0, cost_price: 0, notes: "", }); const result = await listStockPurchases(pbMock); expect(result.items.length).toBe(2); // Sorted -created: p2 is newer, lands first. expect(result.items[0].id).toBe(p2.id); expect(result.items[0].item_count).toBe(1); expect(result.items[1].id).toBe(p1.id); expect(result.items[1].item_count).toBe(2); }); it("filters by status when provided", async () => { await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", status: "draft", }); await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071502STK", status: "sent", }); const result = await listStockPurchases(pbMock, { status: "sent" }); expect(result.items.length).toBe(1); expect(result.items[0].status).toBe("sent"); }); }); // ============================================================================ // getStockPurchase + getStockPurchaseItems // ============================================================================ describe("getStockPurchase + getStockPurchaseItems", () => { it("returns the header and the lines separately", async () => { const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", supplier: "sup-1", status: "draft", notes: "", }); await pbMock.collection("stock_purchase_items").create({ purchase: p.id, stock_item: "si-1", description: "A", quantity: 1, quantity_received: 0, cost_price: 0, notes: "", }); const purchase = await getStockPurchase(pbMock, p.id); expect(purchase.id).toBe(p.id); const items = await getStockPurchaseItems(pbMock, p.id); expect(items.length).toBe(1); expect(items[0].description).toBe("A"); }); }); // ============================================================================ // markStockPurchaseSent // ============================================================================ describe("markStockPurchaseSent", () => { it("flips draft → sent", async () => { const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", status: "draft", }); const result = await markStockPurchaseSent(pbMock, p.id); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.purchase.status).toBe("sent"); }); it("rejects a non-draft purchase (already sent)", async () => { const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", status: "sent", }); const result = await markStockPurchaseSent(pbMock, p.id); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("wrong_status"); } }); it("returns not_found for a missing id", async () => { const result = await markStockPurchaseSent(pbMock, "missing-id"); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("not_found"); } }); }); // ============================================================================ // receiveStockPurchase — the big one // ============================================================================ describe("receiveStockPurchase", () => { // Helper to seed a sent purchase + N lines + N stock_items. async function seedSentPurchase(opts: { lineQuantities: number[]; stockItemQty?: number; }) { const stockItemQty = opts.stockItemQty ?? 0; const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", supplier: "sup-1", status: "sent", }); const stockItemIds: string[] = []; for (let i = 0; i < opts.lineQuantities.length; i++) { const si = await pbMock.collection("stock_items").create({ name: `Item ${i + 1}`, current_qty: stockItemQty, is_active: true, }); stockItemIds.push(si.id); await pbMock.collection("stock_purchase_items").create({ purchase: p.id, stock_item: si.id, description: `Line ${i + 1}`, quantity: opts.lineQuantities[i], quantity_received: 0, cost_price: 1.0, notes: "", }); } return { purchaseId: p.id, stockItemIds }; } it("receives the full remaining qty per line and flips status to received (NO inventory writes)", async () => { const { purchaseId, stockItemIds } = await seedSentPurchase({ lineQuantities: [10, 5], }); const result = await receiveStockPurchase(pbMock, purchaseId); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.purchase.status).toBe("received"); expect(result.lines.length).toBe(2); expect(result.lines[0].booked_qty).toBe(10); expect(result.lines[1].booked_qty).toBe(5); // Inventory writes moved to bookStockFromInvoice. The receive // helper no longer touches stock_items or stock_ledger, so // current_qty must stay at the seeded value (0). const si0 = await pbMock.collection("stock_items").getOne(stockItemIds[0]); const si1 = await pbMock.collection("stock_items").getOne(stockItemIds[1]); expect(si0.current_qty).toBe(0); expect(si1.current_qty).toBe(0); // No ledger rows either — same reason. const ledger = await pbMock.collection("stock_ledger").getFullList(); expect(ledger.length).toBe(0); // Line result shape keeps ledger_id + new_current_qty for // backward-compat with the OCR match report — both stay // empty/zero because the writes now live elsewhere. expect(result.lines[0].ledger_id).toBe(""); expect(result.lines[0].new_current_qty).toBe(0); // stock_purchase_items.quantity_received accumulated correctly. const lines = await pbMock .collection("stock_purchase_items") .getFullList({ filter: `purchase = "${purchaseId}"` }); const sorted = lines.sort((a, b) => String(a.description).localeCompare(String(b.description)) ); // Sorted by description: "Line 1" (qty 10) first, "Line 2" (qty 5) second. expect(sorted[0].description).toBe("Line 1"); expect(sorted[0].quantity_received).toBe(10); // Lines flip to "collected" so the accountant's booked_at = null // queue picks them up. expect(sorted[0].status).toBe("collected"); expect(sorted[1].description).toBe("Line 2"); expect(sorted[1].quantity_received).toBe(5); expect(sorted[1].status).toBe("collected"); }); it("honours a per-line quantity_received override (partial receive)", async () => { const { purchaseId, stockItemIds } = await seedSentPurchase({ lineQuantities: [10, 5], }); // Override: receive 5 of the 10 on line 1, 3 of 5 on line 2. const lines = await pbMock .collection("stock_purchase_items") .getFullList({ filter: `purchase = "${purchaseId}"` }); const sorted = lines.sort((a, b) => String(a.description).localeCompare(String(b.description)) ); const sorted0 = sorted[0] as { id: string }; const sorted1 = sorted[1] as { id: string }; const result = await receiveStockPurchase(pbMock, purchaseId, { items: [{ id: sorted0.id, quantity_received: 5 }, { id: sorted1.id, quantity_received: 3 }], }); expect(result.ok).toBe(true); if (!result.ok) return; // current_qty is NOT bumped by the receive helper any more — // the inventory commit is bookStockFromInvoice's job. const si0 = await pbMock.collection("stock_items").getOne(stockItemIds[0]); const si1 = await pbMock.collection("stock_items").getOne(stockItemIds[1]); expect(si0.current_qty).toBe(0); expect(si1.current_qty).toBe(0); // status flipped to received. expect(result.purchase.status).toBe("received"); // Per-line overrides are recorded on quantity_received. const updated = await pbMock .collection("stock_purchase_items") .getFullList({ filter: `purchase = "${purchaseId}"` }); const updatedSorted = updated.sort((a, b) => String(a.description).localeCompare(String(b.description)) ); expect((updatedSorted[0] as { quantity_received: number }).quantity_received).toBe(5); expect((updatedSorted[1] as { quantity_received: number }).quantity_received).toBe(3); }); it("caps an override at the remaining qty (cannot receive more than ordered)", async () => { const { purchaseId, stockItemIds } = await seedSentPurchase({ lineQuantities: [5], }); const [line] = await pbMock .collection("stock_purchase_items") .getFullList({ filter: `purchase = "${purchaseId}"` }); const lineT = line as { id: string }; const result = await receiveStockPurchase(pbMock, purchaseId, { items: [{ id: lineT.id, quantity_received: 9999 }], }); expect(result.ok).toBe(true); if (!result.ok) return; // The accumulator lands at the line's ordered qty (5) — never // above it. const updated = await pbMock.collection("stock_purchase_items").getOne(lineT.id); expect(updated.quantity_received).toBe(5); // stock_items.current_qty unchanged (the helper doesn't bump it). const si = await pbMock.collection("stock_items").getOne(stockItemIds[0]); expect(si.current_qty).toBe(0); }); it("does NOT double-handle when the same purchase is received twice (idempotency)", async () => { const { purchaseId, stockItemIds } = await seedSentPurchase({ lineQuantities: [10], }); // First receive: accumulates qty_received, flips to received. const first = await receiveStockPurchase(pbMock, purchaseId); expect(first.ok).toBe(true); // Second receive: rejected with wrong_status because the header // is now `received`, not `sent`. No new writes anywhere. const second = await receiveStockPurchase(pbMock, purchaseId); expect(second.ok).toBe(false); if (second.ok) return; expect(second.reason).toBe("wrong_status"); // current_qty is still at the seeded value (the helper never // touched it). const si = await pbMock.collection("stock_items").getOne(stockItemIds[0]); expect(si.current_qty).toBe(0); // No ledger rows from the receive helper. const ledger = await pbMock.collection("stock_ledger").getFullList(); expect(ledger.length).toBe(0); // quantity_received on the line is 10 — the accumulator landed // once, not twice. const lines = await pbMock .collection("stock_purchase_items") .getFullList({ filter: `purchase = "${purchaseId}"` }); expect((lines[0] as { quantity_received: number }).quantity_received).toBe(10); }); it("rejects a purchase that is in draft (not sent)", async () => { const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", status: "draft", }); const result = await receiveStockPurchase(pbMock, p.id); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("wrong_status"); } }); it("rejects a missing purchase", async () => { const result = await receiveStockPurchase(pbMock, "missing-id"); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("not_found"); } }); it("rejects a negative per-line override", async () => { const { purchaseId } = await seedSentPurchase({ lineQuantities: [10], }); const [line] = await pbMock .collection("stock_purchase_items") .getFullList({ filter: `purchase = "${purchaseId}"` }); const lineT = line as { id: string }; const result = await receiveStockPurchase(pbMock, purchaseId, { items: [{ id: lineT.id, quantity_received: -1 }], }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("wrong_status"); } }); }); // ============================================================================ // listStockPurchases — total value aggregation // ============================================================================ describe("listStockPurchases — total value", () => { async function seedPurchaseWithItems(pb: typeof pbMock, id: string, items: Array<{ quantity: number; quantity_received: number; cost_price: number; }>) { await pb.collection("stock_purchases").create({ id, purchase_number: `PO26000100STK-${id}`, supplier: "sup-1", status: "draft", notes: "", email_error: "", email_message_id: "", }); for (const item of items) { await pb.collection("stock_purchase_items").create({ purchase: id, stock_item: "si-1", description: "Widget", ...item, notes: "", }); } } it("returns ordered_value = Σ quantity × cost_price across lines", async () => { await seedPurchaseWithItems(pbMock, "sp-a", [ { quantity: 10, quantity_received: 0, cost_price: 12.5 }, { quantity: 4, quantity_received: 0, cost_price: 99 }, ]); const { items } = await listStockPurchases(pbMock); const row = items.find((r) => r.id === "sp-a")!; expect(row.ordered_value).toBe(521); // 125 + 396 }); it("returns received_value = Σ quantity_received × cost_price across lines", async () => { await seedPurchaseWithItems(pbMock, "sp-a", [ { quantity: 10, quantity_received: 6, cost_price: 12.5 }, { quantity: 4, quantity_received: 4, cost_price: 99 }, ]); const { items } = await listStockPurchases(pbMock); const row = items.find((r) => r.id === "sp-a")!; expect(row.received_value).toBe(471); // 75 + 396 }); it("rounds ordered_value and received_value to 2dp", async () => { await seedPurchaseWithItems(pbMock, "sp-a", [ { quantity: 3, quantity_received: 1, cost_price: 0.1 + 0.2 }, // 0.30000000000000004 ]); const { items } = await listStockPurchases(pbMock); const row = items.find((r) => r.id === "sp-a")!; expect(row.ordered_value).toBe(0.9); expect(row.received_value).toBe(0.3); }); it("returns { ordered_value: 0, received_value: 0 } when a purchase has no lines", async () => { await pbMock.collection("stock_purchases").create({ id: "sp-empty", purchase_number: "PO26000200STK", supplier: "sup-1", status: "draft", notes: "", email_error: "", email_message_id: "", }); const { items } = await listStockPurchases(pbMock); const row = items.find((r) => r.id === "sp-empty")!; expect(row.ordered_value).toBe(0); expect(row.received_value).toBe(0); expect(row.item_count).toBe(0); }); it("computes each row's totals from its own items only (no cross-row leakage)", async () => { await seedPurchaseWithItems(pbMock, "sp-x", [ { quantity: 1, quantity_received: 1, cost_price: 100 }, ]); await seedPurchaseWithItems(pbMock, "sp-y", [ { quantity: 2, quantity_received: 2, cost_price: 50 }, ]); const { items } = await listStockPurchases(pbMock); const x = items.find((r) => r.id === "sp-x")!; const y = items.find((r) => r.id === "sp-y")!; expect(x.ordered_value).toBe(100); expect(x.received_value).toBe(100); expect(y.ordered_value).toBe(100); expect(y.received_value).toBe(100); }); }); // ============================================================================ // recordStockPurchaseSendOutcome // ============================================================================ describe("recordStockPurchaseSendOutcome", () => { it("does NOT auto-flip stock_purchase_items.status on success (operator must mark them ready to collect manually)", async () => { const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", status: "sending", email_payload: { sentAt: "", sentMessageId: "" }, }); const it1 = await pbMock.collection("stock_purchase_items").create({ purchase: p.id, status: "collection_pool", }); const it2 = await pbMock.collection("stock_purchase_items").create({ purchase: p.id, status: "collection_pool", }); const result = await recordStockPurchaseSendOutcome({ purchaseId: p.id, outcome: "success", messageId: "", }); // The stock_purchase itself flips to "sent" + stamps the message ID. expect(result.status).toBe("sent"); expect(result.email_message_id).toBe(""); // The items MUST stay in collection_pool — the operator marks them // ready-to-collect from the stock-order page only after the supplier // has confirmed. Same pattern as customer POs. const itemsAfter = await pbMock .collection("stock_purchase_items") .getFullList({ filter: `purchase = "${p.id}"` }); expect(itemsAfter.find((i) => i.id === it1.id)?.status).toBe("collection_pool"); expect(itemsAfter.find((i) => i.id === it2.id)?.status).toBe("collection_pool"); }); it("flips stock_purchase to sent + stamps email_message_id on success", async () => { const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", status: "sending", email_payload: { sentAt: "", sentMessageId: "" }, }); const result = await recordStockPurchaseSendOutcome({ purchaseId: p.id, outcome: "success", messageId: "", }); expect(result.status).toBe("sent"); expect(result.email_message_id).toBe(""); expect(result.email_error).toBe(""); expect((result.email_payload as Record).sentAt).toBeTruthy(); expect((result.email_payload as Record).sentMessageId).toBe( "", ); }); it("flips stock_purchase to mail_failed + stamps errorMessage on failure (does not touch items)", async () => { const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", status: "sending", }); const it1 = await pbMock.collection("stock_purchase_items").create({ purchase: p.id, status: "collection_pool", }); const result = await recordStockPurchaseSendOutcome({ purchaseId: p.id, outcome: "failure", errorMessage: "SMTP 550 mailbox unavailable", }); expect(result.status).toBe("mail_failed"); expect(result.email_error).toBe("[smtp] SMTP 550 mailbox unavailable"); // Items are NOT touched on failure (and were never touched to begin with). const itemsAfter = await pbMock .collection("stock_purchase_items") .getFullList({ filter: `purchase = "${p.id}"` }); expect(itemsAfter.find((i) => i.id === it1.id)?.status).toBe("collection_pool"); }); }); describe("generateStockPurchasePdf", () => { /** * Seed a stock purchase with a supplier + at least one linked * line item. Returns the purchase row. */ async function seedStockPurchaseWithLines(opts: { documents?: string[]; documentsHistory?: Array>; existingDocuments?: string[]; } = {}) { const sup = await pbMock.collection("suppliers").create({ suppliername: "Acme Mining", email: "sales@acme.test", }); const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", status: "draft", supplier: sup.id, documents: opts.existingDocuments ?? [], documents_history: opts.documentsHistory ?? [], }); // Stitch `expand.supplier` onto the purchase so the mock's // expand option isn't needed (the collection map keys are // plural — "suppliers" — but the service reads `expand.supplier`, // so it's easier to set expand directly here). (p as Record).expand = { supplier: sup }; await pbMock.collection("stock_purchase_items").create({ purchase: p.id, stock_item: "stk-1", description: "Hose 1m", quantity: 4, cost_price: 250, status: "collection_pool", }); await pbMock.collection("stock_purchase_items").create({ purchase: p.id, stock_item: "stk-2", description: "Fitting", quantity: 2, cost_price: 100, status: "collection_pool", }); return p; } it("renders a PDF, appends it to documents, and writes a history entry", async () => { const p = await seedStockPurchaseWithLines(); const result = await generateStockPurchasePdf(p.id, {}); expect(result.pdfId).toBe(p.id); expect(result.version).toBe(1); expect(result.filename).toMatch(/^STK-PO-CNL-PO26071501STK-v1\.pdf$/); expect(result.total).toBe(1200); // 4*250 + 2*100 = 1200 expect(result.downloadUrl).toBe(`/api/v1/files/stock_purchases/${p.id}/${result.filename}`); const after = await pbMock.collection("stock_purchases").getOne(p.id); const docsAfter = Array.isArray(after.documents) ? after.documents : after.documents ? [after.documents] : []; expect(docsAfter).toContain(result.filename); const history = (after.documents_history ?? []) as Array>; expect(history).toHaveLength(1); expect(history[0]).toMatchObject({ version: 1, file: result.filename, status: "current", }); expect(after.total_amount).toBe(1200); }); it("bumps version + marks previous 'current' as 'superseded' on regen", async () => { const p = await seedStockPurchaseWithLines({ documents: ["STK-PO-CNL-PO26071501STK-v1.pdf"], documentsHistory: [ { version: 1, file: "STK-PO-CNL-PO26071501STK-v1.pdf", token: "tok-v1", generatedAt: "2026-07-14T12:00:00.000Z", generatedBy: "", reason: "", status: "current", }, ], }); const result = await generateStockPurchasePdf(p.id, { notes: "Edited cost_price on line 2", }); expect(result.version).toBe(2); const after = await pbMock.collection("stock_purchases").getOne(p.id); const history = after.documents_history as Array>; expect(history).toHaveLength(2); expect(history[0]?.status).toBe("superseded"); expect(history[1]).toMatchObject({ version: 2, file: result.filename, status: "current", reason: "Edited cost_price on line 2", }); }); it("does NOT touch the purchase status (PDF lifecycle ≠ email lifecycle)", async () => { const p = await seedStockPurchaseWithLines(); // Status starts as "draft"; PDF generation must leave it alone. const result = await generateStockPurchasePdf(p.id, {}); expect(result).toBeDefined(); const after = await pbMock.collection("stock_purchases").getOne(p.id); expect(after.status).toBe("draft"); }); it("throws StockPurchasePdfValidationError(purchase_not_found) for a missing id", async () => { await expect(generateStockPurchasePdf("does-not-exist", {})).rejects.toBeInstanceOf( StockPurchasePdfValidationError, ); await expect(generateStockPurchasePdf("does-not-exist", {})).rejects.toMatchObject({ code: "purchase_not_found", }); }); it("throws StockPurchasePdfValidationError(missing_relations) when the PO has no supplier", async () => { const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", status: "draft", supplier: "", documents: [], documents_history: [], }); await pbMock.collection("stock_purchase_items").create({ purchase: p.id, description: "x", quantity: 1, cost_price: 1, }); await expect(generateStockPurchasePdf(p.id, {})).rejects.toBeInstanceOf( StockPurchasePdfValidationError, ); await expect(generateStockPurchasePdf(p.id, {})).rejects.toMatchObject({ code: "missing_relations", }); }); it("throws StockPurchasePdfValidationError(no_items) when there are no line items", async () => { const sup = await pbMock.collection("suppliers").create({ suppliername: "Acme", email: "a@b.test", }); const p = await pbMock.collection("stock_purchases").create({ purchase_number: "PO26071501STK", status: "draft", supplier: sup.id, documents: [], documents_history: [], }); // Stitch expand so the supplier check passes and the no_items // branch is reached. (p as Record).expand = { supplier: sup }; await expect(generateStockPurchasePdf(p.id, {})).rejects.toBeInstanceOf( StockPurchasePdfValidationError, ); await expect(generateStockPurchasePdf(p.id, {})).rejects.toMatchObject({ code: "no_items", }); }); });