import { describe, it, expect, vi } from "vitest"; import { normaliseInsightSku, populateQuoteItemInsights, populateQuoteInsightsForQuote, } from "@/lib/server/quote-items-insights-service"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: vi.fn(), })); // ─── Pure helpers ─────────────────────────────────────────────────── describe("normaliseInsightSku", () => { it("trims, uppercases, and normalises separators", () => { expect(normaliseInsightSku(" drl-6mm ")).toBe("DRL-6MM"); expect(normaliseInsightSku("DRL 6MM")).toBe("DRL-6MM"); expect(normaliseInsightSku("DRL_6MM")).toBe("DRL-6MM"); }); it("strips leading zero runs after dash or start", () => { expect(normaliseInsightSku("DRL-006MM")).toBe("DRL-6MM"); expect(normaliseInsightSku("BOLT-006")).toBe("BOLT-6"); expect(normaliseInsightSku("000123")).toBe("123"); }); it("leaves single leading zeros alone (those are real SKUs)", () => { expect(normaliseInsightSku("DRL-06")).toBe("DRL-06"); expect(normaliseInsightSku("0DRL")).toBe("0DRL"); }); it("returns empty string for falsy input", () => { expect(normaliseInsightSku("")).toBe(""); // @ts-expect-error — test the defensive guard expect(normaliseInsightSku(undefined)).toBe(""); // @ts-expect-error expect(normaliseInsightSku(null)).toBe(""); }); }); // ─── Service — happy paths ────────────────────────────────────────── function buildPb(opts: { target: Record; quoteItems?: Array>; orderItems?: Array>; priceList?: Array>; stockItems?: Array>; pos?: Array>; suppliers?: Array<{ id: string; suppliername: string }>; }) { const target = opts.target; const quoteItems = opts.quoteItems ?? []; const orderItems = opts.orderItems ?? []; const priceList = opts.priceList ?? []; const stockItems = opts.stockItems ?? []; const pos = opts.pos ?? []; const suppliers = opts.suppliers ?? []; const emptyList = { items: [], totalItems: 0 }; const updated: Array<{ id: string; patch: Record }> = []; const collection = (name: string) => { if (name === "quote_items") { return { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockImplementation( (_page: number, _perPage: number, listOpts?: { filter?: string; sort?: string }) => { const f = listOpts?.filter || ""; // Match by sku="..." in filter const skuMatch = /sku = "([^"]+)"/.exec(f); const wantedSku = skuMatch?.[1] || ""; const items = quoteItems.filter((it) => { if (wantedSku && it.sku !== wantedSku) return false; return true; }); const sorted = [...items].sort((a, b) => { if (listOpts?.sort === "-created") { return String(b.created).localeCompare(String(a.created)); } if (listOpts?.sort === "-updated") { return String(b.updated).localeCompare(String(a.updated)); } return 0; }); return { items: sorted, totalItems: sorted.length }; } ), getFullList: vi.fn().mockResolvedValue(quoteItems), update: vi.fn().mockImplementation(async (id: string, patch: Record) => { updated.push({ id, patch }); return { ...target, ...patch }; }), }; } if (name === "order_items") { return { getOne: vi.fn(), getList: vi.fn().mockImplementation( (_page: number, _perPage: number, listOpts?: { filter?: string; sort?: string }) => { const f = listOpts?.filter || ""; const skuMatch = /sku = "([^"]+)"/.exec(f); const wantedSku = skuMatch?.[1] || ""; const items = orderItems.filter((it) => { if (wantedSku && it.sku !== wantedSku) return false; if (f.includes("item_status != \"cancelled\"")) { if (it.item_status === "cancelled" || it.item_status === "removed") return false; } if (f.includes("invoiced_date != null")) { if (!it.invoiced_date) return false; if (!it.supplier_invoice_number && !it.customer_invoice_number) return false; } return true; }); const sorted = [...items].sort((a, b) => { if (listOpts?.sort === "-updated") { return String(b.updated).localeCompare(String(a.updated)); } if (listOpts?.sort === "-invoiced_date") { return String(b.invoiced_date).localeCompare(String(a.invoiced_date)); } return 0; }); return { items: sorted, totalItems: sorted.length }; } ), update: vi.fn(), }; } if (name === "price_list") { return { getList: vi.fn().mockImplementation( (_page: number, _perPage: number, listOpts?: { filter?: string; sort?: string }) => { const f = listOpts?.filter || ""; // Support both `sku = "X"` and the OR form `(sku = "X" || sku = "Y")` // used by the normalised-SKU query path. const wantedSkus: string[] = []; const re = /sku = "([^"]+)"/g; let m: RegExpExecArray | null; while ((m = re.exec(f))) wantedSkus.push(m[1]); const items = wantedSkus.length ? priceList.filter((it) => wantedSkus.includes(String(it.sku || ""))) : []; return { items, totalItems: items.length }; } ), }; } if (name === "stock_items") { return { getList: vi.fn().mockImplementation( (_page: number, _perPage: number, listOpts?: { filter?: string }) => { const f = listOpts?.filter || ""; const skuMatch = /sku = "([^"]+)"/.exec(f); const wantedSku = skuMatch?.[1] || ""; const items = stockItems.filter((it) => wantedSku && it.sku === wantedSku); return { items, totalItems: items.length }; } ), }; } if (name === "pos") { return { getList: vi.fn().mockImplementation( (_page: number, _perPage: number, listOpts?: { filter?: string }) => { const f = listOpts?.filter || ""; const skuMatch = /sku = "([^"]+)"/.exec(f); const wantedSku = skuMatch?.[1] || ""; const items = pos.filter((it) => wantedSku && it.sku === wantedSku); return { items, totalItems: items.length }; } ), }; } if (name === "suppliers") { return { getList: vi.fn().mockResolvedValue({ items: suppliers, totalItems: suppliers.length }), }; } return { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList), update: vi.fn() }; }; return { collection, updated }; } describe("populateQuoteItemInsights — SKU-exact happy path", () => { it("answers all four questions when SKU + history exist", async () => { const pb = buildPb({ target: { id: "qi-target", sku: "M16-BOLT", description: "M16 bolt", cost_price: 100, selling_price: 150, }, priceList: [ { id: "pl-1", sku: "M16-BOLT", cost_price: 90, selling_price: 130, supplier: "sup-acme", brand: "Acme", category: "Bolts", subcategory: "M16", updated: "2026-07-10T10:00:00Z", }, ], quoteItems: [ { id: "qi-old", quote: "q-old", sku: "M16-BOLT", cost_price: 95, selling_price: 145, created: "2026-06-01T10:00:00Z", expand: { quote: { quote_number: "Q202606010" } }, }, ], orderItems: [ { id: "oi-1", order: "o-1", sku: "M16-BOLT", supplier: "sup-acme", cost_price: 90, selling_price: 140, item_status: "delivered", updated: "2026-05-15T10:00:00Z", delivered_at: "2026-05-20", invoiced_date: "2026-05-25T10:00:00Z", supplier_invoice_number: "INV-42", customer_invoice_number: "C-INV-7", quantity: 5, qty_invoiced: 5, }, ], stockItems: [ { id: "si-1", name: "M16 Bolt", sku: "M16-BOLT", current_qty: 100, min_qty: 20, selling_price: 130, markup: 44, }, ], suppliers: [{ id: "sup-acme", suppliername: "Acme Fasteners" }], }); vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); const result = await populateQuoteItemInsights("qi-target"); expect(result.match_kind).toBe("sku_exact"); expect(result.found_data).toBe(true); const i = result.insights; expect(i.match.sku).toBe("M16-BOLT"); expect(i.match.match_confidence).toBe(1); // last_quoted expect(i.last_quoted?.quote_item_id).toBe("qi-old"); expect(i.last_quoted?.quote_number).toBe("Q202606010"); // on_price_list + entries expect(i.on_price_list).toBe(true); expect(i.price_list_entries).toHaveLength(1); expect(i.price_list_entries![0].supplier_name).toBe("Acme Fasteners"); // last_order expect(i.last_order?.order_item_id).toBe("oi-1"); expect(i.last_order?.item_status).toBe("delivered"); expect(i.last_order?.supplier_name).toBe("Acme Fasteners"); // last_invoiced expect(i.last_invoiced?.supplier_invoice_number).toBe("INV-42"); expect(i.last_invoiced?.customer_invoice_number).toBe("C-INV-7"); // stock expect(i.stock?.status).toBe("in_stock"); expect(i.stock?.current_qty).toBe(100); // suppliers expect(i.suppliers).toHaveLength(1); expect(i.suppliers![0].last_cost).toBe(90); }); it("writes the blob back to PB by default and skips when write=false", async () => { const pb = buildPb({ target: { id: "qi-t", sku: "X1", description: "X", cost_price: 0, selling_price: 0 }, suppliers: [], }); vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); const r1 = await populateQuoteItemInsights("qi-t"); expect(pb.updated).toHaveLength(1); expect((pb.updated[0].patch as { ai_item_insights: unknown }).ai_item_insights).toEqual( r1.insights ); pb.updated.length = 0; await populateQuoteItemInsights("qi-t", { write: false }); expect(pb.updated).toHaveLength(0); }); }); describe("populateQuoteItemInsights — description fallback", () => { it("recovers an SKU from description when target SKU is empty", async () => { const pb = buildPb({ target: { id: "qi-target", sku: "", description: "M16 hex bolt grade 8.8 zinc plated", cost_price: 0, selling_price: 0, }, quoteItems: [ // Fuzzy candidate from earlier: same description almost exactly, // carrying a SKU the target doesn't have. { id: "qi-cand", sku: "M16-HEX-8.8", description: "M16 hex bolt grade 8.8 zinc plated steel", created: "2026-06-01T10:00:00Z", }, // Distractor { id: "qi-other", sku: "M8-BOLT", description: "M8 socket cap screw" }, ], orderItems: [ { id: "oi-1", order: "o-1", sku: "M16-HEX-8.8", supplier: "sup-a", cost_price: 90, selling_price: 140, item_status: "delivered", updated: "2026-05-15T10:00:00Z", }, ], suppliers: [{ id: "sup-a", suppliername: "Acme" }], }); vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); const result = await populateQuoteItemInsights("qi-target"); expect(result.match_kind).toBe("description_fuzzy"); expect(result.insights.match.sku).toBe("M16-HEX-8.8"); expect(result.found_data).toBe(true); expect(result.insights.last_order?.order_item_id).toBe("oi-1"); }); it("returns match_kind=none when there is no SKU and no fuzzy match", async () => { const pb = buildPb({ target: { id: "qi-t", sku: "", description: "Brand new widget", cost_price: 0, selling_price: 0 }, quoteItems: [ { id: "qi-x", sku: "OTHER", description: "Completely different product line" }, ], suppliers: [], }); vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); const result = await populateQuoteItemInsights("qi-t"); expect(result.match_kind).toBe("none"); expect(result.found_data).toBe(false); expect(result.insights.summary).toMatch(/no history/i); }); }); describe("populateQuoteItemInsights — SKU normalisation", () => { it("matches price_list rows whose SKU is the normalised form", async () => { const pb = buildPb({ target: { id: "qi-t", sku: "DRL-006MM", description: "Drill", cost_price: 0, selling_price: 0 }, // The exact `DRL-006MM` isn't in price_list; only the normalised `DRL-6MM` is. priceList: [ { id: "pl-1", sku: "DRL-6MM", cost_price: 50, selling_price: 75, supplier: "sup-d", brand: "DrillCo", category: "Drills", subcategory: "6mm", updated: "2026-07-10T10:00:00Z", }, ], suppliers: [{ id: "sup-d", suppliername: "DrillCo" }], }); vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); const result = await populateQuoteItemInsights("qi-t"); expect(result.insights.on_price_list).toBe(true); expect(result.insights.price_list_entries).toHaveLength(1); expect(result.insights.price_list_entries![0].sku).toBe("DRL-6MM"); }); }); describe("populateQuoteInsightsForQuote — batch", () => { it("iterates every active item on the quote and rolls up counts", async () => { const target = { id: "qi-1", sku: "X1", description: "X", cost_price: 0, selling_price: 0 }; const items = [{ id: "qi-1" }, { id: "qi-2" }]; const pb = { collection: vi.fn((name: string) => { if (name === "quote_items") { return { getOne: vi.fn().mockResolvedValue(target), getFullList: vi.fn().mockResolvedValue(items), getList: vi.fn().mockResolvedValue({ items: [], totalItems: 0 }), update: vi.fn(), }; } return { getOne: vi.fn(), getList: vi.fn().mockResolvedValue({ items: [] }) }; }), }; vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); const batch = await populateQuoteInsightsForQuote("q-1"); expect(batch.total).toBe(2); expect(batch.results).toHaveLength(2); expect(batch.populated + batch.no_match).toBe(2); }); });