- Orders now match on customer invoice numbers and delivery-note numbers carried on their lines (tagged "invoice" / "delivery note"). - New Delivery Notes source: DN number, customer PO, customer and company names; preview pulls lines and invoices for visible hits only. - Quick-overview dialog moved to search-preview.tsx and redesigned: type-tinted header, status pills, stat tiles, invoice chips with QuickBooks links, line list, icon timeline with dates and gaps. Fixes the dialog being pinned at 24rem by the primitive's sm:max-w-sm. - Result list: per-type colour (icon tile, accent border, badge, row hover), status pills per row, and a jump bar; mobile-tightened. - Stock-order preview now fetches ordered_value/created (Value row and age chip were always empty); supplier names keep their original case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
9.1 KiB
TypeScript
251 lines
9.1 KiB
TypeScript
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"]);
|
|
});
|
|
});
|