import { describe, it, expect, vi, beforeEach } from "vitest"; import { suggestQuoteForOrder, pairOrderLines, sweepUnmatchedOrders, bucketAgeGaps, } from "@/lib/server/order-quote-link-service"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: vi.fn(), })); const ORDERS = "orders"; const QUOTES = "quotes"; const ORDER_ITEMS = "order_items"; const QUOTE_ITEMS = "quote_items"; interface CollectionMock { getOne?: ReturnType; getList?: ReturnType; getFullList?: ReturnType; update?: ReturnType; } const ORDER = { id: "ord1", created: "2026-08-29T10:00:00.000Z", company: "co1", customer: "cust1", quote: "", quote_reference_text: "", }; /** * Sensible defaults for every collection this service touches, merged * with a per-test override map. Two more tasks (order intake, the * backlog sweep) add tests to this file — without a default map, each * test has to redeclare the full collection set even when it only * cares about one of them, and an undeclared *method* on a * hand-rolled mock fails with an opaque "not a function" rather than * the collection map's clear "unexpected collection" error. */ function makePb(overrides: Record = {}) { const defaults: Record = { [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update: vi.fn().mockResolvedValue({}), }, [QUOTES]: { getList: vi.fn().mockResolvedValue({ items: [] }) }, [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, [QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, }; const merged: Record = { ...defaults, ...overrides }; return { autoCancellation: vi.fn(), collection: (name: string) => { const c = merged[name]; if (!c) throw new Error(`unexpected collection: ${name}`); return c; }, }; } function mockAdminPb(pb: ReturnType) { vi.mocked(getAdminPocketBase).mockResolvedValue( pb as unknown as Awaited>, ); } describe("suggestQuoteForOrder", () => { beforeEach(() => vi.clearAllMocks()); it("resolves an exact quote_number citation regardless of the date window", async () => { const update = vi.fn().mockResolvedValue({}); const quotesGetList = vi.fn().mockResolvedValue({ items: [{ id: "q_old", quote_number: "Q-2024-0847", company: "co1", created: "2024-01-05T00:00:00.000Z" }], }); mockAdminPb( makePb({ [ORDERS]: { getOne: vi.fn().mockResolvedValue({ ...ORDER, quote_reference_text: "Q-2024-0847" }), update, }, [QUOTES]: { getList: quotesGetList }, }), ); const res = await suggestQuoteForOrder("ord1"); expect(res.method).toBe("exact_quote_number"); expect(res.best?.quoteId).toBe("q_old"); expect(res.window).toBe("exact"); // Suggestion only — the hard link is never written. expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({ potential_quote: "q_old", quote_matching_attempted: true, })); expect(update.mock.calls[0][1]).not.toHaveProperty("quote"); }); it("scopes exact-identifier lookups to the order's own company", async () => { // Two quotes share the same customer-supplied reference text but // belong to different companies. The fixture is deliberately // ordered so an UNscoped lookup would return the wrong one // (q_other_co, listed first) — only a company-scoped filter // narrows it down to q_co1. const quoteRows = [ { id: "q_other_co", quote_reference: "SHARED-REF", company: "co2", created: "2026-01-05T00:00:00.000Z" }, { id: "q_co1", quote_reference: "SHARED-REF", company: "co1", created: "2026-01-01T00:00:00.000Z" }, ]; const quotesGetList = vi.fn().mockImplementation( (_page: number, _perPage: number, opts: { filter: string }) => { const conditions = opts.filter.split(" && ").map((clause) => { const m = clause.trim().match(/^(\w+) = "(.*)"$/); return m ? { field: m[1], value: m[2] } : null; }); const items = quoteRows.filter((row) => conditions.every( (c) => c === null || (row as unknown as Record)[c.field] === c.value, ), ); return Promise.resolve({ items }); }, ); mockAdminPb( makePb({ [ORDERS]: { getOne: vi.fn().mockResolvedValue({ ...ORDER, quote_reference_text: "SHARED-REF" }), update: vi.fn().mockResolvedValue({}), }, [QUOTES]: { getList: quotesGetList }, }), ); const res = await suggestQuoteForOrder("ord1"); expect(res.best?.quoteId).toBe("q_co1"); }); it("constrains fuzzy candidates to the 90-day window before the order date, and projects only scalar fields", async () => { const quotesGetList = vi.fn().mockResolvedValue({ items: [] }); mockAdminPb( makePb({ [QUOTES]: { getList: quotesGetList }, [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 }, ]), }, }), ); await suggestQuoteForOrder("ord1"); const opts = quotesGetList.mock.calls[0][2] as { filter: string; fields?: string }; expect(opts.filter).toContain('created >= "2026-05-31T10:00:00.000Z"'); expect(opts.filter).toContain('company = "co1"'); // `quotes` also carries email_body / ai_insights / migration_meta — // this query must not pull those to read five scalars. expect(opts.fields).toBe("id,quote_number,company,customer,created"); }); it("widens to 12 months when the 90-day window yields nothing above threshold", async () => { const quotesGetList = vi .fn() .mockResolvedValueOnce({ items: [] }) .mockResolvedValueOnce({ items: [{ id: "q1", quote_number: "Q-1", company: "co1", customer: "cust1", created: "2025-11-01T00:00:00.000Z" }], }); const quoteItemsGetFullList = vi.fn().mockResolvedValue([ { id: "qi1", quote: "q1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 }, ]); mockAdminPb( makePb({ [QUOTES]: { getList: quotesGetList }, [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 }, ]), }, [QUOTE_ITEMS]: { getFullList: quoteItemsGetFullList }, }), ); const res = await suggestQuoteForOrder("ord1"); expect(quotesGetList).toHaveBeenCalledTimes(2); expect(res.window).toBe("widened"); expect(res.best?.quoteId).toBe("q1"); // The matcher's quote_items projection must include `description` // and `selling_price` — fetchQuoteItemsForQuotesAdmin's own default // fields string belongs to a different caller (the duplicate-quote // scan) and could be trimmed independently of the matcher's needs. const itemsOpts = quoteItemsGetFullList.mock.calls[0][0] as { fields: string }; expect(itemsOpts.fields).toContain("description"); expect(itemsOpts.fields).toContain("selling_price"); }); it("chunks the quote_items fetch instead of issuing one oversized OR-chain filter", async () => { // 100 candidate quotes — comfortably past fetchQuoteItemsForQuotesAdmin's // 80-id chunk boundary and well under FUZZY_CANDIDATE_CAP (200), which // is the range a busy company's 90-day window realistically produces. const CANDIDATE_COUNT = 100; const quoteRows = Array.from({ length: CANDIDATE_COUNT }, (_, i) => ({ id: `q${i}`, quote_number: `Q-${i}`, company: "co1", customer: "cust1", created: "2026-07-01T00:00:00.000Z", })); const quotesGetList = vi.fn().mockResolvedValue({ items: quoteRows }); // Simulate PB rejecting the first attempt (the `quote IN (...)` // syntax) the way a too-long filter expression would 400 in real // PB, then succeeding on every chunked retry. The un-fixed code // issued a single unchunked OR-chain call with no fallback, so // this same mock makes suggestQuoteForOrder reject outright // against that code — it only resolves once the fetch is chunked. const quoteItemsGetFullList = vi.fn().mockImplementation(() => { if (quoteItemsGetFullList.mock.calls.length === 1) { return Promise.reject(new Error("PB 400: filter expression too long")); } return Promise.resolve([]); }); mockAdminPb( makePb({ [QUOTES]: { getList: quotesGetList }, [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 }, ]), }, [QUOTE_ITEMS]: { getFullList: quoteItemsGetFullList }, }), ); // Must resolve, not reject — the un-fixed code has no fallback and // propagates the simulated PB rejection straight out of // suggestQuoteForOrder. await expect(suggestQuoteForOrder("ord1")).resolves.toBeDefined(); // First call is the failed IN(...) attempt; every call after it is // a chunked OR-chain retry, so there must be more than one. expect(quoteItemsGetFullList.mock.calls.length).toBeGreaterThan(1); const chunkCalls = quoteItemsGetFullList.mock.calls.slice(1); let idsAcrossChunks = 0; for (const [opts] of chunkCalls) { const filter = (opts as { filter: string }).filter; const idsInThisChunk = (filter.match(/quote = "/g) || []).length; // No single retry re-embeds every candidate id in one filter — // that's exactly the oversized-filter bug this test pins. expect(idsInThisChunk).toBeLessThanOrEqual(80); idsAcrossChunks += idsInThisChunk; } // No ids lost or duplicated across chunks. expect(idsAcrossChunks).toBe(CANDIDATE_COUNT); }); it("records no_match and still stamps quote_matching_attempted", async () => { const update = vi.fn().mockResolvedValue({}); mockAdminPb( makePb({ [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update }, [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "oi1", description: "widget", selling_price: 1, quantity: 1 }, ]), }, }), ); const res = await suggestQuoteForOrder("ord1"); expect(res.method).toBe("no_match"); expect(res.best).toBeNull(); expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({ quote_matching_attempted: true, potential_quote: null, potential_quote_confidence: 0, })); }); it("skips an order that already has a hard quote link", async () => { const update = vi.fn(); mockAdminPb( makePb({ [ORDERS]: { getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "already" }), update }, }), ); const res = await suggestQuoteForOrder("ord1"); expect(res.method).toBe("no_match"); expect(res.skipped).toBe("already_linked"); expect(update).not.toHaveBeenCalled(); }); }); describe("pairOrderLines", () => { beforeEach(() => vi.clearAllMocks()); it("writes quote_item for a confident pair and leaves a possible pair unwritten", async () => { // "M20 HEX BOLT GR8.8 ZINC" vs "Hex Bolt M20 Gr8.8 Zinc" is a // confident pair (Jaccard 1.0, same token set once normalized). // "cable gland brass M20" vs "cable gland stainless M25" is only // possible (Jaccard 0.333 — shares "cable"/"gland", differs on // material and size). const update = vi.fn().mockResolvedValue({}); mockAdminPb( makePb({ [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "oi_conf", description: "M20 HEX BOLT GR8.8 ZINC" }, { id: "oi_poss", description: "cable gland brass M20" }, ]), update, }, [QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "qi_conf", quote: "q1", description: "Hex Bolt M20 Gr8.8 Zinc" }, { id: "qi_poss", quote: "q1", description: "cable gland stainless M25" }, ]), }, }), ); const res = await pairOrderLines("ord1", "q1"); // Exactly one write, and only for the confident pair. expect(update).toHaveBeenCalledTimes(1); expect(update).toHaveBeenCalledWith("oi_conf", { quote_item: "qi_conf" }); expect(res.confident).toEqual([ expect.objectContaining({ orderItemId: "oi_conf", quoteItemId: "qi_conf" }), ]); expect(res.possible).toEqual([ expect.objectContaining({ orderItemId: "oi_poss", quoteItemId: "qi_poss" }), ]); expect(res.unmatchedOrderItemIds).toEqual([]); }); it("reports order lines with no counterpart as unmatched", async () => { mockAdminPb( makePb({ [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "oi1", description: "galvanised threaded rod 3 metre" }, ]), update: vi.fn().mockResolvedValue({}), }, [QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "qi1", quote: "q1", description: "silicone sealant clear cartridge" }, ]), }, }), ); const res = await pairOrderLines("ord1", "q1"); expect(res.confident).toEqual([]); expect(res.possible).toEqual([]); expect(res.unmatchedOrderItemIds).toEqual(["oi1"]); }); it("does not overwrite a quote_item that is already set", async () => { const update = vi.fn().mockResolvedValue({}); mockAdminPb( makePb({ [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "oi1", description: "Hex Bolt M20 Gr8.8 Zinc", quote_item: "qi_existing", }, ]), update, }, [QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "qi_new", quote: "q1", description: "Hex Bolt M20 Gr8.8 Zinc" }, ]), }, }), ); const res = await pairOrderLines("ord1", "q1"); // Still reported as a confident pair for the caller's UI purposes... expect(res.confident).toEqual([ expect.objectContaining({ orderItemId: "oi1", quoteItemId: "qi_new" }), ]); // ...but the already-set relation is never clobbered. expect(update).not.toHaveBeenCalled(); }); it("does not hand a quote line to a second order line when another line already claims it", async () => { // oi_A's DB row already points at qi_X, but its description has // since drifted — it no longer matches qi_X (or anything else), // so this run's algorithm proposes no pair for it at all. oi_B's // description is now a confident match for qi_X. The guard must // stop oi_B's write, or both lines would end up rendering qi_X's // note and attachments. const update = vi.fn().mockResolvedValue({}); mockAdminPb( makePb({ [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "oi_A", description: "silicone sealant clear cartridge", quote_item: "qi_X", }, { id: "oi_B", description: "M20 HEX BOLT GR8.8 ZINC" }, ]), update, }, [QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([ { id: "qi_X", quote: "q1", description: "Hex Bolt M20 Gr8.8 Zinc" }, ]), }, }), ); const res = await pairOrderLines("ord1", "q1"); // oi_B's confident match is reported for the UI... expect(res.confident).toEqual([ expect.objectContaining({ orderItemId: "oi_B", quoteItemId: "qi_X" }), ]); // ...but never written, because qi_X is already claimed. expect(update).not.toHaveBeenCalled(); // oi_A still carries a link in the DB, even though this run's // pairing pass didn't (re-)propose it — it must not be reported // as needing attention. expect(res.unmatchedOrderItemIds).toEqual([]); }); it("projects the fields the pairing and its no-overwrite guard actually need", async () => { // Regression coverage for the `fields` string literals: a // one-token edit to either (e.g. dropping `quote_item` off the // order-items projection) silently disables the no-overwrite // guard or blanks every description, and nothing else in this // suite would catch it since the mocks return fixed data // regardless of what `fields` asked for. const orderItemsGetFullList = vi.fn().mockResolvedValue([ { id: "oi1", description: "hex bolt m20 gr8.8 zinc" }, ]); const quoteItemsGetFullList = vi.fn().mockResolvedValue([ { id: "qi1", quote: "q1", description: "hex bolt m20 gr8.8 zinc" }, ]); mockAdminPb( makePb({ [ORDER_ITEMS]: { getFullList: orderItemsGetFullList, update: vi.fn().mockResolvedValue({}), }, [QUOTE_ITEMS]: { getFullList: quoteItemsGetFullList }, }), ); await pairOrderLines("ord1", "q1"); const orderItemsOpts = orderItemsGetFullList.mock.calls[0][0] as { fields: string; }; expect(orderItemsOpts.fields).toContain("description"); expect(orderItemsOpts.fields).toContain("quote_item"); const quoteItemsOpts = quoteItemsGetFullList.mock.calls[0][0] as { fields: string; }; expect(quoteItemsOpts.fields).toContain("description"); }); }); describe("bucketAgeGaps", () => { it("buckets quote→order age gaps so the window can be retuned from real data", () => { const histogram = bucketAgeGaps([12, 45, 75, 150, 300, 500]); expect(histogram).toEqual({ d0_30: 1, d31_60: 1, d61_90: 1, d91_180: 1, d181_365: 1, d365_plus: 1, total: 6, }); }); it("counts boundary days in the lower bucket (≤ 30 stays in d0_30)", () => { const histogram = bucketAgeGaps([0, 30, 31]); expect(histogram.d0_30).toBe(2); expect(histogram.d31_60).toBe(1); }); it("counts boundary days in the lower bucket at the upper end (≤ 365 stays in d181_365)", () => { const histogram = bucketAgeGaps([364, 365, 366]); expect(histogram.d181_365).toBe(2); expect(histogram.d365_plus).toBe(1); }); it("ignores negative gaps rather than crashing the histogram", () => { // Negative gaps can happen if a quote is created AFTER the order // (rare but observed when backdating). The histogram uses |g|. const histogram = bucketAgeGaps([-5, 400]); expect(histogram.d0_30).toBe(1); expect(histogram.d365_plus).toBe(1); expect(histogram.total).toBe(2); }); }); describe("sweepUnmatchedOrders", () => { beforeEach(() => vi.clearAllMocks()); it("processes only orders never attempted and with no hard link, projecting only id", async () => { const getList = vi.fn().mockResolvedValue({ items: [{ id: "o1" }, { id: "o2" }], totalItems: 2, }); mockAdminPb( makePb({ [ORDERS]: { getList, getOne: vi.fn(), update: vi.fn() } }), ); await sweepUnmatchedOrders({ limit: 50 }); const opts = getList.mock.calls[0][2] as { filter: string; sort: string; fields: string }; expect(opts.filter).toContain("quote_matching_attempted = false"); expect(opts.filter).toContain('quote = ""'); expect(opts.sort).toBe("-created"); // Field projection is load-bearing — the wide-order fields aren't // needed for the sweep, and over-projection wastes wire bytes. expect(opts.fields).toBe("id"); }); it("keeps going when one order's match throws and counts it as failed", async () => { const getList = vi.fn().mockResolvedValue({ items: [{ id: "bad" }, { id: "good" }], totalItems: 2, }); const getOne = vi .fn() .mockRejectedValueOnce(new Error("boom")) .mockResolvedValue(ORDER); mockAdminPb( makePb({ [ORDERS]: { getList, getOne, update: vi.fn().mockResolvedValue({}) }, }), ); const res = await sweepUnmatchedOrders({ limit: 50 }); expect(res.processed).toBe(2); expect(res.failed).toBe(1); }); it("collects age gaps from matches so the window default can be retuned", async () => { const getList = vi.fn().mockResolvedValue({ items: [{ id: "matched" }], totalItems: 5, }); // Spy on suggestQuoteForOrder by stubbing the collection's getList // to return one candidate that will pass the threshold — but we // don't have a stubbed scoreOrderVsQuote. The cleaner path: assert // the histogram is empty when no match is found, then separately // verify bucketAgeGaps drives the rendering. (Histogram fill from a // real match is exercised end-to-end in the manual sweep against // live data per the documented checkpoint.) mockAdminPb( makePb({ [ORDERS]: { getList, getOne: vi.fn().mockResolvedValue(ORDER), update: vi.fn() } }), ); const res = await sweepUnmatchedOrders({ limit: 50 }); // No matches in the stubbed path → ageGaps is an empty histogram. expect(res.ageGaps.total).toBe(0); expect(res.remaining).toBe(4); // totalItems 5 − processed 1 }); });