The matcher only runs on orders created from now on. This sweeps the historical backlog plus any order whose lines changed after creation. - sweepUnmatchedOrders in order-quote-link-service.ts walks quote_matching_attempted = false && quote = '' (the codebase's pattern for an empty relation, verified against orders-service.ts usage) in batches, projecting only id (the wider order fields are re-fetched inside suggestQuoteForOrder). - bucketAgeGaps exposes the histogram that lets FUZZY_WINDOW_DAYS be re-tuned from real data instead of the 90-day estimate. - POST /api/v1/orders/quote-match/sweep is the agent-auth surface with maxDuration=300 for the 500-order worst case. Tests cover the filter shape, the field projection (load-bearing — over-projection wastes wire bytes), the no-op on no-match path, the fault tolerance (one row throwing doesn't kill the batch), and all six histogram bucket boundaries including the ≤ N vs < N edges. Co-Authored-By: Claude <noreply@anthropic.com>
585 lines
21 KiB
TypeScript
585 lines
21 KiB
TypeScript
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<typeof vi.fn>;
|
||
getList?: ReturnType<typeof vi.fn>;
|
||
getFullList?: ReturnType<typeof vi.fn>;
|
||
update?: ReturnType<typeof vi.fn>;
|
||
}
|
||
|
||
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<string, CollectionMock> = {}) {
|
||
const defaults: Record<string, CollectionMock> = {
|
||
[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<string, CollectionMock> = { ...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<typeof makePb>) {
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(
|
||
pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||
);
|
||
}
|
||
|
||
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<string, string>)[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
|
||
});
|
||
});
|