import { describe, it, expect, beforeEach, vi } from "vitest"; import type { ItemIntel, ItemIntelQuoteRow } from "@/lib/types"; const { pbMock, store, clearStore, intelByItem } = vi.hoisted(() => { const store: { items: Array> } = { items: [] }; const intelByItem = new Map>(); const pbMock = { autoCancellation: () => undefined, collection: () => ({ async getFullList() { return store.items; }, }), }; const clearStore = () => { store.items = []; intelByItem.clear(); }; return { pbMock, store, clearStore, intelByItem }; }); vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), })); vi.mock("@/lib/server/item-intel-service", () => ({ populateItemIntel: vi.fn(async (itemId: string) => { const found = intelByItem.get(itemId); if (!found) throw new Error(`no stubbed intel for ${itemId}`); return { recent_quote: [], ...found } as ItemIntel; }), })); import { getQuotedBeforeForQuote } from "./quoted-before-service"; import { populateItemIntel } from "@/lib/server/item-intel-service"; /** Minimal ItemIntelQuoteRow — only the fields the roll-up reads. */ const priorQuote = (over: Partial): ItemIntelQuoteRow => ({ score: 0.9, quote_item_id: `qi-${Math.random()}`, quote_id: "prior-1", quote_number: "Q-PRIOR-1", quote_status: "emailed", customer_name: "", company: "Blyvoor Gold", description: "", cost_price: 0, selling_price: 100, quantity: 1, quoted_at: "2026-08-01T00:00:00Z", days_ago: 10, ...over, }) as ItemIntelQuoteRow; const seedLine = ( itemId: string, description: string, priors: ItemIntelQuoteRow[], quantity = 1, ) => { store.items.push({ id: itemId, description, quantity }); intelByItem.set(itemId, { recent_quote: priors }); }; beforeEach(() => { clearStore(); vi.clearAllMocks(); }); describe("getQuotedBeforeForQuote", () => { it("rolls prior quotes up across lines, ranking by how many lines they cover", async () => { // The tender case: one prior quote covered both of this quote's // lines, another only shared a single commodity line. seedLine("i-1", "M20 x 50mm Hex Bolt", [ priorQuote({ quote_id: "same-tender", quote_number: "Q-TENDER", selling_price: 10, quantity: 100 }), priorQuote({ quote_id: "commodity", quote_number: "Q-COMMODITY", selling_price: 11, quantity: 1 }), ]); seedLine("i-2", "M20 Flat Washer", [ priorQuote({ quote_id: "same-tender", quote_number: "Q-TENDER", selling_price: 2, quantity: 200 }), ]); const result = await getQuotedBeforeForQuote("q-target"); expect(result.totalLines).toBe(2); expect(result.matchedLines).toBe(2); // Q-TENDER covered both lines, so it ranks first. expect(result.relatedQuotes[0].quoteNumber).toBe("Q-TENDER"); expect(result.relatedQuotes[0].matchedLines).toBe(2); // 10×100 + 2×200 = 1400 expect(result.relatedQuotes[0].matchedValue).toBe(1400); expect(result.relatedQuotes[1].quoteNumber).toBe("Q-COMMODITY"); expect(result.relatedQuotes[1].matchedLines).toBe(1); }); it("counts a prior quote once per line even if it matched twice on that line", async () => { seedLine("i-1", "M20 x 50mm Hex Bolt", [ priorQuote({ quote_id: "dupe", quote_number: "Q-D", quote_item_id: "a" }), priorQuote({ quote_id: "dupe", quote_number: "Q-D", quote_item_id: "b" }), ]); const result = await getQuotedBeforeForQuote("q-target"); expect(result.relatedQuotes).toHaveLength(1); expect(result.relatedQuotes[0].matchedLines).toBe(1); }); it("flags an unpriced prior quote instead of implying it went out at zero", async () => { seedLine("i-1", "M20 x 50mm Hex Bolt", [ priorQuote({ quote_id: "unpriced", selling_price: 0 }), ]); const result = await getQuotedBeforeForQuote("q-target"); expect(result.relatedQuotes[0].matchedValue).toBe(0); expect(result.relatedQuotes[0].hasPrice).toBe(false); }); it("carries the best match score through so weak hits can be shown as weak", async () => { seedLine("i-1", "M20 x 50mm Hex Bolt", [ priorQuote({ quote_id: "loose", score: 0.52 }), ]); seedLine("i-2", "M20 Flat Washer", [ priorQuote({ quote_id: "strong", score: 0.95 }), ]); const result = await getQuotedBeforeForQuote("q-target"); const loose = result.relatedQuotes.find((r) => r.quoteId === "loose"); const strong = result.relatedQuotes.find((r) => r.quoteId === "strong"); expect(loose?.matchConfidence).toBeCloseTo(0.52); expect(strong?.matchConfidence).toBeCloseTo(0.95); // Equal line coverage → the stronger match sorts first. expect(result.relatedQuotes[0].quoteId).toBe("strong"); }); it("reports no matches when nothing was quoted before", async () => { seedLine("i-1", "Some brand new bespoke part", []); const result = await getQuotedBeforeForQuote("q-target"); expect(result.totalLines).toBe(1); expect(result.matchedLines).toBe(0); expect(result.relatedQuotes).toEqual([]); }); it("keeps going when one line's intel lookup throws", async () => { // A single bad line must not sink the whole panel. store.items.push({ id: "i-broken", description: "explodes", quantity: 1 }); seedLine("i-ok", "M20 x 50mm Hex Bolt", [priorQuote({ quote_id: "p" })]); const result = await getQuotedBeforeForQuote("q-target"); expect(result.totalLines).toBe(2); expect(result.matchedLines).toBe(1); expect(result.lines.find((l) => l.itemId === "i-broken")?.priorQuotes).toEqual([]); }); it("does not persist intel — the panel is a read-only lookup", async () => { seedLine("i-1", "M20 x 50mm Hex Bolt", [priorQuote({})]); await getQuotedBeforeForQuote("q-target"); expect(populateItemIntel).toHaveBeenCalledWith("i-1", "quote_items", { write: false, }); }); });