import { describe, it, expect } from "vitest"; import { UNIVERSAL_SEARCH_WINDOW_DAYS, bestScore, buildOrderTimeline, buildQuoteTimeline, daysAgoIso, escapeForPbFilter, orderGroups, scoreField, type UniversalGroup, } from "./universal-search-service"; /** * These cover the PURE layer of the universal search service. The PB * fan-out itself is exercised against live PB during development (and * the orders/quote matching has its own coverage in the sibling * services), but the rules that actually caused bugs — ranking, the * never-hide-a-cap invariant, filter escaping, the window maths — are * cheap and fast to pin down here. */ describe("scoreField — ranking tiers", () => { it("ranks identifiers above names above body text", () => { // Same substring, three surfaces. An operator searching an order // number wants the order NUMBERED that, not the one that mentions // it in a description. const id = scoreField("TIAAN06", "tiaan", "id"); const name = scoreField("TIAAN06 fitting", "tiaan", "name"); const body = scoreField("mentions tiaan in prose", "tiaan", "body"); expect(id).toBeGreaterThan(name); expect(name).toBeGreaterThan(body); }); it("scores exact above prefix above contains", () => { const exact = scoreField("bolts", "bolts", "name"); const prefix = scoreField("bolts m16", "bolts", "name"); const contains = scoreField("m16 bolts", "bolts", "name"); expect(exact).toBeGreaterThan(prefix); expect(prefix).toBeGreaterThan(contains); }); it("returns 0 for no match, empty value, or empty query", () => { expect(scoreField("bolts", "nuts", "name")).toBe(0); expect(scoreField("", "bolt", "name")).toBe(0); expect(scoreField(null, "bolt", "name")).toBe(0); expect(scoreField(undefined, "bolt", "name")).toBe(0); // An empty query must not match everything — the page guards on // length, but the scorer must not be the thing that breaks if that // guard is ever bypassed. expect(scoreField("bolts", "", "name")).toBe(0); }); it("is case-insensitive in both directions", () => { expect(scoreField("Afrisam", "afrisam", "name")).toBeGreaterThan(0); expect(scoreField("afrisam", "AFRISAM", "name")).toBeGreaterThan(0); }); }); describe("bestScore", () => { it("returns the single best field score, not a sum", () => { // A row matching on three fields should not outrank one matching an // identifier — otherwise a generic prose hit outranks an exact // order-number hit purely by repetition. const threeProse = bestScore( [ ["some tiaan text", "body"], ["more tiaan", "body"], ["even more tiaan", "body"], ], "tiaan", ); const oneId = bestScore([["TIAAN-06/08/2026", "id"]], "tiaan"); expect(oneId).toBeGreaterThan(threeProse); }); it("is 0 when nothing matches", () => { expect( bestScore( [ ["bolts", "name"], [null, "id"], ], "nuts", ), ).toBe(0); }); }); describe("escapeForPbFilter", () => { it("escapes quotes and backslashes so a query can't break the filter", () => { // A quote injected into the filter would let a search term rewrite // the PB query — this is the whole reason the helper exists. expect(escapeForPbFilter('a"b')).toBe('a\\"b'); expect(escapeForPbFilter("a\\b")).toBe("a\\\\b"); expect(escapeForPbFilter("plain")).toBe("plain"); }); it("escapes a hostile query so no quote survives unescaped", () => { const hostile = '" || id != ""'; const escaped = escapeForPbFilter(hostile); // Every `"` must be backslash-prefixed. That is the only thing an // escaper has to guarantee: the value is interpolated inside a // quoted PB filter literal, so a surviving bare quote is what would // let a search term break out and rewrite the query. A naive // substring assertion would false-positive on the legitimate `\"`. const unescaped = escaped.replace(/\\"/g, ""); expect(unescaped).not.toContain('"'); expect(escaped.startsWith('\\"')).toBe(true); }); }); describe("daysAgoIso — the 90-day scope window", () => { it("is exactly N days back from the supplied instant", () => { const now = new Date("2026-10-14T00:00:00.000Z"); expect(daysAgoIso(90, now)).toBe("2026-07-16T00:00:00.000Z"); expect(daysAgoIso(0, now)).toBe("2026-10-14T00:00:00.000Z"); }); it("defaults to the window constant the UI reports", () => { // 90 → 60 on 2026-10-15 at the operator's request. If this // assertion is failing after an intentional change, update the // number AND the comment above it — the value is duplicated in // quotes-search-service (private) and quotes-service (exported, // shared with the /quotes page and its archive), and the three // MUST move together or the page label desyncs from the filter. expect(UNIVERSAL_SEARCH_WINDOW_DAYS).toBe(60); }); }); describe("orderGroups — presentation ordering", () => { const group = ( label: string, total: number, status: UniversalGroup["status"] = "ok", ): UniversalGroup => ({ type: "order", label, hits: total > 0 ? [{ id: "1", title: "x", href: "/" }] : [], total, truncated: false, tookMs: 1, status, }); it("puts the source with the most matches first", () => { const ordered = orderGroups([group("A", 2), group("B", 50), group("C", 9)]); expect(ordered.map((g) => g.label)).toEqual(["B", "C", "A"]); }); it("sinks failed sources below healthy ones", () => { // A broken source must never bury a real answer at the bottom of a // page the operator has to scroll past. const ordered = orderGroups([ group("Broken", 999, "error"), group("Good", 1, "ok"), ]); expect(ordered.map((g) => g.label)).toEqual(["Good", "Broken"]); }); it("is stable and does not mutate the input", () => { const input = [group("A", 2), group("B", 50)]; const ordered = orderGroups(input); expect(input.map((g) => g.label)).toEqual(["A", "B"]); expect(ordered.map((g) => g.label)).toEqual(["B", "A"]); }); }); describe("the never-hide-a-cap invariant", () => { /** * Regression guard for the contract this module was built around. A * cap that renders as an empty list is indistinguishable from "no * results", which is exactly how the `/orders` 500-cap bug presented: * recent orders silently invisible, older ones fine, no error anywhere. * * The group type makes this structural rather than a convention — * `total` is required and independent of `hits.length`, so a caller * cannot represent "capped to nothing" without also being able to * report the true count. */ it("keeps total independent of the capped hits list", () => { const capped: UniversalGroup = { type: "order", label: "Orders", hits: [{ id: "1", title: "ORD-1", href: "/orders/1" }], total: 1240, truncated: true, tookMs: 12, status: "ok", }; expect(capped.hits).toHaveLength(1); expect(capped.total).toBe(1240); // The UI branches on this exact flag to render "showing 1 of 1240". expect(capped.truncated).toBe(true); }); it("a failed source reports status and error rather than an empty ok group", () => { const failed: UniversalGroup = { type: "quote", label: "Quotes", hits: [], total: 0, truncated: false, tookMs: 6000, status: "timeout", error: "timed out", }; // `status !== "ok"` is what the page renders as an explicit // "couldn't search quotes" row. expect(failed.status).not.toBe("ok"); expect(failed.error).toBeTruthy(); }); }); describe("buildOrderTimeline", () => { it("emits one Invoiced step per invoice, carrying its number", () => { // A partial pickup bills one order across two invoices; a single // "Invoiced" step would hide which number went out when. const events = buildOrderTimeline({ created: "2026-10-01 08:00:00.000Z", rep: "Tiaan" }, [ { customer_invoice_number: "INV1", invoiced_date: "2026-10-05" }, { customer_invoice_number: "INV1", invoiced_date: "2026-10-05" }, { customer_invoice_number: "INV2", invoiced_date: "2026-10-09", drv_collected_at: "2026-10-08" }, ]); expect(events.map((e) => [e.label, e.at, e.detail])).toEqual([ ["Order raised", "2026-10-01", "Rep: Tiaan"], ["Invoiced", "2026-10-05", "INV1 · 2 lines"], ["Collected", "2026-10-08", undefined], ["Invoiced", "2026-10-09", "INV2"], ]); }); it("omits steps that have no timestamp", () => { const events = buildOrderTimeline({ created: "2026-10-01" }, [{ item_status: "pending" }]); expect(events).toEqual([{ label: "Order raised", at: "2026-10-01", kind: "created" }]); }); }); describe("buildQuoteTimeline", () => { it("orders the email lifecycle and skips what didn't happen", () => { const events = buildQuoteTimeline({ created: "2026-10-01 07:00:00.000Z", email_delivered_at: "2026-10-02 09:00:00.000Z", pdf_generated_at: "2026-10-02 08:00:00.000Z", email_opened_at: "", }); expect(events.map((e) => e.kind)).toEqual(["created", "pdf", "sent"]); }); });