import { describe, it, expect, beforeEach, vi } from "vitest"; // --- PocketBase mock --------------------------------------------------------- // The mock state has to be hoisted to the top of the file because Vitest // hoists `vi.mock` factories above all imports. The state lives in a // `vi.hoisted` block so the factory can close over it. const { store, pbMock, clearStore } = vi.hoisted(() => { type StoreMap = Map>; const store: Record = {}; let seq = 0; const nextId = () => `mock-${++seq}`; // Relation name → collection name. PB stores every relation as the // singular id and the related records in the pluralised collection // (e.g. `order.customer` → `customers` collection). The mock's // getFullList uses this to resolve expand joins. Most words are // `+s`, but a few are irregular — `company` → `companies`, etc. const IRREGULAR_PLURALS: Record = { company: "companies", }; const pluralize = (rel: string): string => { if (IRREGULAR_PLURALS[rel]) return IRREGULAR_PLURALS[rel]; return rel.endsWith("s") ? rel : `${rel}s`; }; const getOrCreateMap = (name: string): StoreMap => { if (!store[name]) store[name] = new Map(); return store[name]; }; // The mock `where` is intentionally minimal — supports the patterns the // production service actually issues: `field = "value"` (with or without // quotes), `field != "value"`, `&&` (AND), `||` (OR), and `id` lookups by // PB record id. Anything unrecognised passes through (returns all items // for the AND group), so unknown filters don't false-negative tests. const matchesClause = (item: Record, clause: string): boolean => { const m = clause.match(/^(\w+)\s*(=~|!~|=|!=)\s*"?([^"]+?)"?$/); if (!m) return true; const [, field, op, raw] = m; const expected = raw.replace(/^"|"$/g, ""); const actual = item[field]; if (field === "year" || field === "month") { const num = Number(actual); const want = Number(expected); switch (op) { case "=": return num === want; case "!=": return num !== want; default: return true; } } const actualStr = String(actual ?? ""); switch (op) { case "=": return actualStr === expected; case "!=": return actualStr !== expected; case "=~": return actualStr.includes(expected); case "!~": return !actualStr.includes(expected); default: return true; } }; const where = (name: string, filter: string): Record[] => { const items = Array.from(getOrCreateMap(name).values()); if (!filter || !filter.trim()) return items; // Split on `||` first (outer OR), then each alternative is a group of // `&&`-joined AND clauses. An item passes if it satisfies ANY // alternative group. const alternatives = filter.split("||").map((a) => a.trim()).filter(Boolean); return items.filter((item) => alternatives.some((alt) => { const clauses = alt.split("&&").map((c) => c.trim()).filter(Boolean); return clauses.every((clause) => matchesClause(item, clause)); }), ); }; const applyExpand = ( rows: Record[], expand: string | undefined, ): Record[] => { if (!expand) return rows; const relations = expand.split(",").map((s) => s.trim()).filter(Boolean); return rows.map((it) => { const exp: Record = {}; for (const rel of relations) { const relId = it[rel]; if (typeof relId === "string" && getOrCreateMap(pluralize(rel)).has(relId)) { exp[rel] = getOrCreateMap(pluralize(rel)).get(relId); } } return { ...it, expand: exp }; }); }; const pbMock = { autoCancellation: () => undefined, collection: (name: string) => ({ async getList( page = 1, perPage = 25, opts: { filter?: string; sort?: string; expand?: string } = {}, ) { const all = where(name, opts.filter || ""); if (opts.sort) { const sign = opts.sort.startsWith("-") ? -1 : 1; const field = opts.sort.replace(/^-/, ""); all.sort((a, b) => { const av = a[field] as string | number | undefined; const bv = b[field] as string | number | undefined; if (av === bv) return 0; if (av === undefined) return 1; if (bv === undefined) return -1; return (av > bv ? 1 : -1) * sign; }); } // Real pagination — mirror PB's getList semantics so the // service code (which relies on totalPages) exercises the same // branches under test as in production. const totalItems = all.length; const totalPages = Math.max(1, Math.ceil(totalItems / perPage)); const start = (page - 1) * perPage; const items = applyExpand(all.slice(start, start + perPage), opts.expand); return { items, totalItems, totalPages, page, perPage }; }, async getFullList(opts: { filter?: string; fields?: string; expand?: string } = {}) { // Mirror production: respect the filter. The fields projection // is a wire-payload optimisation in PB; for tests we just // return the matched records as-is. Expand ("customer,company" // etc.) is honoured by attaching the related records from the // store under `expand.` so tests asserting on names // pulled through the expand join pass. const items = where(name, opts.filter || ""); return applyExpand(items, opts.expand); }, 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) { 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) { if (name === "order_counters") { const map = getOrCreateMap(name); for (const item of map.values()) { if (item.year === data.year && item.month === data.month) { const err = new Error("unique constraint") as Error & { status: number }; err.status = 409; throw err; } } } // Respect an explicit `id` when the test provides one — PB does // the same, and several tests (expand joins, split continuations) // rely on the predictable id to wire related records together. const item = { id: typeof data.id === "string" ? data.id : nextId(), created: new Date().toISOString(), updated: new Date().toISOString(), ...data, }; getOrCreateMap(name).set(item.id, item); return item; }, async update(id: string, data: Record) { 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; } for (const [key, value] of Object.entries(data)) { if (key.endsWith("+")) { const real = key.slice(0, -1); existing[real] = Number(existing[real] ?? 0) + Number(value); } else { existing[key] = value; } } existing.updated = new Date().toISOString(); return { ...existing }; }, }), }; const clearStore = () => { for (const key of Object.keys(store)) delete store[key]; seq = 0; }; return { store, pbMock, clearStore }; }); vi.mock("pocketbase", () => { return { default: vi.fn().mockImplementation(() => pbMock), }; }); vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), })); import { createOrder, createOrderFromQuote, clearOrderItemStatusTabCountsCache, getOrderItemStatusTabCounts, getOrderIdsForTabGroup, getOrderItemsCollectionName, getOrdersArchiveData, getOrdersDashboardData, listOrdersWithItems, splitOrderForContinuation, cancelOrder, cancelOrdersBulk, findDuplicateOrders, } from "./orders-service"; beforeEach(() => { clearStore(); }); // --- Tests ------------------------------------------------------------------- describe("getOrderItemsCollectionName", () => { it("defaults to 'order_items' when env var is unset", () => { const original = process.env.ORDER_ITEMS_COLLECTION; delete process.env.ORDER_ITEMS_COLLECTION; try { expect(getOrderItemsCollectionName()).toBe("order_items"); } finally { if (original !== undefined) process.env.ORDER_ITEMS_COLLECTION = original; } }); it("returns the env var when set (e.g. order_items_v2)", () => { process.env.ORDER_ITEMS_COLLECTION = "order_items_v2"; try { expect(getOrderItemsCollectionName()).toBe("order_items_v2"); } finally { delete process.env.ORDER_ITEMS_COLLECTION; } }); }); describe("createOrderFromQuote", () => { it("returns the existing order when one already references the quote", async () => { // Pre-seed an existing order in the store; dedupe check should find it // and short-circuit before the rest of the pipeline runs. await pbMock.collection("orders").create({ id: "order-1", quote: "quote-abc", order_number: "ORD-2025-01-001", }); const result = await createOrderFromQuote("quote-abc"); expect(result.already_exists).toBe(true); expect(result.order.id).toBe("order-1"); expect(result.created_items).toBe(0); }); it("throws when the quote has no items", async () => { await pbMock.collection("quotes").create({ id: "quote-empty", company: "comp-1", customer: "cust-1", notes: "", }); // No quote_items rows → service must throw. await expect(createOrderFromQuote("quote-empty")).rejects.toThrow( /no items to convert/, ); }); it("creates an order, copies every quote_item, and sets the right defaults", async () => { // Seed the dependencies in the mock store. await pbMock.collection("quotes").create({ id: "quote-99", company: "comp-7", customer: "cust-3", notes: "Rush", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "quote-99", description: "Widget", quantity: 3, selling_price: 100, supplier: "sup-1", cost_price: 50, markup: 10, notes: "front", }); await pbMock.collection("quote_items").create({ id: "qi-2", quote: "quote-99", description: "Sprocket", quantity: 2, selling_price: 75, supplier: "sup-2", cost_price: 30, markup: 0, notes: "", }); const result = await createOrderFromQuote("quote-99"); expect(result.already_exists).toBe(false); expect(result.created_items).toBe(2); // Pull the created order directly out of the store. const orders = await pbMock.collection("orders").getFullList(); const created = orders.find((o) => o.quote === "quote-99") as | Record | undefined; expect(created).toBeDefined(); expect(created?.quote).toBe("quote-99"); expect(created?.company).toBe("comp-7"); expect(created?.customer).toBe("cust-3"); expect(created?.quote_matching_attempted).toBe(true); expect(created?.is_repair_order).toBe(false); expect(created?.invoiced).toBe(false); // Starts at ORD-{now-year}-{now-month}-001 because the orders table // is empty for this test (no prior order to extend from). const now = new Date(); const expectedPrefix = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-`; expect(String(created?.order_number)).toBe(`${expectedPrefix}001`); // Order-item payload assertions: the two quote_items should each // have produced one order_item row. const orderItems = await pbMock.collection("order_items").getFullList(); const ourItems = orderItems.filter((o) => o.order === created?.id); expect(ourItems.length).toBe(2); const widget = ourItems.find((o) => o.description === "Widget"); expect(widget).toMatchObject({ po_description: "Widget", quantity: 3, order_qty: 3, qty_ordered: "3", selling_price: 100, supplier: "sup-1", cost_price: 50, markup: 10, markup_percent: 10, item_status: "pending", status: "active", drv_status: "unassigned", notes: "front", quote_item: "qi-1", }); const sprocket = ourItems.find((o) => o.description === "Sprocket"); expect(sprocket?.quote_item).toBe("qi-2"); expect(sprocket?.supplier).toBe("sup-2"); expect(sprocket?.drv_status).toBe("unassigned"); }); it("extends the latest order's sequence (no month-boundary reset)", async () => { // The most recent order is in June; we're now in July. The contract // is that the next order continues June's sequence (NOT a fresh // July-001). The old code's bug was using `now` month, which would // have generated `ORD-2026-07-001` here. await pbMock.collection("orders").create({ order_number: "ORD-2026-06-007", quote: "some-other-quote", }); await pbMock.collection("quotes").create({ id: "quote-seq", company: "c", customer: null, notes: "", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "quote-seq", description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); await createOrderFromQuote("quote-seq"); const orders = await pbMock.collection("orders").getFullList(); const newOrder = orders.find((o) => o.quote === "quote-seq"); expect(newOrder?.order_number).toBe("ORD-2026-06-008"); }); it("starts a fresh month at 001 when the orders table is empty", async () => { // The orders collection is empty → no most-recent order to extend // from. Falls back to wall-clock year/month, sequence at 001. await pbMock.collection("quotes").create({ id: "q-fresh", company: "c", customer: null, notes: "", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "q-fresh", description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); await createOrderFromQuote("q-fresh"); const orders = await pbMock.collection("orders").getFullList(); const newOrder = orders.find((o) => o.quote === "q-fresh"); const now = new Date(); const expected = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-001`; expect(newOrder?.order_number).toBe(expected); }); it("falls back to wall-clock year/month when the latest order_number is unparseable", async () => { // A junk order_number (legacy import, manual edit, or old data with a // different prefix) should NOT break the generator. The service // detects unparseable prefixes and uses now year/month. await pbMock.collection("orders").create({ order_number: "GARBAGE-2026-06-007", quote: "old", }); await pbMock.collection("quotes").create({ id: "q-junk", company: "c", customer: null, notes: "", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "q-junk", description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); await createOrderFromQuote("q-junk"); const orders = await pbMock.collection("orders").getFullList(); const newOrder = orders.find((o) => o.quote === "q-junk"); const now = new Date(); const expected = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-001`; expect(newOrder?.order_number).toBe(expected); }); it("writes to the collection named by ORDER_ITEMS_COLLECTION env var", async () => { process.env.ORDER_ITEMS_COLLECTION = "order_items_v2"; await pbMock.collection("quotes").create({ id: "q", company: "c", customer: null, notes: "", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "q", description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); const result = await createOrderFromQuote("q"); expect(result.order_items_collection).toBe("order_items_v2"); const v2Rows = await pbMock.collection("order_items_v2").getFullList(); expect(v2Rows.length).toBe(1); expect(v2Rows[0].order).toBeDefined(); delete process.env.ORDER_ITEMS_COLLECTION; }); it("concurrent calls produce unique order numbers (no race)", async () => { // Set up the dependencies for a single order. await pbMock.collection("quotes").create({ id: "q-race", company: "c", customer: null, notes: "", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "q-race", description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); // Run 5 createOrderFromQuote calls in parallel against distinct // quotes. The counter must give each a unique number even under // simulated concurrency. (Note: the test mock is not actually // concurrent — the `await`s in the implementation interleave the // operations — so the assertion verifies the counter pattern, not // true concurrency. Real concurrency safety comes from PB's // `last_seq+` server-side increment.) const quotes = ["q-race-a", "q-race-b", "q-race-c", "q-race-d", "q-race-e"]; for (const id of quotes) { await pbMock.collection("quotes").create({ id, company: "c", customer: null, notes: "" }); await pbMock.collection("quote_items").create({ id: `qi-${id}`, quote: id, description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); } const results = await Promise.all(quotes.map((id) => createOrderFromQuote(id))); const numbers = results.map((r) => String((r.order as unknown as { order_number: string }).order_number)); expect(new Set(numbers).size).toBe(5); }); }); // ---------------------------------------------------------------------------- // Standalone order creation — createOrder. Use for counter sales / walk-ins / // internal transfers where there's no originating quote. // ---------------------------------------------------------------------------- describe("createOrder", () => { it("creates an order with no quote and the supplied items", async () => { // Seed the relation targets. await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" }); const result = await createOrder({ customer_id: "cust-x", company_id: "comp-x", notes: "Counter sale — cash", contact_name: "Mr Dreyer", rep: "Giellie", items: [ { description: "M16 Bolt", quantity: 50, cost_price: 5, markup: 50, selling_price: 7.5 }, { description: "M16 Nut", quantity: 50, cost_price: 3, markup: 50, selling_price: 4.5 }, ], }); expect(result.order.id).toBeDefined(); expect(result.order.customer).toBe("cust-x"); expect(result.order.company).toBe("comp-x"); expect(result.order.notes).toBe("Counter sale — cash"); expect(result.order.contact_name).toBe("Mr Dreyer"); expect(result.order.rep).toBe("Giellie"); // Quote-related fields stay blank — no originating quote. expect(result.order.quote).toBeNull(); expect(result.order.potential_quote).toBeNull(); expect(result.order.quote_matching_attempted).toBe(false); expect(result.order.quote_match_confidence).toBe(0); expect(result.order.potential_quote_confidence).toBe(0); expect(result.order.is_repair_order).toBe(false); expect(result.order.invoiced).toBe(false); // Two items created, with the per-row defaults the rest of the // pipeline relies on. expect(result.items_created.length).toBe(2); expect(result.items_failed.length).toBe(0); const orderItems = await pbMock.collection("order_items").getFullList(); const ours = orderItems.filter((o) => o.order === result.order.id); expect(ours.length).toBe(2); for (const it of ours) { expect(it.item_status).toBe("pending"); expect(it.drv_status).toBe("unassigned"); expect(it.status).toBe("active"); } }); it("patches secondary fields (sku/unit/supplier_id/notes) onto each item", async () => { await pbMock.collection("customers").create({ id: "cust-y" }); await pbMock.collection("companies").create({ id: "comp-y" }); await pbMock.collection("suppliers").create({ id: "sup-y" }); const result = await createOrder({ customer_id: "cust-y", company_id: "comp-y", items: [ { description: "Bearing 6205", quantity: 4, cost_price: 80, markup: 25, selling_price: 100, sku: "BRG-6205", unit: "EA", supplier_id: "sup-y", notes: "From shelf", }, ], }); expect(result.items_created.length).toBe(1); expect(result.items_failed.length).toBe(0); const orderItems = await pbMock.collection("order_items").getFullList(); const ours = orderItems.filter((o) => o.order === result.order.id); expect(ours[0]).toMatchObject({ sku: "BRG-6205", unit: "EA", supplier: "sup-y", notes: "From shelf", selling_price: 100, cost_price: 80, }); }); it("throws when customer_id does not resolve", async () => { // No customers seeded — getOne will 404. await pbMock.collection("companies").create({ id: "comp-z" }); await expect( createOrder({ customer_id: "ghost", company_id: "comp-z", items: [{ description: "x" }], }), ).rejects.toThrow(/Customer not found/); }); it("throws when company_id does not resolve", async () => { await pbMock.collection("customers").create({ id: "cust-z" }); await expect( createOrder({ customer_id: "cust-z", company_id: "ghost", items: [{ description: "x" }], }), ).rejects.toThrow(/Company not found/); }); it("throws when items array is empty", async () => { await pbMock.collection("customers").create({ id: "cust-1" }); await pbMock.collection("companies").create({ id: "comp-1" }); await expect( createOrder({ customer_id: "cust-1", company_id: "comp-1", items: [] }), ).rejects.toThrow(/items must be a non-empty array/); }); it("throws when customer_id is missing", async () => { await pbMock.collection("companies").create({ id: "comp-1" }); await expect( createOrder({ customer_id: "", company_id: "comp-1", items: [{ description: "x" }], }), ).rejects.toThrow(/customer_id and company_id are required/); }); }); // ---------------------------------------------------------------------------- // Order split — splitOrderForContinuation. Caller passes the item IDs they // want moved; unlisted items stay on the parent. // ---------------------------------------------------------------------------- describe("splitOrderForContinuation", () => { it("moves pending items to a new continuation order and stamps breadcrumbs", async () => { // Seed: a parent order with three pending items. await pbMock.collection("orders").create({ id: "ord-1", order_number: "ORD-2026-08-001", company: "comp-1", customer: "cust-1", quote: "q-1", notes: "Original notes", rep: "Alice", contact_name: "Mr Buyer", quote_reference_text: "RFQ-123", rfq_number: "RFQ-123", }); for (let i = 0; i < 3; i++) { await pbMock.collection("order_items").create({ id: `oi-${i + 1}`, order: "ord-1", description: `Item ${i + 1}`, quantity: 1, order_qty: 1, item_status: "pending", drv_status: "unassigned", status: "active", selling_price: 10, cost_price: 5, markup: 50, markup_percent: 50, sku: `SKU-${i + 1}`, unit: "EA", notes: "", quote_item: `qi-${i + 1}`, }); } const result = await splitOrderForContinuation("ord-1", ["oi-1", "oi-2"]); // Continuation order exists, inherited the parent's identity fields. expect(result.continuation.id).toBeDefined(); expect(result.continuation.id).not.toBe("ord-1"); expect(result.continuation.company).toBe("comp-1"); expect(result.continuation.customer).toBe("cust-1"); expect(result.continuation.quote).toBe("q-1"); expect(result.continuation.rep).toBe("Alice"); expect(result.continuation.contact_name).toBe("Mr Buyer"); expect(result.continuation.quote_reference_text).toBe("RFQ-123"); expect(result.continuation.rfq_number).toBe("RFQ-123"); // Notes get the breadcrumb appended. expect(result.continuation.notes).toMatch(/Original notes/); expect(result.continuation.notes).toMatch(/Split from ORD-2026-08-001/); // Two items moved, one kept. expect(result.itemsMoved).toBe(2); expect(result.itemsKept).toBe(1); expect(result.itemsFailed).toEqual([]); // Original items still belong to the parent (order relation unchanged). const allItems = await pbMock.collection("order_items").getFullList(); const onParent = allItems.filter((it) => it.order === "ord-1"); expect(onParent.length).toBe(3); // Moved items have the breadcrumb in their notes. const movedO1 = onParent.find((it) => it.id === "oi-1"); const movedO2 = onParent.find((it) => it.id === "oi-2"); expect(movedO1?.notes).toMatch(/Moved to ORD-/); expect(movedO2?.notes).toMatch(/Moved to ORD-/); expect(movedO1?.notes).toMatch(/via order split on /); // Unmoved item has no breadcrumb. const keptO3 = onParent.find((it) => it.id === "oi-3"); expect(keptO3?.notes ?? "").not.toMatch(/Moved to/); // New order gets two clones with the original fields preserved // and workflow flags reset to defaults. const onContinuation = allItems.filter((it) => it.order === result.continuation.id); expect(onContinuation.length).toBe(2); for (const clone of onContinuation) { expect(clone.item_status).toBe("pending"); expect(clone.drv_status).toBe("unassigned"); expect(clone.status).toBe("active"); expect(clone.quote_item).toMatch(/^qi-/); } }); it("refuses to move items in non-pending statuses, reporting them per-item", async () => { await pbMock.collection("orders").create({ id: "ord-2", order_number: "ORD-2026-08-002", company: "comp-1", customer: "cust-1", quote: "", notes: "", }); await pbMock.collection("order_items").create({ id: "oi-pending", order: "ord-2", description: "Pending", item_status: "pending", quantity: 1, }); await pbMock.collection("order_items").create({ id: "oi-po", order: "ord-2", description: "PO generated", item_status: "po_generated", quantity: 1, }); await pbMock.collection("order_items").create({ id: "oi-delivered", order: "ord-2", description: "Delivered", item_status: "delivered", quantity: 1, }); const result = await splitOrderForContinuation("ord-2", [ "oi-pending", "oi-po", "oi-delivered", ]); // Only the pending one moves. The other two are reported in itemsFailed // with their original status names in the message. expect(result.itemsMoved).toBe(1); expect(result.itemsFailed.length).toBe(2); expect(result.itemsFailed.find((f) => f.id === "oi-po")).toMatchObject({ id: "oi-po", }); expect(result.itemsFailed.find((f) => f.id === "oi-delivered")?.error).toMatch( /delivered/, ); // The continuation order still exists even with partial moves. expect(result.continuation.id).toBeDefined(); const allItems = await pbMock.collection("order_items").getFullList(); expect(allItems.filter((it) => it.order === result.continuation.id).length).toBe(1); // The refused items are untouched on the parent (no notes breadcrumb). expect(allItems.find((it) => it.id === "oi-po")?.notes ?? "").not.toMatch(/Moved to/); }); it("reports items that do not belong to the parent order", async () => { await pbMock.collection("orders").create({ id: "ord-3", order_number: "ORD-2026-08-003", company: "comp-1", customer: "cust-1", notes: "", }); await pbMock.collection("order_items").create({ id: "oi-on-ord-3", order: "ord-3", description: "Belongs", item_status: "pending", quantity: 1, }); await pbMock.collection("orders").create({ id: "ord-other", order_number: "ORD-2026-08-099", company: "comp-1", customer: "cust-1", notes: "", }); await pbMock.collection("order_items").create({ id: "oi-other-order", order: "ord-other", description: "Foreign", item_status: "pending", quantity: 1, }); const result = await splitOrderForContinuation("ord-3", [ "oi-on-ord-3", "oi-other-order", "oi-doesnt-exist", ]); expect(result.itemsMoved).toBe(1); expect(result.itemsFailed.length).toBe(2); expect(result.itemsFailed.find((f) => f.id === "oi-other-order")?.error).toMatch( /does not belong to order ord-3/, ); expect(result.itemsFailed.find((f) => f.id === "oi-doesnt-exist")?.error).toMatch( /does not belong to order ord-3/, ); }); it("throws when the parent order does not exist", async () => { await expect( splitOrderForContinuation("ghost-order", ["oi-1"]), ).rejects.toThrow(/Order not found/); }); it("throws when itemIds is empty", async () => { await pbMock.collection("orders").create({ id: "ord-4", order_number: "ORD-2026-08-004", company: "comp-1", customer: "cust-1", notes: "", }); await expect(splitOrderForContinuation("ord-4", [])).rejects.toThrow( /itemIds must be a non-empty array/, ); }); }); // ---------------------------------------------------------------------------- // /orders dashboard read model — tab filter, counts cache, rep lookup. // // These tests cover the bug surface fixed in 2026-07: the old // `getOrdersDashboardData` returned the global orders pagination // (Page 1 of 226, 4520 total orders) regardless of which tab the // operator selected, so "Processing (4)" badges showed empty pages // when the four matching orders happened to fall on later pages. // ---------------------------------------------------------------------------- describe("getOrderIdsForTabGroup", () => { it("returns the distinct order ids with at least one item in the tab group", async () => { // Three orders, each with one item. Statuses cover "new", // "processing", and "delivered" tabs so we can verify partition // membership. await pbMock.collection("orders").create({ id: "o-new", order_number: "ORD-2025-01-001", quote: "q1", }); await pbMock.collection("orders").create({ id: "o-processing", order_number: "ORD-2025-01-002", quote: "q2", }); await pbMock.collection("orders").create({ id: "o-delivered", order_number: "ORD-2025-01-003", quote: "q3", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-new", description: "Widget", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o-processing", description: "Sprocket", item_status: "processed", }); await pbMock.collection("order_items").create({ id: "oi-3", order: "o-delivered", description: "Gear", item_status: "delivered", }); const processingIds = await getOrderIdsForTabGroup(pbMock as never, "processing"); expect(processingIds.sort()).toEqual(["o-processing"]); const newIds = await getOrderIdsForTabGroup(pbMock as never, "new"); expect(newIds.sort()).toEqual(["o-new"]); const deliveredIds = await getOrderIdsForTabGroup(pbMock as never, "delivered"); expect(deliveredIds.sort()).toEqual(["o-delivered"]); }); it("dedupes when an order has multiple items in the same tab", async () => { await pbMock.collection("orders").create({ id: "o-multi", order_number: "ORD-2025-01-010", quote: "q", }); await pbMock.collection("order_items").create({ id: "oi-a", order: "o-multi", description: "A", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-b", order: "o-multi", description: "B", item_status: "pending", }); const ids = await getOrderIdsForTabGroup(pbMock as never, "new"); expect(ids).toEqual(["o-multi"]); }); }); describe("getOrderItemStatusTabCounts", () => { beforeEach(() => clearOrderItemStatusTabCountsCache()); it("returns the count of distinct orders per tab", async () => { await pbMock.collection("orders").create({ id: "o-a", order_number: "ORD-2025-01-001", quote: "qa", }); await pbMock.collection("orders").create({ id: "o-b", order_number: "ORD-2025-01-002", quote: "qb", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-a", description: "x", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o-a", description: "y", item_status: "processed", }); await pbMock.collection("order_items").create({ id: "oi-3", order: "o-b", description: "z", item_status: "pending", }); const counts = await getOrderItemStatusTabCounts(pbMock as never); expect(counts.new).toBe(2); expect(counts.processing).toBe(1); }); it("bypasses the cache when bypassCache is true", async () => { await pbMock.collection("orders").create({ id: "o-cache", order_number: "ORD-2025-01-001", quote: "qc", }); await pbMock.collection("order_items").create({ id: "oi-cache", order: "o-cache", description: "x", item_status: "pending", }); const first = await getOrderItemStatusTabCounts(pbMock as never); expect(first.new).toBe(1); // Mutate the underlying store directly — bypasses our normal // service writes, but lets us verify the cache is being served // if `bypassCache` is not set. await pbMock.collection("orders").create({ id: "o-cache-2", order_number: "ORD-2025-01-002", quote: "qc2", }); await pbMock.collection("order_items").create({ id: "oi-cache-2", order: "o-cache-2", description: "x", item_status: "pending", }); const cached = await getOrderItemStatusTabCounts(pbMock as never); expect(cached.new).toBe(1); // unchanged — cache hit const fresh = await getOrderItemStatusTabCounts(pbMock as never, { bypassCache: true }); expect(fresh.new).toBe(2); // refresh sees the new order }); it("clearOrderItemStatusTabCountsCache forces a fresh read", async () => { await pbMock.collection("orders").create({ id: "o-clear", order_number: "ORD-2025-01-001", quote: "qclr", }); await pbMock.collection("order_items").create({ id: "oi-clear", order: "o-clear", description: "x", item_status: "pending", }); await getOrderItemStatusTabCounts(pbMock as never); // prime the cache clearOrderItemStatusTabCountsCache(); const fresh = await getOrderItemStatusTabCounts(pbMock as never); expect(fresh.new).toBe(1); }); it("fires a single combined getFullList for all tabs (not 6 separate queries)", async () => { // Regression guard: the old implementation issued 6 parallel // `getFullList` calls (one per tab), each with a separate filter // scan. With ~18k rows in `order_items` and no `item_status` // index, those 6 scans dominated the page-load budget. The new // implementation collapses to 1 combined query. This test // intercepts the mock's `collection()` to count how many times // `getFullList` is called on the `order_items` collection. await pbMock.collection("order_items").create({ id: "oi-1", order: "o1", description: "x", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o2", description: "x", item_status: "processed", }); await pbMock.collection("order_items").create({ id: "oi-3", order: "o3", description: "x", item_status: "delivered", }); const originalCollection = pbMock.collection; let getFullListCalls = 0; pbMock.collection = ((name: string) => { const proxy = originalCollection(name); if (name === "order_items") { const originalGetFullList = proxy.getFullList; proxy.getFullList = ((...args: unknown[]) => { getFullListCalls++; return (originalGetFullList as (...a: unknown[]) => unknown).call( proxy, ...args, ); }) as typeof proxy.getFullList; } return proxy; }) as typeof pbMock.collection; try { await getOrderItemStatusTabCounts(pbMock as never, { bypassCache: true }); expect(getFullListCalls).toBe(1); } finally { pbMock.collection = originalCollection; } }); it("can skip archive tabs for the main orders page", async () => { await pbMock.collection("order_items").create({ id: "oi-1", order: "o1", description: "x", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o2", description: "x", item_status: "delivered", }); await pbMock.collection("order_items").create({ id: "oi-3", order: "o3", description: "x", item_status: "cancelled", }); const counts = await getOrderItemStatusTabCounts(pbMock as never, { bypassCache: true, includeArchivedTabs: false, }); expect(counts.new).toBe(1); expect(counts.delivered).toBe(0); expect(counts.cancelled).toBe(0); }); }); describe("listOrdersWithItems — tab filter", () => { it("returns only orders that have at least one item in the requested tab", async () => { // Two orders: one with a "pending" item, one with a "processed" // item. Filtered to "processing" should yield only the second. await pbMock.collection("orders").create({ id: "o-pending", order_number: "ORD-2025-01-001", quote: "qp", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("orders").create({ id: "o-processed", order_number: "ORD-2025-01-002", quote: "qpr", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-pending", order: "o-pending", description: "Widget", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-processed", order: "o-processed", description: "Sprocket", item_status: "processed", }); const result = await listOrdersWithItems({ page: 1, perPage: 20, tabGroup: "processing", }); expect(result.items.map((i) => i.order.id)).toEqual(["o-processed"]); expect(result.totalItems).toBe(1); expect(result.totalPages).toBe(1); }); it("returns an empty list with totalPages=0 when no orders match the tab", async () => { await pbMock.collection("orders").create({ id: "o-only-new", order_number: "ORD-2025-01-001", quote: "qn", }); await pbMock.collection("order_items").create({ id: "oi-only-new", order: "o-only-new", description: "x", item_status: "pending", }); const result = await listOrdersWithItems({ page: 1, perPage: 20, tabGroup: "cancelled", }); expect(result.items).toEqual([]); expect(result.totalItems).toBe(0); }); it("reports totalItems from the matching set, not the page slice (pagination honesty)", async () => { // Five orders with processed items, perPage=2. The page-1 response // should have 2 items but totalItems=5 / totalPages=3 so the // footer says "Page 1 of 3" — matching the operator's mental // model of how many orders the tab actually contains. for (let i = 0; i < 5; i++) { await pbMock.collection("orders").create({ id: `o-${i}`, order_number: `ORD-2025-01-00${i + 1}`, quote: `q-${i}`, created: `2025-01-0${i + 1}T00:00:00Z`, }); await pbMock.collection("order_items").create({ id: `oi-${i}`, order: `o-${i}`, description: "x", item_status: "processed", }); } const result = await listOrdersWithItems({ page: 1, perPage: 2, tabGroup: "processing", }); expect(result.items.length).toBe(2); expect(result.totalItems).toBe(5); expect(result.totalPages).toBe(3); }); it("falls back to all-orders pagination when no tab is supplied", async () => { // Sanity check — the existing all-orders path is unchanged. PB's // `getList(page, perPage, { sort })` returns a paginated slice; // the test mock mirrors that with `totalItems = items.length`, // so totalPages = 1 here regardless of the underlying set size. await pbMock.collection("orders").create({ id: "o-a", order_number: "ORD-2025-01-001", quote: "qa", }); await pbMock.collection("orders").create({ id: "o-b", order_number: "ORD-2025-01-002", quote: "qb", }); const result = await listOrdersWithItems({ page: 1, perPage: 20, }); expect(result.items.length).toBe(2); }); it("does not pass a `fields` parameter on the orders list (PB 0.26 breaks expand when fields is set)", async () => { // Regression guard: earlier I tried to limit the wire payload // by passing a `fields` list with per-expand syntax. PB 0.26 // treats `fields` as a hard allowlist of MAIN record columns, // and the moment you set it the `expand` payload is dropped // from the response — so customer/company/rep/quote names // vanish from every order card. The orders list deliberately // omits `fields`; this test pins that behaviour so a future // "let's slim the payload" change doesn't reintroduce the bug. let capturedFields: string | undefined; let capturedExpand: string | undefined; const originalCollection = pbMock.collection; pbMock.collection = ((name: string) => { const proxy = originalCollection(name); if (name === "orders") { const originalGetList = proxy.getList; proxy.getList = ((page: number, perPage: number, opts: { filter?: string; sort?: string; fields?: string; expand?: string }) => { capturedFields = opts?.fields; capturedExpand = opts?.expand; return (originalGetList as (...a: unknown[]) => unknown).call( proxy, page, perPage, opts, ); }) as typeof proxy.getList; } return proxy; }) as typeof pbMock.collection; try { await listOrdersWithItems({ page: 1, perPage: 20 }); // `fields` must NOT be sent — PB 0.26 drops the expand payload // when it sees it. expect(capturedFields).toBeUndefined(); // Expand IS sent, and includes the four relations the list view // reads (company, customer, customer.rep, quote). Importantly // it does NOT include `potential_quote` — the list view only // checks the field's existence, never the expanded record. expect(capturedExpand).toContain("company"); expect(capturedExpand).toContain("customer"); expect(capturedExpand).toContain("customer.rep"); expect(capturedExpand).toContain("quote"); expect(capturedExpand).not.toContain("potential_quote"); } finally { pbMock.collection = originalCollection; } }); }); describe("listOrdersWithItems — search scope + per-tab match counts", () => { /** * Seed one order per active tab, all sharing the token "widget" in * their item description, plus a delivered (archived) order that also * matches. Lets each test assert both cross-tab counting and the * archive exclusion. */ async function seedCrossTabMatches() { const rows: [string, string, string][] = [ ["o-new", "pending", "2025-01-01T00:00:00Z"], ["o-proc", "processed", "2025-01-02T00:00:00Z"], ["o-pool", "collection_pool", "2025-01-03T00:00:00Z"], ["o-coll", "collected", "2025-01-04T00:00:00Z"], ["o-archived", "delivered", "2025-01-05T00:00:00Z"], ]; for (const [id, status, created] of rows) { await pbMock.collection("orders").create({ id, order_number: `ORD-${id}`, quote: `q-${id}`, created, }); await pbMock.collection("order_items").create({ id: `oi-${id}`, order: id, description: "Widget assembly", item_status: status, }); } } it("counts matches across every active tab, not just the active one", async () => { await seedCrossTabMatches(); // Operator is standing on "new" — but the hits in processing, // collection_pool and collected must still be counted so the tab // strip can point at them. This is the core of "show me where it is". const result = await listOrdersWithItems({ page: 1, perPage: 20, search: "widget", tabGroup: "new", }); expect(result.searchCounts).toMatchObject({ new: 1, processing: 1, collection_pool: 1, collected: 1, }); // ...while the visible list is still narrowed to the active tab. expect(result.items.map((i) => i.order.id)).toEqual(["o-new"]); }); it("excludes archived (delivered/cancelled) orders from search", async () => { await seedCrossTabMatches(); const result = await listOrdersWithItems({ page: 1, perPage: 20, search: "widget", }); // "o-archived" matches the text but is delivered-only, so it is out // of scope — it belongs to /orders/archive. expect(result.items.map((i) => i.order.id)).not.toContain("o-archived"); expect(result.searchCounts?.delivered ?? 0).toBe(0); }); it("is unaffected by sort direction (regression: the 500-cap coupling)", async () => { // The old implementation fetched the first 500 orders BY SORT and // filtered those, so an ascending sort would hide newer matches. // Search now scopes by active-order id, so both directions agree. await seedCrossTabMatches(); const ascending = await listOrdersWithItems({ search: "widget", sort: "created", }); const descending = await listOrdersWithItems({ search: "widget", sort: "-created", }); expect(ascending.items.map((i) => i.order.id).sort()).toEqual( descending.items.map((i) => i.order.id).sort(), ); expect(ascending.searchCounts).toEqual(descending.searchCounts); }); it("counts an order once per tab it has items in", async () => { // A single order straddling two tabs should appear in both counts — // the counts are 'distinct orders per tab', not a partition. await pbMock.collection("orders").create({ id: "o-split", order_number: "ORD-split", quote: "q-split", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-split-a", order: "o-split", description: "Widget", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-split-b", order: "o-split", description: "Widget", item_status: "processed", }); const result = await listOrdersWithItems({ search: "widget" }); expect(result.searchCounts).toMatchObject({ new: 1, processing: 1 }); }); it("omits searchCounts entirely when no search is active", async () => { await seedCrossTabMatches(); const result = await listOrdersWithItems({ page: 1, perPage: 20 }); expect(result.searchCounts).toBeUndefined(); }); }); describe("listOrdersWithItems — tab taxonomy completeness", () => { /** * Regression guard. The `ItemStatus` union and `TAB_GROUP_ITEM_STATUSES` * must stay in lockstep — every status MUST be assigned to a tab, * because: * 1. Unmapped statuses are invisible to /orders (they fail the * tab filter). * 2. They drop out of `TAB_STATUSES` and therefore out of the * search candidate set built by `getActiveOrderIds`. * 3. They drop out of the tab counts query entirely. * * This caught the bug where 60 active items (ready_invoice, * invoiced, delivery_note, removed) were silently missing from * search and mis-bucketed as "new" — including order 4500704208 * (ready_invoice, expected: Collected). */ it("returns search hits whose item_status is mapped to a non-new tab", async () => { // ready_invoice should land on the Collected tab now. Previously // it was bucketed as "new" and excluded from the search candidate // set, so the order was unfindable. await pbMock.collection("orders").create({ id: "o-inv", order_number: "ORD-invoice-ready", quote: "q-inv", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-inv", order: "o-inv", description: "Widget", item_status: "ready_invoice", }); const result = await listOrdersWithItems({ search: "invoice" }); expect(result.items.map((i) => i.order.id)).toEqual(["o-inv"]); expect(result.searchCounts).toMatchObject({ collected: 1 }); expect(result.searchCounts?.new ?? 0).toBe(0); }); it("counts every item_status in the live union across the active set", async () => { // Build one item per ItemStatus union member, confirm each one // surfaces in `getActiveOrderIds` (the search candidate set). const allStatuses = [ "pending", "approval_needed", "approval_rejected", "processed", "po_generated", "collection_pool", "assigned", "ready_delivery", "collected", "stock_verified", "picking", "delivered", "cancelled", "ready_invoice", "invoiced", "stock_booked", "with_driver", "checked_in", "completed", "removed", "delivery_note", ]; for (const s of allStatuses) { await pbMock.collection("orders").create({ id: `o-${s}`, order_number: `ORD-${s}`, quote: `q-${s}`, }); await pbMock.collection("order_items").create({ id: `oi-${s}-${allStatuses.indexOf(s)}`, order: `o-${s}`, description: "x", item_status: s, }); } // If this fails, an ItemStatus union member has been added to // src/lib/types.ts without a matching TAB_GROUP_ITEM_STATUSES entry // in src/lib/item-status-ui.ts — and that item is invisible to // /orders search and counts. const result = await listOrdersWithItems({ search: "ORD-", includeArchivedCounts: false, }); const foundIds = new Set(result.items.map(i => i.order.id)); // Every active-tab status should appear; delivered and cancelled // remain excluded by design (they live on /orders/archive). const active = allStatuses.filter(s => s !== "delivered" && s !== "cancelled"); for (const s of active) { expect(foundIds, `item_status "${s}" should be in search results`).toContain(`o-${s}`); } }); }); describe("getOrdersDashboardData — search counts replace global counts", () => { it("promotes per-tab match counts over the global tab counts while searching", async () => { await pbMock.collection("orders").create({ id: "o-hit", order_number: "ORD-hit", quote: "q-hit", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-hit", order: "o-hit", description: "Unobtainium", item_status: "processed", }); // A second order that must NOT contribute to the badges during a // search for "unobtainium". await pbMock.collection("orders").create({ id: "o-miss", order_number: "ORD-miss", quote: "q-miss", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-miss", order: "o-miss", description: "Something else", item_status: "processed", }); const searched = await getOrdersDashboardData({ search: "unobtainium", includeArchivedCounts: false, bypassCountsCache: true, }); // Badge shows 1 match in processing, not the global 2 orders. expect(searched.counts.processing).toBe(1); const browsing = await getOrdersDashboardData({ includeArchivedCounts: false, bypassCountsCache: true, }); expect(browsing.counts.processing).toBe(2); }); }); describe("getOrdersDashboardData — end-to-end", () => { beforeEach(() => clearOrderItemStatusTabCountsCache()); it("returns tab-scoped counts and items in a single response", async () => { // Three orders: a pending one (new tab), a processed one // (processing tab), and a delivered one. await pbMock.collection("orders").create({ id: "o-p", order_number: "ORD-2025-01-001", quote: "qp", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("orders").create({ id: "o-pr", order_number: "ORD-2025-01-002", quote: "qpr", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("orders").create({ id: "o-d", order_number: "ORD-2025-01-003", quote: "qd", created: "2025-01-03T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-p", order: "o-p", description: "Widget", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-pr", order: "o-pr", description: "Sprocket", item_status: "processed", }); await pbMock.collection("order_items").create({ id: "oi-d", order: "o-d", description: "Gear", item_status: "delivered", }); const result = await getOrdersDashboardData({ page: 1, perPage: 20, tabGroup: "processing", }); // Counts are global (always were) — the badge still tells the truth. expect(result.counts.new).toBe(1); expect(result.counts.processing).toBe(1); expect(result.counts.delivered).toBe(1); // Items are scoped to the active tab. expect(result.items.map((i) => i.order.id)).toEqual(["o-pr"]); expect(result.totalPages).toBe(1); expect(result.totalItems).toBe(1); }); it("can omit archive counts for the main orders page", async () => { await pbMock.collection("orders").create({ id: "o-p", order_number: "ORD-2025-01-001", quote: "qp", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("orders").create({ id: "o-d", order_number: "ORD-2025-01-002", quote: "qd", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-p", order: "o-p", description: "Widget", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-d", order: "o-d", description: "Gear", item_status: "delivered", }); const result = await getOrdersDashboardData({ page: 1, perPage: 20, tabGroup: "new", includeArchivedCounts: false, }); expect(result.counts.new).toBe(1); expect(result.counts.delivered).toBe(0); expect(result.counts.cancelled).toBe(0); }); }); // ---------------------------------------------------------------------------- // Orders archive — read model for /orders/archive. // // The archive is the home for orders with at least one item in // (delivered, cancelled) status. On the live PB that holds ~99% of // all orders, so it gets its own paginated page rather than living // as a tab on the main orders view. These tests pin the contract. // ---------------------------------------------------------------------------- describe("getOrdersArchiveData", () => { function makeOrdersArchivePb(options: { failOnArchiveItemScan?: boolean } = {}) { const listRecords = (name: string) => Array.from((store[name] || new Map()).values()) as Array>; const matchesArchiveFilter = ( order: Record, filter: string, ) => { const orderItems = listRecords("order_items").filter( (item) => String(item.order ?? "") === String(order.id ?? ""), ); const hasDelivered = orderItems.some((item) => item.item_status === "delivered"); const hasCancelled = orderItems.some((item) => item.item_status === "cancelled"); const wantsDelivered = filter.includes('order_items_via_order.item_status ?= "delivered"'); const wantsCancelled = filter.includes('order_items_via_order.item_status ?= "cancelled"'); if (wantsDelivered && wantsCancelled) return hasDelivered || hasCancelled; if (wantsDelivered) return hasDelivered; if (wantsCancelled) return hasCancelled; return true; }; const matchesSearch = (order: Record, filter: string) => { const orderNumberMatch = filter.match(/order_number ~ "([^"]+)"/); if (orderNumberMatch) { return String(order.order_number ?? "") .toLowerCase() .includes(orderNumberMatch[1].toLowerCase()); } const contactMatch = filter.match(/contact_name ~ "([^"]+)"/); if (contactMatch) { return String(order.contact_name ?? "") .toLowerCase() .includes(contactMatch[1].toLowerCase()); } return true; }; return { autoCancellation: () => undefined, collection: (name: string) => { if (name === "orders") { return { async getList( page = 1, perPage = 25, opts: { filter?: string; sort?: string; expand?: string } = {}, ) { let items = listRecords("orders").filter((order) => matchesArchiveFilter(order, opts.filter || "") && matchesSearch(order, opts.filter || ""), ); if (opts.sort === "-created") { items = items.sort((a, b) => String(b.created ?? "").localeCompare(String(a.created ?? "")), ); } const totalItems = items.length; const totalPages = Math.max(1, Math.ceil(totalItems / perPage)); const start = (page - 1) * perPage; return { items: items.slice(start, start + perPage), totalItems, totalPages, page, perPage, }; }, getFullList: pbMock.collection("orders").getFullList, }; } if (name === "order_items" && options.failOnArchiveItemScan) { return { async getFullList(opts: { filter?: string } = {}) { if (!(opts.filter || "").includes('order = "')) { throw new Error("unexpected full archive item scan"); } return pbMock.collection("order_items").getFullList(opts); }, }; } return pbMock.collection(name); }, }; } it("returns orders with at least one delivered or cancelled item, plus header counts", async () => { // 2 delivered orders, 1 cancelled, 1 active (no delivered/cancelled). await pbMock.collection("orders").create({ id: "o-d1", order_number: "ORD-2025-01-001", quote: "q1", }); await pbMock.collection("orders").create({ id: "o-d2", order_number: "ORD-2025-01-002", quote: "q2", }); await pbMock.collection("orders").create({ id: "o-c1", order_number: "ORD-2025-01-003", quote: "q3", }); await pbMock.collection("orders").create({ id: "o-active", order_number: "ORD-2025-01-004", quote: "q4", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-d1", description: "x", item_status: "delivered", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o-d2", description: "x", item_status: "delivered", }); await pbMock.collection("order_items").create({ id: "oi-3", order: "o-c1", description: "x", item_status: "cancelled", }); await pbMock.collection("order_items").create({ id: "oi-4", order: "o-active", description: "x", item_status: "pending", }); const result = await getOrdersArchiveData( { page: 1, perPage: 20 }, makeOrdersArchivePb() as never, ); // 3 archived orders (the active one is excluded). expect(result.items.map((i) => i.order.id).sort()).toEqual([ "o-c1", "o-d1", "o-d2", ]); expect(result.totalItems).toBe(3); // Header counts: distinct orders per status. expect(result.counts.delivered).toBe(2); expect(result.counts.cancelled).toBe(1); }); it("dedupes orders that have multiple items in the matching statuses", async () => { // An order with two delivered items should still count as 1 order. await pbMock.collection("orders").create({ id: "o-multi", order_number: "ORD-2025-01-001", quote: "q1", }); await pbMock.collection("order_items").create({ id: "oi-a", order: "o-multi", description: "A", item_status: "delivered", }); await pbMock.collection("order_items").create({ id: "oi-b", order: "o-multi", description: "B", item_status: "delivered", }); await pbMock.collection("order_items").create({ id: "oi-c", order: "o-multi", description: "C", item_status: "cancelled", }); const result = await getOrdersArchiveData( { page: 1, perPage: 20 }, makeOrdersArchivePb() as never, ); expect(result.totalItems).toBe(1); expect(result.counts.delivered).toBe(1); expect(result.counts.cancelled).toBe(1); }); it("filters archive results by search (order_number, company, customer, contact)", async () => { await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-2025-FINDME", quote: "q1", contact_name: "Specific Contact", }); await pbMock.collection("orders").create({ id: "o-2", order_number: "ORD-2025-OTHER", quote: "q2", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", item_status: "delivered", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o-2", description: "x", item_status: "delivered", }); // Search by order_number const byOrderNum = await getOrdersArchiveData( { page: 1, perPage: 20, search: "FINDME" }, makeOrdersArchivePb() as never, ); expect(byOrderNum.items.map((i) => i.order.id)).toEqual(["o-1"]); // Search by contact_name const byContact = await getOrdersArchiveData( { page: 1, perPage: 20, search: "specific" }, makeOrdersArchivePb() as never, ); expect(byContact.items.map((i) => i.order.id)).toEqual(["o-1"]); }); it("returns an empty result when no orders have delivered/cancelled items", async () => { await pbMock.collection("orders").create({ id: "o-active", order_number: "ORD-2025-01-001", quote: "q1", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-active", description: "x", item_status: "pending", }); const result = await getOrdersArchiveData( { page: 1, perPage: 20 }, makeOrdersArchivePb() as never, ); expect(result.items).toEqual([]); expect(result.totalItems).toBe(0); expect(result.counts.delivered).toBe(0); expect(result.counts.cancelled).toBe(0); }); it("loads the default archive page without scanning every order_item first", async () => { await pbMock.collection("orders").create({ id: "o-d1", order_number: "ORD-2025-01-001", quote: "q1", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-d1", description: "x", item_status: "delivered", }); const result = await getOrdersArchiveData( { page: 1, perPage: 20 }, makeOrdersArchivePb({ failOnArchiveItemScan: true }) as never, ); expect(result.items.map((i) => i.order.id)).toEqual(["o-d1"]); expect(result.counts.delivered).toBe(1); }); }); /* ============================================================================ * cancelOrder / cancelOrdersBulk / findDuplicateOrders * ---------------------------------------------------------------------------- * Smoke tests for the soft-cancel + duplicate-detection flow added on * 2026-08-12. Real PB verification lives in the live script in the * scratchpad directory; these tests pin the basic contract. * ========================================================================== */ describe("cancelOrder", () => { it("flips every in-flight item_status to 'cancelled'", async () => { await pbMock.collection("orders").create({ id: "o-cancel", order_number: "ORD-cancel", quote: "qc", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-cancel", description: "a", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o-cancel", description: "b", item_status: "po_generated", }); await pbMock.collection("order_items").create({ id: "oi-3", order: "o-cancel", description: "c", item_status: "collected", }); const result = await cancelOrder("o-cancel"); expect(result.itemsCancelled).toBe(3); expect(result.itemsSkipped).toEqual([]); const after = await pbMock.collection("order_items").getFullList(); expect(after.every((i) => i.item_status === "cancelled")).toBe(true); }); it("refuses to cancel an order where every item is terminal", async () => { await pbMock.collection("orders").create({ id: "o-shipped", order_number: "ORD-shipped", quote: "qs", }); await pbMock.collection("order_items").create({ id: "oi-d1", order: "o-shipped", description: "a", item_status: "delivered", }); await pbMock.collection("order_items").create({ id: "oi-d2", order: "o-shipped", description: "b", item_status: "delivered", }); await expect(cancelOrder("o-shipped")).rejects.toThrow(/terminal state/); }); it("partially cancels an order with mixed statuses, reporting the skipped ones", async () => { await pbMock.collection("orders").create({ id: "o-mixed", order_number: "ORD-mixed", quote: "qm", }); await pbMock.collection("order_items").create({ id: "oi-m1", order: "o-mixed", description: "a", item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-m2", order: "o-mixed", description: "b", item_status: "delivered", }); const result = await cancelOrder("o-mixed"); expect(result.itemsCancelled).toBe(1); expect(result.itemsSkipped).toEqual([{ id: "oi-m2", item_status: "delivered" }]); }); it("allowDelivered flag lets the bulk-cancel path skip the terminal guard", async () => { await pbMock.collection("orders").create({ id: "o-bulk", order_number: "ORD-bulk", quote: "qb", }); await pbMock.collection("order_items").create({ id: "oi-b1", order: "o-bulk", description: "a", item_status: "delivered", }); await pbMock.collection("order_items").create({ id: "oi-b2", order: "o-bulk", description: "b", item_status: "pending", }); const result = await cancelOrder("o-bulk", { allowDelivered: true }); expect(result.itemsCancelled).toBe(2); expect(result.itemsSkipped).toEqual([]); }); }); describe("cancelOrdersBulk", () => { it("returns per-order success/failure", async () => { await pbMock.collection("orders").create({ id: "o-ok", order_number: "ORD-ok", quote: "qk", }); await pbMock.collection("order_items").create({ id: "oi-ok", order: "o-ok", description: "x", item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-missing", order_number: "ORD-missing", quote: "qm", }); // (no items — empty order, will succeed with 0 cancelled) const result = await cancelOrdersBulk(["o-ok", "o-missing", "o-doesnt-exist"]); expect(result.succeeded.map((r) => r.orderId).sort()).toEqual(["o-missing", "o-ok"]); expect(result.failed).toEqual([{ orderId: "o-doesnt-exist", error: expect.stringContaining("not found") }]); }); }); describe("findDuplicateOrders", () => { it("only returns groups with ≥2 active records (the ones visible on the order board)", async () => { // The 389020 pattern from live data: 4 records share the number, // but only 2 are "on the board" (have items in active status). // The other 2 are in delivered/cancelled and the operator never // sees them — so the duplicates endpoint should hide them. await pbMock.collection("orders").create({ id: "o-1", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "Widget", item_status: "po_generated", }); await pbMock.collection("orders").create({ id: "o-2", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.100Z", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o-2", description: "Widget", item_status: "po_generated", }); // Third copy — off the board await pbMock.collection("orders").create({ id: "o-3", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.200Z", }); await pbMock.collection("order_items").create({ id: "oi-3", order: "o-3", description: "Widget", item_status: "delivered", }); const groups = await findDuplicateOrders(); expect(groups).toHaveLength(1); expect(groups[0].records.map((r) => r.id).sort()).toEqual(["o-1", "o-2"]); }); it("does not surface groups where only one record is active", async () => { // Two records share the number, but only one has items in active // status. The off-board copy is hidden, leaving only 1 record, // so the group doesn't qualify as a duplicate from the operator's // perspective. await pbMock.collection("orders").create({ id: "o-1", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "Widget", item_status: "po_generated", }); await pbMock.collection("orders").create({ id: "o-2", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.100Z", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o-2", description: "Widget", item_status: "delivered", }); const groups = await findDuplicateOrders(); expect(groups).toHaveLength(0); }); it("does not surface singletons or orders with no active items", async () => { await pbMock.collection("orders").create({ id: "o-solo", order_number: "SOLO", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("orders").create({ id: "o-delivered", order_number: "ALSO-SOLO", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-d", order: "o-delivered", description: "Widget", item_status: "delivered", }); const groups = await findDuplicateOrders(); expect(groups).toHaveLength(0); }); it("enriches each active record with company, customer, and item statuses", async () => { await pbMock.collection("customers").create({ id: "c-x", customer: "Cust" }); await pbMock.collection("companies").create({ id: "co-x", company: "Co" }); await pbMock.collection("orders").create({ id: "o-1", order_number: "DUP", quote: "q", customer: "c-x", company: "co-x", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "M16x50 bolt", quantity: 400, item_status: "po_generated", }); await pbMock.collection("orders").create({ id: "o-2", order_number: "DUP", quote: "q", customer: "c-x", company: "co-x", created: "2025-01-01T00:00:00.100Z", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o-2", description: "M16x50 bolt", quantity: 400, item_status: "invoiced", }); const groups = await findDuplicateOrders(); expect(groups).toHaveLength(1); const r1 = groups[0].records.find((r) => r.id === "o-1")!; const r2 = groups[0].records.find((r) => r.id === "o-2")!; expect(r1.customerName).toBe("Cust"); expect(r1.companyName).toBe("Co"); expect(r1.itemStatusSummary).toEqual({ po_generated: 1 }); // The invoiced copy shows the operator how far each got — the // "invoiced" one is the natural keeper. expect(r2.itemStatusSummary).toEqual({ invoiced: 1 }); }); it("sorts records by created asc so the oldest is first", async () => { await pbMock.collection("orders").create({ id: "o-newer", order_number: "DUP", quote: "q", created: "2025-01-01T01:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-n", order: "o-newer", description: "x", item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-older", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-o", order: "o-older", description: "x", item_status: "pending", }); const groups = await findDuplicateOrders(); expect(groups[0].records.map((r) => r.id)).toEqual(["o-older", "o-newer"]); }); it("returns groups sorted by size (largest first)", async () => { // Use distinct quantities per order_number so the cross-number // line-aggregate pass doesn't accidentally fire here — this test // is about the order-number pass's sort order. for (const [id, num, qty] of [ ["o-s1", "SOLO", 1], ["o-s2", "SOLO", 1], ["o-t1", "TRIPLE", 2], ["o-t2", "TRIPLE", 2], ["o-t3", "TRIPLE", 2], ]) { await pbMock.collection("orders").create({ id, order_number: num, quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: `oi-${id}`, order: id, description: "x", quantity: qty, item_status: "pending", }); } const groups = await findDuplicateOrders(); expect(groups.map((g) => g.orderNumber)).toEqual(["TRIPLE", "SOLO"]); }); // ────── line-aggregate ("Same line items") pass ────── it("flags cross-order_number line-aggregate duplicates even when descriptions differ", async () => { // Two orders under different order_numbers, same line breakdown // (1 line, qty 5, R100). Descriptions and created-timestamps // differ — the kind: "line_aggregate" pass should still flag it. await pbMock.collection("orders").create({ id: "o-a", order_number: "ORD-A", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-a", order: "o-a", description: "M16 hex bolt 50mm grade 8.8", quantity: 5, selling_price: 100, item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-b", order_number: "ORD-B", quote: "q", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-b", order: "o-b", description: "M16 x 50 hex bolt", // different text quantity: 5, selling_price: 100, item_status: "pending", }); const groups = await findDuplicateOrders(); const aggregateGroups = groups.filter((g) => g.kind === "line_aggregate"); expect(aggregateGroups).toHaveLength(1); expect(aggregateGroups[0].records.map((r) => r.id).sort()).toEqual(["o-a", "o-b"]); expect(aggregateGroups[0].orderNumber).toMatch(/^1 line · 5 · R/); }); it("does not double-list a same-number group under line-aggregate (cross-number filter)", async () => { // Two records under the SAME order_number with matching content // — already an order-number duplicate. The line-aggregate pass // must NOT surface them again. await pbMock.collection("orders").create({ id: "o-1", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", quantity: 5, selling_price: 100, item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-2", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.100Z", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o-2", description: "x", quantity: 5, selling_price: 100, item_status: "invoiced", }); const groups = await findDuplicateOrders(); expect(groups).toHaveLength(1); expect(groups[0].kind).toBe("order_number"); expect(groups[0].records.map((r) => r.id)).toEqual(["o-1", "o-2"]); }); it("excludes orders with zero items from line-aggregate matching", async () => { // Empty orders would all match each other on (0 lines, 0 qty, R0) // — must not be flagged. await pbMock.collection("orders").create({ id: "o-empty-1", order_number: "EMPTY-A", quote: "q", }); await pbMock.collection("orders").create({ id: "o-empty-2", order_number: "EMPTY-B", quote: "q", }); const groups = await findDuplicateOrders(); expect(groups).toHaveLength(0); }); it("rounds sub-cent price differences so 1499.998 and 1500.002 both match 1500.00", async () => { await pbMock.collection("orders").create({ id: "o-x", order_number: "X", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-x", order: "o-x", description: "x", quantity: 1, selling_price: 1499.998, item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-y", order_number: "Y", quote: "q", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-y", order: "o-y", description: "x", quantity: 1, selling_price: 1500.002, item_status: "pending", }); const groups = await findDuplicateOrders(); expect(groups.filter((g) => g.kind === "line_aggregate")).toHaveLength(1); }); it("matches when both sides are entirely zero-priced", async () => { await pbMock.collection("orders").create({ id: "o-z1", order_number: "Z1", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-z1", order: "o-z1", description: "x", quantity: 5, selling_price: 0, item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-z2", order_number: "Z2", quote: "q", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-z2", order: "o-z2", description: "x", quantity: 5, selling_price: 0, item_status: "pending", }); const groups = await findDuplicateOrders(); expect(groups.filter((g) => g.kind === "line_aggregate")).toHaveLength(1); }); it("does not match when one side is zero-priced and the other is not", async () => { await pbMock.collection("orders").create({ id: "o-p1", order_number: "P1", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-p1", order: "o-p1", description: "x", quantity: 1, selling_price: 0, item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-p2", order_number: "P2", quote: "q", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-p2", order: "o-p2", description: "x", quantity: 1, selling_price: 50, item_status: "pending", }); const groups = await findDuplicateOrders(); expect(groups).toHaveLength(0); }); it("does not match when total quantity differs (everything else the same)", async () => { await pbMock.collection("orders").create({ id: "o-q1", order_number: "Q1", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-q1", order: "o-q1", description: "x", quantity: 5, selling_price: 100, item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-q2", order_number: "Q2", quote: "q", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-q2", order: "o-q2", description: "x", quantity: 6, selling_price: 100, item_status: "pending", }); const groups = await findDuplicateOrders(); expect(groups).toHaveLength(0); }); it("does not match when line count differs (everything else the same)", async () => { await pbMock.collection("orders").create({ id: "o-c1", order_number: "C1", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-c1a", order: "o-c1", description: "x", quantity: 5, selling_price: 100, item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-c1b", order: "o-c1", description: "y", quantity: 5, selling_price: 100, item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-c2", order_number: "C2", quote: "q", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-c2", order: "o-c2", description: "x", quantity: 10, selling_price: 100, item_status: "pending", }); const groups = await findDuplicateOrders(); expect(groups).toHaveLength(0); }); it("computes totalSellingPrice as the sum of selling_price × quantity per line", async () => { // 2 lines: qty 2 @ R50 = R100, qty 3 @ R100 = R300 → total R400. // qty 5, R400. Match against another order with same aggregates. await pbMock.collection("orders").create({ id: "o-m1", order_number: "M1", quote: "q", created: "2025-01-01T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-m1a", order: "o-m1", description: "a", quantity: 2, selling_price: 50, item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-m1b", order: "o-m1", description: "b", quantity: 3, selling_price: 100, item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-m2", order_number: "M2", quote: "q", created: "2025-01-02T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-m2a", order: "o-m2", description: "a", quantity: 2, selling_price: 50, item_status: "pending", }); await pbMock.collection("order_items").create({ id: "oi-m2b", order: "o-m2", description: "b", quantity: 3, selling_price: 100, item_status: "pending", }); const groups = await findDuplicateOrders(); const aggs = groups.filter((g) => g.kind === "line_aggregate"); expect(aggs).toHaveLength(1); expect(aggs[0].orderNumber).toMatch(/^2 lines · 5 · R/); expect(aggs[0].records[0].totalQuantity).toBe(5); expect(aggs[0].records[0].totalSellingPrice).toBeCloseTo(400, 2); }); });