WFDASH-WEBAPP/src/lib/server/order-quote-link-service.test.ts

586 lines
21 KiB
TypeScript
Raw Normal View History

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>;
}
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
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) => {
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
const c = merged[name];
if (!c) throw new Error(`unexpected collection: ${name}`);
return c;
},
};
}
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
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({
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
items: [{ id: "q_old", quote_number: "Q-2024-0847", company: "co1", created: "2024-01-05T00:00:00.000Z" }],
});
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
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");
});
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
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: [] });
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
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");
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
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" }],
});
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
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 },
]),
},
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
[QUOTE_ITEMS]: { getFullList: quoteItemsGetFullList },
}),
);
const res = await suggestQuoteForOrder("ord1");
expect(quotesGetList).toHaveBeenCalledTimes(2);
expect(res.window).toBe("widened");
expect(res.best?.quoteId).toBe("q1");
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
// 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([]);
});
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
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({});
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
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();
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors Six review findings on suggestQuoteForOrder, all bite hardest once order intake and the backlog sweep start calling it: 1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded constant owned by the duplicate-quote scan. The matcher now passes its own `fields` (an added, defaulted parameter) instead of depending on that string never being trimmed out from under it — losing `description`/`selling_price` would have collapsed every confidence score toward zero with no exception and no failing test. 2. The candidate-quote query in fuzzyPass had no `fields`, so it pulled full quote records (email_body, ai_insights, migration_meta) for up to 200 candidates, twice per order when the window widens, to read five scalars. Added `fields: "id,quote_number,company,customer,created"`. 3. findExactMatch's bare `catch {}` swallowed everything, not just the "field doesn't exist on an older PB schema" case it claimed to guard (all four fields exist per pb_schema-2.json). An expired admin token or a PB blip during the sweep would silently fall through to the fuzzy path, and writeSuggestion would then persist quote_matching_attempted: true / quote_match_method: "no_match" - permanently hiding an order that had a valid citation from the sweep's retry filter. Narrowed to rethrow anything that isn't a ClientResponseError with status 400. 4. Exact-identifier lookups (quote_reference_text, rfq_number are customer free text) weren't scoped to the order's company, so a reference-number collision across two customers could return a confidence-1.0 suggestion pointing at the wrong company's quote. Added `company = "..."` to the filter when the order has one. 5. SuggestQuoteResult.window used the literal "90d", hardcoding FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant can't make the sweep's reporting lie. 6. order-quote-link-service.test.ts's makePb forced every test to redeclare the full collection map. Gave it a default map merged with a per-test override and renamed useP to mockAdminPb, since two more tasks add tests to this file. Adds tests for #1 (matcher's quote_items fields include description and selling_price), #2 (candidate query's fields), and #4 (a same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
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();
});
fix(orders): close cross-line quote_item collision, pin pairing's field projections pairOrderLines' no-overwrite guard only checked whether an order line's own row already carried a quote_item, not whether the quote line it was about to claim was already held by a different order line. A description drift (or a hand-confirmed link) on one line let a fresh confident match on another line silently double-assign the same quote_item, so two parts would render the same estimator's note and attachments — the exact failure this feature exists to prevent, reached from the other direction. Fixed by seeding a claimed-quote-item set from the fetched order rows before the write loop, and skipping any confident pair that targets an already-claimed quote line. unmatchedOrderItemIds now also excludes any line that already carries a link, even when this run's pairing pass didn't re-propose it. Also names the two field projections (PAIR_ORDER_ITEM_FIELDS, PAIR_QUOTE_ITEM_FIELDS) next to the existing MATCHER_QUOTE_ITEM_FIELDS constant, and adds a test that asserts on the actual `fields` string passed to each getFullList call. Previously nothing distinguished `fields: "id,description"` from `fields: "id,description,quote_item"` at the order-items query — a one-token trim silently disabled the no-overwrite guard (every row read as unlinked) with the full suite staying green, since the existing behavioral tests supply `quote_item` values directly in their fixtures rather than through the query. Verified by re-running that exact mutation against the new test: it fails, and neither pre-existing behavioral test catches it on its own.
2026-08-29 12:01:13 +00:00
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
});
});