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 ?? ""); // Date / numeric comparisons go through `actual` (not the stringified // version) so PB-style `>=`/`<=`/`>`/`<` against ISO timestamps or // numbers behaves the way the service layer expects. The mock only // implements the subset the production code actually emits; if a // caller adds a new operator the match falls through to `true` so // tests don't false-negative. if (op === ">=" || op === "<=" || op === ">" || op === "<") { const a = actual as string | number | undefined | null; if (a === undefined || a === null) return false; switch (op) { case ">=": return a > expected || a === expected; case "<=": return a < expected || a === expected; case ">": return a > expected; case "<": return a < expected; default: return true; } } 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; // Strip outer parens around the whole expression so the splitter // below doesn't see `(` / `)` as clause-leading characters. PB // filters are parenthesised when they mix `&&` / `||` — see // `completedTabFilter` in orders-service.ts for the canonical // example. const stripped = filter.replace(/^\s*\(/, "").replace(/\)\s*$/, ""); // Split on `||` first (outer OR), then each alternative is a group of // `&&`-joined AND clauses. An item passes if it satisfies ANY // alternative group. We also strip wrapping parens around each // alternative so individual clauses don't start with `(`. const alternatives = stripped .split("||") .map((a) => a.trim().replace(/^\(/, "").replace(/\)$/, "")) .filter(Boolean); return items.filter((item) => alternatives.some((alt) => { const clauses = alt .split("&&") .map((c) => c.trim().replace(/^\(/, "").replace(/\)$/, "").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), })); vi.mock("./order-quote-link-service", () => ({ pairOrderLines: vi.fn().mockResolvedValue({ confident: [], possible: [], unmatchedOrderItemIds: [], }), suggestQuoteForOrder: vi.fn().mockResolvedValue({ best: null }), })); vi.mock("./order-delivery-note-link-service", () => ({ suggestDeliveryNoteForOrder: vi.fn().mockResolvedValue({ best: null, method: "no_match", }), })); // `after()` normally defers its callback until the response has been // sent — outside a real Next.js request, nothing ever flushes that // queue. Stub it to run the callback inline so `createOrder` tests can // assert on the scheduled quote-match synchronously. vi.mock("next/server", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, after: vi.fn((cb: () => unknown) => { void cb(); }), }; }); import { createOrder, createOrderFromQuote, clearOrderItemStatusTabCountsCache, getOrderItemStatusTabCounts, getOrderIdsForTabGroup, getOrderItemsCollectionName, getOrdersArchiveData, getOrdersDashboardData, listOrdersWithItems, splitOrderForContinuation, cancelOrder, cancelOrdersBulk, findDuplicateOrders, // Hermes unblock (PR 1, 2026-08-21): setOrderQuote, findOrderByOrderNumber, findOrderByEmailMessageId, } 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/); }); }); // ---------------------------------------------------------------------------- // createOrder email_message_id dedup pre-check. // // The n8n email-intake pipeline can pick up the same IMAP message multiple // times if the seen-flag races the workflow execution. Without this // dedup check, every poll cycle creates a brand new `orders` row. // `createOrder` (the service layer) is the line of defense for the // agent-API / counter / quote pipeline; the n8n workflow has its own // parallel check at the workflow layer. // // See docs/audit/order-pipeline-investigation-2026-08-20.md §2. // ---------------------------------------------------------------------------- describe("createOrder — email_message_id dedup", () => { it("returns the existing order when an email_message_id already exists, and marks duplicate=true", async () => { await pbMock.collection("customers").create({ id: "cust-d1" }); await pbMock.collection("companies").create({ id: "comp-d1" }); // Seed an existing order with the same email_message_id — this is // what would happen if the n8n pipeline already created one and // the next poll cycle is asking for the same write. await pbMock.collection("orders").create({ id: "order-existing", order_number: "ZA10PO26002142", company: "comp-d1", customer: "cust-d1", email_message_id: "", contact_name: "Neville Benadé", }); const result = await createOrder({ customer_id: "cust-d1", company_id: "comp-d1", contact_name: "Nbenade", // a different LLM-extracted name — irrelevant, we should NOT overwrite email_message_id: "", items: [{ description: "M16 Bolt", quantity: 50 }], }); // Same record came back, no new order created. expect(result.duplicate).toBe(true); expect(result.order.id).toBe("order-existing"); expect(result.order.order_number).toBe("ZA10PO26002142"); expect(result.order.contact_name).toBe("Neville Benadé"); expect(result.items_created).toEqual([]); expect(result.items_failed).toEqual([]); // The store should still have exactly one record keyed by this id. const orders = await pbMock.collection("orders").getFullList(); expect(orders.filter((o) => o.email_message_id === "")).toHaveLength(1); }); it("creates a brand-new order when email_message_id is unique", async () => { await pbMock.collection("customers").create({ id: "cust-d2" }); await pbMock.collection("companies").create({ id: "comp-d2" }); const result = await createOrder({ customer_id: "cust-d2", company_id: "comp-d2", contact_name: "Mr Fresh", email_message_id: "", items: [{ description: "M16 Bolt", quantity: 50 }], }); expect(result.duplicate).toBeUndefined(); expect(result.order.id).toBeDefined(); expect(result.order.email_message_id).toBe(""); expect(result.items_created.length).toBe(1); }); it("skips the dedup check entirely when email_message_id is empty string", async () => { await pbMock.collection("customers").create({ id: "cust-d3" }); await pbMock.collection("companies").create({ id: "comp-d3" }); const first = await createOrder({ customer_id: "cust-d3", company_id: "comp-d3", email_message_id: "", items: [{ description: "Counter sale item" }], }); // Counter-form orders don't carry an email_message_id — we never // dedup them, so two should be creatable. const second = await createOrder({ customer_id: "cust-d3", company_id: "comp-d3", email_message_id: "", items: [{ description: "Another counter sale" }], }); expect(first.duplicate).toBeUndefined(); expect(second.duplicate).toBeUndefined(); expect(first.order.id).not.toBe(second.order.id); }); it("skips the dedup check when email_message_id is undefined", async () => { await pbMock.collection("customers").create({ id: "cust-d4" }); await pbMock.collection("companies").create({ id: "comp-d4" }); const result = await createOrder({ customer_id: "cust-d4", company_id: "comp-d4", // email_message_id omitted entirely items: [{ description: "Walk-in" }], }); expect(result.duplicate).toBeUndefined(); expect(result.order.id).toBeDefined(); }); it("trims whitespace before the dedup lookup", async () => { await pbMock.collection("customers").create({ id: "cust-d5" }); await pbMock.collection("companies").create({ id: "comp-d5" }); await pbMock.collection("orders").create({ id: "order-padded", order_number: "ZA10PO26002142", company: "comp-d5", customer: "cust-d5", email_message_id: "", }); // Caller passes the same id with surrounding whitespace — should // still hit the dedup path. const result = await createOrder({ customer_id: "cust-d5", company_id: "comp-d5", email_message_id: " \n", items: [{ description: "x" }], }); expect(result.duplicate).toBe(true); expect(result.order.id).toBe("order-padded"); }); it("returns the existing record when the DB-level UNIQUE constraint rejects the insert (race)", async () => { await pbMock.collection("customers").create({ id: "cust-d6" }); await pbMock.collection("companies").create({ id: "comp-d6" }); // Race scenario: the pre-check finds NO existing record (no // contention yet), but by the time we INSERT, another worker // has already created the row (parallel n8n execution, or a // manual /api/v1/orders/create call). The DB rejects our insert // with a unique-constraint error. The catch block in createOrder // re-queries by email_message_id, finds the existing record, and // returns it as duplicate. // // The mock's `collection(name)` returns a fresh object every call, // so we wrap `pbMock.collection` itself to cache the per-collection // objects — that way monkey-patching `ordersColl.create` actually // affects the object createOrder will see. const collectionCache = new Map>(); const originalCollection = pbMock.collection; pbMock.collection = ((name: string) => { if (!collectionCache.has(name)) { collectionCache.set(name, originalCollection(name)); } return collectionCache.get(name)!; }) as typeof pbMock.collection; const ordersColl = pbMock.collection("orders") as unknown as { create: (data: Record) => Promise> }; const originalCreate = ordersColl.create; let insertAttempts = 0; ordersColl.create = async (data: Record) => { insertAttempts++; if (insertAttempts === 1) { // First attempt: the DB-level UNIQUE constraint rejects us // because another worker already inserted the same // email_message_id. Before throwing, insert the "raced" // record so the catch block's lookup can find it. const err = new Error( "Failed to create record. UNIQUE constraint failed: index idx_orders_email_message_id", ) as Error & { status: number }; err.status = 409; await originalCreate({ id: "order-race", order_number: "RACE-1", company: "comp-d6", customer: "cust-d6", email_message_id: "", ...data, }); throw err; } return originalCreate(data); }; try { const result = await createOrder({ customer_id: "cust-d6", company_id: "comp-d6", email_message_id: "", items: [{ description: "x" }], }); expect(result.duplicate).toBe(true); expect(result.order.id).toBe("order-race"); expect(insertAttempts).toBe(1); // we did try to insert; the catch caught the unique violation } finally { ordersColl.create = originalCreate; pbMock.collection = originalCollection; } }); }); // ---------------------------------------------------------------------------- // 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 two combined getFullList calls (combined tab scan + completed time-window scan)", async () => { // Regression guard: the pre-`completed`-tab 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 current implementation collapses to: // (a) ONE combined scan covering every tab status, used to // bucket the partition's "primary" tab counts, plus // (b) ONE time-windowed scan for the `completed` tab (recent // `delivered` + `completed` items only). // Total = 2 scans — still O(1) on the number of tabs, and each // scan is fields-only so the wire payload stays tiny. 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(2); } 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-", }); 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", // The completed tab requires a recent `delivered_at` (last 30 // days) — see `completedTabFilter`. The fixture here uses // `now` so the row qualifies regardless of when the test runs. delivered_at: new Date().toISOString(), }); const result = await getOrdersDashboardData({ page: 1, perPage: 20, tabGroup: "processing", }); // Counts are global (always were) — the badge still tells the truth. // `delivered` items now bucket into the `completed` tab (the // partition lists `completed` before `delivered` in TAB_GROUP_ORDER // so the time-windowed slice wins as the "primary" tab). The // archive page reads its own delivered count from // `getOrdersArchiveData` rather than this map. expect(result.counts.new).toBe(1); expect(result.counts.processing).toBe(1); expect(result.counts.completed).toBe(1); expect(result.counts.delivered).toBe(0); // 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 off the board — the operator never sees them // — so the duplicates endpoint should hide them. With the new // `completed` tab, *recent* `delivered` rows count as on the // board (they show up on the Completed tab), so we use a row // whose `delivered_at` is older than the 30-day window as the // "off the board" example. 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. `delivered_at` is set years ago, // well outside the 30-day Completed window, so it falls through // to the archive and isn't visible to the operator. 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", delivered_at: "2024-01-01T00:00:00Z", }); 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", }); // Off-board copy: `delivered_at` set years ago, well outside the // 30-day Completed window — so it lives on /orders/archive and // the operator can't see it. (Naïvely setting `item_status = // delivered` without a stale `delivered_at` would now show up on // the Completed tab and count as on the board.) 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", delivered_at: "2024-01-01T00:00:00Z", }); 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", }); // Off-board: a delivered row whose `delivered_at` is well outside // the 30-day Completed window (so it lives only on // /orders/archive). 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", delivered_at: "2024-01-01T00:00:00Z", }); 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); }); }); // ────────────────────────────────────────────────────────────────────── // setOrderQuote (Hermes unblock) // ────────────────────────────────────────────────────────────────────── describe("setOrderQuote", () => { it("links an existing order to an existing quote, returns {order, previous}", async () => { await pbMock.collection("quotes").create({ id: "q-1" }); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", item_status: "pending", }); const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); expect(result.previous).toEqual({ quote: "" }); expect(result.order.id).toBe("o-1"); expect(result.order.quote).toBe("q-1"); // The store reflects the write. const stored = (store["orders"] || new Map()).get("o-1"); expect(stored?.quote).toBe("q-1"); }); it("refuses 409 order_locked when any item is past pending", async () => { await pbMock.collection("quotes").create({ id: "q-1" }); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", item_status: "po_generated", }); await pbMock.collection("order_items").create({ id: "oi-2", order: "o-1", description: "y", item_status: "pending", }); try { await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); expect.fail("should have thrown"); } catch (err) { expect((err as Error & { status?: number }).status).toBe(409); const data = (err as Error & { data?: { code?: string; offendingItemIds?: string[] } }).data; expect(data?.code).toBe("order_locked"); expect(data?.offendingItemIds).toContain("oi-1"); } // Order quote must not have changed. const stored = (store["orders"] || new Map()).get("o-1"); expect(stored?.quote).toBe(""); }); it("refuses 409 quote_already_linked when another order has the quote", async () => { await pbMock.collection("quotes").create({ id: "q-1" }); await pbMock.collection("orders").create({ id: "o-other", order_number: "ORD-OTHER", quote: "q-1", }); await pbMock.collection("order_items").create({ id: "oi-other", order: "o-other", description: "x", item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", item_status: "pending", }); try { await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); expect.fail("should have thrown"); } catch (err) { expect((err as Error & { status?: number }).status).toBe(409); const data = (err as Error & { data?: { code?: string; conflictingOrderId?: string } }).data; expect(data?.code).toBe("quote_already_linked"); expect(data?.conflictingOrderId).toBe("o-other"); } }); it("404 when the order doesn't exist", async () => { await pbMock.collection("quotes").create({ id: "q-1" }); try { await setOrderQuote({ orderId: "missing", quoteId: "q-1" }); expect.fail("should have thrown"); } catch (err) { expect((err as Error & { status?: number }).status).toBe(404); } }); it("404 when the quote doesn't exist", async () => { await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", item_status: "pending", }); try { await setOrderQuote({ orderId: "o-1", quoteId: "missing" }); expect.fail("should have thrown"); } catch (err) { expect((err as Error & { status?: number }).status).toBe(404); } }); it("is idempotent when the order already points at the quote (no-op + no audit stamp)", async () => { await pbMock.collection("quotes").create({ id: "q-1" }); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "q-1", notes: "original note", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", item_status: "pending", }); const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1", actor: { id: "agent-1", name: "Hermes" }, }); expect(result.previous).toEqual({ quote: "q-1" }); expect(result.order.quote).toBe("q-1"); // Notes must NOT be touched on idempotent no-op. expect((store["orders"] || new Map()).get("o-1")?.notes).toBe("original note"); }); it("stamps actor into notes when provided", async () => { await pbMock.collection("quotes").create({ id: "q-1" }); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "", notes: "existing note", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", item_status: "pending", }); await setOrderQuote({ orderId: "o-1", quoteId: "q-1", actor: { id: "agent-1", name: "Hermes" }, }); const stored = (store["orders"] || new Map()).get("o-1"); expect(stored?.quote).toBe("q-1"); expect(String(stored?.notes ?? "")).toMatch(/existing note/); expect(String(stored?.notes ?? "")).toMatch(/Hermes linked this order to quote q-1/); }); }); // ────────────────────────────────────────────────────────────────────── // setOrderQuote line pairing (Task 5) // ────────────────────────────────────────────────────────────────────── describe("setOrderQuote line pairing", () => { it("pairs order lines to the quote's lines after writing the hard link", async () => { const { pairOrderLines } = await import("./order-quote-link-service"); vi.mocked(pairOrderLines).mockClear(); await pbMock.collection("quotes").create({ id: "q-1" }); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending", }); const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1"); expect(result.order.quote).toBe("q-1"); }); it("does not fail the link when pairing throws", async () => { const { pairOrderLines } = await import("./order-quote-link-service"); vi.mocked(pairOrderLines).mockClear(); vi.mocked(pairOrderLines).mockRejectedValueOnce(new Error("PB down")); await pbMock.collection("quotes").create({ id: "q-1" }); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending", }); const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); // The link still landed despite the pairing failure. expect(result.order.quote).toBe("q-1"); }); it("does not pair lines when the link is refused (order_locked)", async () => { const { pairOrderLines } = await import("./order-quote-link-service"); vi.mocked(pairOrderLines).mockClear(); await pbMock.collection("quotes").create({ id: "q-1" }); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", item_status: "po_generated", }); try { await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); expect.fail("should have thrown"); } catch (err) { expect((err as Error & { status?: number }).status).toBe(409); } expect(pairOrderLines).not.toHaveBeenCalled(); }); it("also pairs lines on the idempotent no-op path (order already points at the quote)", async () => { const { pairOrderLines } = await import("./order-quote-link-service"); vi.mocked(pairOrderLines).mockClear(); await pbMock.collection("quotes").create({ id: "q-1" }); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "q-1", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending", }); await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); // Re-running pairing on an already-linked order is safe (it never // overwrites an existing `quote_item`) and picks up lines added // or edited since the original link — so pairing runs here too, // even though the hard link itself is a no-op. expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1"); }); }); // ────────────────────────────────────────────────────────────────────── // findOrderByOrderNumber (Hermes unblock) // ────────────────────────────────────────────────────────────────────── describe("findOrderByOrderNumber", () => { it("returns null on miss (no matching order)", async () => { const hit = await findOrderByOrderNumber("DOES-NOT-EXIST"); expect(hit).toBeNull(); }); it("returns the full order record on hit", async () => { await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", quote: "q", created: "2026-08-21T00:00:00Z", }); const hit = await findOrderByOrderNumber("ORD-1"); expect(hit).not.toBeNull(); expect(hit?.id).toBe("o-1"); expect(hit?.order_number).toBe("ORD-1"); }); it("escapes double-quotes in user input", async () => { // Filter "order_number = \"weird\\\"value\"" — the mock's where // only handles simple patterns, so we just confirm no crash on // a quote-containing input. const hit = await findOrderByOrderNumber('weird"value'); expect(hit).toBeNull(); }); }); // ────────────────────────────────────────────────────────────────────── // findOrderByEmailMessageId (Hermes unblock) // ────────────────────────────────────────────────────────────────────── describe("findOrderByEmailMessageId", () => { it("returns null on miss", async () => { const hit = await findOrderByEmailMessageId(""); expect(hit).toBeNull(); }); it("returns the full order record on hit", async () => { await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", email_message_id: "", }); const hit = await findOrderByEmailMessageId(""); expect(hit).not.toBeNull(); expect(hit?.id).toBe("o-1"); expect(hit?.email_message_id).toBe(""); }); }); // ────────────────────────────────────────────────────────────────────── // findDuplicateOrders — email_message_id kind (Hermes unblock) // ────────────────────────────────────────────────────────────────────── describe("findDuplicateOrders — email_message_id kind", () => { it("flags a group of ≥2 records sharing the same email_message_id", async () => { const sharedEmail = ""; await pbMock.collection("orders").create({ id: "o-a", order_number: "ORD-A", quote: "q", email_message_id: sharedEmail, created: "2026-08-21T00:00:00Z", }); await pbMock.collection("order_items").create({ id: "oi-a", order: "o-a", description: "x", item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-b", order_number: "ORD-B", quote: "q", email_message_id: sharedEmail, created: "2026-08-21T00:00:01Z", }); await pbMock.collection("order_items").create({ id: "oi-b", order: "o-b", description: "x", item_status: "pending", }); const groups = await findDuplicateOrders({ kind: "email_message_id" }); expect(groups).toHaveLength(1); expect(groups[0].kind).toBe("email_message_id"); expect(groups[0].orderNumber).toBe(sharedEmail); expect(groups[0].records.map((r) => r.id).sort()).toEqual(["o-a", "o-b"]); }); it("skips records with an empty email_message_id", async () => { await pbMock.collection("orders").create({ id: "o-empty-1", order_number: "E1", quote: "q", email_message_id: "", }); await pbMock.collection("order_items").create({ id: "oi-e1", order: "o-empty-1", description: "x", item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-empty-2", order_number: "E2", quote: "q", email_message_id: "", }); await pbMock.collection("order_items").create({ id: "oi-e2", order: "o-empty-2", description: "x", item_status: "pending", }); const groups = await findDuplicateOrders({ kind: "email_message_id" }); expect(groups).toHaveLength(0); }); it("does not include email_message_id groups when kind filter is order_number", async () => { const sharedEmail = ""; await pbMock.collection("orders").create({ id: "o-a", order_number: "ORD-A", quote: "q", email_message_id: sharedEmail, }); await pbMock.collection("order_items").create({ id: "oi-a", order: "o-a", description: "x", item_status: "pending", }); await pbMock.collection("orders").create({ id: "o-b", order_number: "ORD-B", quote: "q", email_message_id: sharedEmail, }); await pbMock.collection("order_items").create({ id: "oi-b", order: "o-b", description: "x", item_status: "pending", }); const groups = await findDuplicateOrders({ kind: "order_number" }); expect(groups).toHaveLength(0); }); }); describe("createOrder quote auto-detection", () => { it("schedules a quote suggestion after the response", async () => { const { suggestQuoteForOrder } = await import("./order-quote-link-service"); const { after } = await import("next/server"); vi.mocked(suggestQuoteForOrder).mockClear(); vi.mocked(after).mockClear(); await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" }); const created = await createOrder({ customer_id: "cust-x", company_id: "comp-x", items: [{ description: "M16 Bolt", quantity: 50 }], }); expect(after).toHaveBeenCalled(); expect(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id); }); it("never fails order creation when the matcher throws", async () => { const consoleErrorSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); const { suggestQuoteForOrder } = await import("./order-quote-link-service"); vi.mocked(suggestQuoteForOrder).mockClear(); vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce(new Error("PB down")); 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", items: [{ description: "M16 Bolt", quantity: 50 }], }); expect(result.order.id).toBeDefined(); expect(result.items_created.length).toBe(1); // Verifies the inner catch swallowed the matcher failure and // logged it — without this assertion the test passes purely // because the `after` mock detaches the promise regardless of // what production code does. expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining("[createOrder] quote auto-detection failed for order"), expect.stringContaining("PB down"), ); consoleErrorSpy.mockRestore(); }); it("falls back to an inline match when after() is unavailable", async () => { const consoleErrorSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); const { after } = await import("next/server"); const { suggestQuoteForOrder } = await import("./order-quote-link-service"); vi.mocked(suggestQuoteForOrder).mockClear(); vi.mocked(after).mockClear(); vi.mocked(after).mockImplementationOnce(() => { throw new Error("`after` was called outside a request scope."); }); await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" }); const r = await createOrder({ customer_id: "cust-x", company_id: "comp-x", items: [{ description: "M16 Bolt", quantity: 50 }], }); expect(r.order.id).toBeDefined(); // Proves the catch fired AND ran the inline fallback — without // the call assertion the test would pass on the spy alone even // if `void runPostCreateMatchers()` was deleted. expect(suggestQuoteForOrder).toHaveBeenCalledWith(r.order.id); // The fallback logs its engagement so a silent switch to // fire-and-forget is impossible to miss in production logs. expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining( "[createOrder] after() unavailable, running post-create matchers inline", ), expect.anything(), ); consoleErrorSpy.mockRestore(); }); }); describe("createOrder delivery-note auto-detection", () => { it("also schedules a delivery-note match after the response", async () => { const { suggestDeliveryNoteForOrder } = await import( "./order-delivery-note-link-service" ); vi.mocked(suggestDeliveryNoteForOrder).mockClear(); await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd", }); const created = await createOrder({ customer_id: "cust-x", company_id: "comp-x", items: [{ description: "M16 Bolt", quantity: 50 }], }); expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id); }); it("a delivery-note match failure does not prevent the quote suggestion from running", async () => { // The point of the two independent try/catch blocks — a delivery- // note match failure (e.g. PB down for delivery_notes collection) // must still leave the quote suggestion able to land. The two // matchers are independent: one failing cannot starve the other. const { suggestDeliveryNoteForOrder } = await import( "./order-delivery-note-link-service" ); const { suggestQuoteForOrder } = await import("./order-quote-link-service"); vi.mocked(suggestDeliveryNoteForOrder).mockClear(); vi.mocked(suggestQuoteForOrder).mockClear(); vi.mocked(suggestDeliveryNoteForOrder).mockRejectedValueOnce( new Error("DN lookup failed"), ); await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd", }); const created = await createOrder({ customer_id: "cust-x", company_id: "comp-x", items: [{ description: "M16 Bolt", quantity: 50 }], }); expect(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id); }); it("a quote-match failure does not prevent the delivery-note match from running", async () => { // The reverse direction — the quote matcher throws, the delivery- // note matcher still runs. Proves the second try/catch is its // own scope and not a continuation of the first. const { suggestDeliveryNoteForOrder } = await import( "./order-delivery-note-link-service" ); const { suggestQuoteForOrder } = await import("./order-quote-link-service"); vi.mocked(suggestDeliveryNoteForOrder).mockClear(); vi.mocked(suggestQuoteForOrder).mockClear(); vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce( new Error("quote lookup failed"), ); await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd", }); const created = await createOrder({ customer_id: "cust-x", company_id: "comp-x", items: [{ description: "M16 Bolt", quantity: 50 }], }); expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id); }); }); describe("createOrderItemForOrder (mid-flight add)", () => { // Task 2 of the non-invoiceable-order-extras plan — the pending // gate that previously 409'd when siblings were po_generated is // removed because adding a new row is independent of any in-flight // PO's pricing/supplier reference. Three tests below cover the // contract: (a) the gate is gone, (b) the new flag is authoritative // for selling_price, (c) omission leaves the row billable. it("adds a line to an order whose siblings are po_generated (was previously 409 order_locked)", async () => { const { createOrderItemForOrder } = await import("./orders-service"); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", // Customer + company required by createOrder's lead row — skip // for this targeted test by using a minimal stub. }); await pbMock.collection("order_items").create({ id: "oi-existing", order: "o-1", description: "Bolt", quantity: 50, item_status: "po_generated", }); const result = await createOrderItemForOrder("o-1", { description: "Washer", quantity: 50, cost_price: 5, }); expect(result.failed).toEqual([]); expect(result.created.length).toBe(1); expect(result.created[0].description).toBe("Washer"); expect(result.orderLocked).toBe(false); }); it("exclude_from_invoice forces selling_price to 0 even when caller passes a non-zero price", async () => { const { createOrderItemForOrder } = await import("./orders-service"); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", }); const result = await createOrderItemForOrder("o-1", { description: "Nut", quantity: 50, cost_price: 10, markup: 50, // would compute selling_price = 15 without the flag selling_price: 15, // would persist 15 without the flag exclude_from_invoice: true, }); expect(result.created[0].selling_price).toBe(0); expect(result.created[0].markup).toBe(50); // markup stays as-is // The flag itself persists on the secondary update — separate // assertion because the `created[]` return shape doesn't include // it. const stored = (store["order_items"] as Map>).get( result.created[0].id, ); expect(stored?.exclude_from_invoice).toBe(true); expect(stored?.selling_price).toBe(0); }); it("omitting exclude_from_invoice preserves the existing markup-based selling_price calculation", async () => { const { createOrderItemForOrder } = await import("./orders-service"); await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", }); const result = await createOrderItemForOrder("o-1", { description: "Bolt", quantity: 50, cost_price: 10, markup: 25, }); expect(result.created[0].selling_price).toBe(12.5); // 10 * 1.25 const stored = (store["order_items"] as Map>).get( result.created[0].id, ); expect(stored?.exclude_from_invoice).toBeFalsy(); }); }); describe("cancelOrderItem", () => { let cancelOrderItem: typeof import("./orders-service").cancelOrderItem; beforeEach(async () => { ({ cancelOrderItem } = await import("./orders-service")); }); it("flips item_status to cancelled for a pending line", async () => { await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1", }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "M2 flat washer", item_status: "pending", }); const result = await cancelOrderItem("o-1", "oi-1"); expect(result.id).toBe("oi-1"); expect(result.item_status).toBe("cancelled"); }); it("refuses 404 when the order doesn't exist", async () => { await pbMock.collection("order_items").create({ id: "oi-orphan", order: "o-missing", description: "x", item_status: "pending", }); // The item belongs to o-missing; we're calling with o-1. // Even though the item exists, the order check fires first. try { await cancelOrderItem("o-1", "oi-orphan"); expect.fail("should have thrown"); } catch (err) { expect((err as Error & { status?: number }).status).toBe(404); } }); it("refuses 404 when the item belongs to a different order", async () => { await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1" }); await pbMock.collection("orders").create({ id: "o-2", order_number: "ORD-2" }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", item_status: "pending", }); // Item is on o-1; we're calling with o-2. try { await cancelOrderItem("o-2", "oi-1"); expect.fail("should have thrown"); } catch (err) { expect((err as Error & { status?: number }).status).toBe(404); } }); it("refuses 409 when the line is already in a terminal status", async () => { await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1" }); // 'invoiced' is in CANCEL_BLOCKED_ITEM_STATUSES — cancelling // would orphan the customer invoice. Same for delivered / // cancelled. One test per status would be overkill; pick // 'invoiced' as the representative terminal state. await pbMock.collection("order_items").create({ id: "oi-invoiced", order: "o-1", description: "x", item_status: "invoiced", }); try { await cancelOrderItem("o-1", "oi-invoiced"); expect.fail("should have thrown"); } catch (err) { expect((err as Error & { status?: number }).status).toBe(409); } }); it("stamps the operator identity on notes when an actor is supplied", async () => { await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1" }); await pbMock.collection("order_items").create({ id: "oi-1", order: "o-1", description: "x", item_status: "pending", }); await cancelOrderItem("o-1", "oi-1", { actor: { id: "u-1", name: "Dave" } }); const stored = (store["order_items"] as Map>).get("oi-1"); expect(stored?.item_status).toBe("cancelled"); // The notes field is appended, not replaced — preserves any // operator note that was already on the line. const notes = String(stored?.notes ?? ""); expect(notes).toMatch(/cancelled by Dave/); }); });