WFDASH-WEBAPP/src/lib/server/order-assistant-service.test.ts
Dave 00d09b3b12 feat(orders): show AI quote match on the order page
Task 8 of the AI order assistant plan.

- new AiQuoteMatchPanel: "Linked by AI" / "AI suggests Q…", the
  reason, the doubts and the line-pair table (order line -> quote line
  with same_item)
- the quote match card gains a "Match with AI" button in every state,
  posts to /api/v1/orders/{id}/assistant/run with the PB token, toasts
  the 409 message verbatim, then reloads the order
- new card state: the AI suggested a quote -> the AI panel + Accept
  (links the AI's quote) / Match with AI / Dismiss
- the AI suggestion marks the order as quote_matching_attempted so the
  deterministic sweep cannot overwrite it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:13:28 +02:00

514 lines
15 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
vi.mock("@/lib/server/ai-settings-service", () => ({
getActiveAiConfig: vi.fn(),
getAiSettings: vi.fn(),
recordOrderAssistantRun: vi.fn(),
clearAiSettingsCache: vi.fn(),
AiDisabledError: class AiDisabledError extends Error {
readonly code = "ai_disabled";
constructor(message: string) {
super(message);
this.name = "AiDisabledError";
}
},
}));
vi.mock("@/lib/server/quotes-service", () => ({
fetchQuoteItemsForQuotesAdmin: vi.fn(),
}));
import {
matchOrder,
runOrderAssistantTick,
resetAiQuoteMatchFieldCheck,
} from "@/lib/server/order-assistant-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getAiSettings, recordOrderAssistantRun } from "@/lib/server/ai-settings-service";
import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
const ORDERS = "orders";
const ORDER_ITEMS = "order_items";
const QUOTES = "quotes";
const SUPPLIERS = "suppliers";
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",
order_number: "ORD-2026-10-001",
created: "2026-10-20T10:00:00.000Z",
company: "co1",
customer: "cust1",
quote: "",
ai_quote_match: null,
};
const ORDER_LINES = [
{
id: "ol1",
order: "ord1",
description: "M16 bolt 60mm",
po_description: "BOLT M16X60",
quantity: 100,
item_status: "pending",
selling_price: 350,
sku: "",
quote_item: "",
},
{
id: "ol2",
order: "ord1",
description: "M16 nut",
po_description: "",
quantity: 100,
item_status: "pending",
selling_price: 50,
sku: "",
quote_item: "",
},
];
const QUOTE_ITEMS = [
{
id: "ql1",
quote: "q1",
description: "M16 x 60 bolt",
quantity: 200,
selling_price: 340,
sku: "56020510",
supplier: "sup1",
},
{
id: "ql2",
quote: "q1",
description: "M16 nut",
quantity: 200,
selling_price: 45,
sku: "",
supplier: "sup1",
},
];
/**
* The makePb pattern from `order-quote-link-service.test.ts`: sensible
* defaults for every collection, merged per METHOD with the per-test
* overrides so a test only has to declare what it cares about.
*/
function makePb(overrides: Record<string, CollectionMock> = {}) {
const defaults: Record<string, CollectionMock> = {
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER }),
getList: vi.fn().mockResolvedValue({ items: [] }),
getFullList: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue(ORDER_LINES.map((l) => ({ ...l }))),
update: vi.fn().mockResolvedValue({}),
},
[QUOTES]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "q1", quote_number: "Q-2026-10-014", company: "co1", created: "2026-10-01T00:00:00.000Z" },
]),
},
[SUPPLIERS]: {
getFullList: vi.fn().mockResolvedValue([{ id: "sup1", suppliername: "Afrisam" }]),
},
};
const merged: Record<string, CollectionMock> = {};
for (const name of new Set([...Object.keys(defaults), ...Object.keys(overrides)])) {
merged[name] = { ...(defaults[name] ?? {}), ...(overrides[name] ?? {}) };
}
return {
autoCancellation: vi.fn(),
collections: {
getOne: vi.fn().mockResolvedValue({
fields: [{ name: "ai_quote_match" }, { name: "quote" }],
}),
},
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>>,
);
}
const SUGGESTION = {
orderId: "ord1",
best: {
quoteId: "q1",
quoteNumber: "Q-2026-10-014",
quoteCreated: "2026-10-01T00:00:00.000Z",
confidence: 0.8,
method: "company_customer_desc_price" as const,
},
alternatives: [],
method: "company_customer_desc_price" as const,
window: "initial" as const,
ageGapDays: 3,
};
function aiDecision(over: Record<string, unknown> = {}) {
return {
decision: {
quote_id: "q1",
confidence: "high" as const,
reason: "same items",
doubts: [],
lines: [
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure" as const, reason: "same" },
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure" as const, reason: "same" },
],
...over,
},
model: "test-model",
};
}
function baseSettings(over: Record<string, unknown> = {}) {
return {
enabled: true,
order_assistant_enabled: true,
order_assistant_interval_minutes: 10,
order_assistant_auto_link: true,
order_assistant_fill_lines: true,
order_assistant_prompt: "",
order_assistant_last_run_at: "",
order_assistant_last_run_summary: null,
...over,
};
}
beforeEach(() => {
vi.clearAllMocks();
resetAiQuoteMatchFieldCheck();
vi.mocked(getAiSettings).mockResolvedValue(baseSettings() as never);
vi.mocked(recordOrderAssistantRun).mockResolvedValue(undefined);
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue(QUOTE_ITEMS as never);
});
describe("matchOrder", () => {
it("auto-links when the AI is sure and writes the pairing onto empty lines", async () => {
const pb = makePb();
mockAdminPb(pb);
const link = vi.fn().mockResolvedValue({ order: {}, previous: { quote: "" } });
const match = vi.fn().mockResolvedValue(aiDecision());
const suggest = vi.fn().mockResolvedValue(SUGGESTION);
const res = await matchOrder("ord1", { deps: { link, match, suggest } });
expect(res.outcome).toBe("linked");
expect(res.quoteId).toBe("q1");
expect(link).toHaveBeenCalledTimes(1);
expect(link.mock.calls[0][0]).toMatchObject({ orderId: "ord1", quoteId: "q1" });
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
expect(orderUpdate).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({ quote_match_method: "ai_auto" }),
);
// The AI pairing is written onto the lines.
const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
expect(itemUpdate).toHaveBeenCalledWith("ol1", { quote_item: "ql1" });
expect(itemUpdate).toHaveBeenCalledWith("ol2", { quote_item: "ql2" });
});
it("never overwrites a quote_item a person set", async () => {
const pb = makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{ ...ORDER_LINES[0], quote_item: "ql-person" },
{ ...ORDER_LINES[1] },
]),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const link = vi.fn().mockResolvedValue({ order: {}, previous: { quote: "" } });
await matchOrder("ord1", {
deps: {
link,
match: vi.fn().mockResolvedValue(aiDecision()),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
expect(itemUpdate).not.toHaveBeenCalledWith("ol1", expect.anything());
expect(itemUpdate).toHaveBeenCalledWith("ol2", { quote_item: "ql2" });
});
it("falls back to a suggestion when the link is refused with 409", async () => {
const pb = makePb();
mockAdminPb(pb);
const err = new Error("locked");
(err as Error & { status?: number }).status = 409;
const link = vi.fn().mockRejectedValue(err);
const res = await matchOrder("ord1", {
deps: {
link,
match: vi.fn().mockResolvedValue(aiDecision()),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
expect(res.outcome).toBe("suggested");
expect(res.quoteId).toBe("q1");
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
expect(orderUpdate).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({
potential_quote: "q1",
potential_quote_confidence: 0.9,
quote_match_method: "ai_suggest",
}),
);
});
it("marks the order as attempted so the sweep leaves the suggestion alone", async () => {
const pb = makePb();
mockAdminPb(pb);
await matchOrder("ord1", {
deps: {
link: vi.fn(),
match: vi.fn().mockResolvedValue(aiDecision({ confidence: "medium" })),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
expect(orderUpdate).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({ quote_matching_attempted: true }),
);
});
it("suggests (and does not link) on medium confidence", async () => { const pb = makePb();
mockAdminPb(pb);
const link = vi.fn();
const res = await matchOrder("ord1", {
deps: {
link,
match: vi.fn().mockResolvedValue(aiDecision({ confidence: "medium" })),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
expect(res.outcome).toBe("suggested");
expect(link).not.toHaveBeenCalled();
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
expect(orderUpdate).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({ potential_quote_confidence: 0.7, quote_match_method: "ai_suggest" }),
);
});
it("suggests (and does not link) when auto-link is switched off", async () => {
vi.mocked(getAiSettings).mockResolvedValue(
baseSettings({ order_assistant_auto_link: false }) as never,
);
const pb = makePb();
mockAdminPb(pb);
const link = vi.fn();
const res = await matchOrder("ord1", {
deps: {
link,
match: vi.fn().mockResolvedValue(aiDecision()),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
expect(res.outcome).toBe("suggested");
expect(link).not.toHaveBeenCalled();
});
it("does not call the AI when the shortlist is empty", async () => {
const pb = makePb();
mockAdminPb(pb);
const match = vi.fn();
const res = await matchOrder("ord1", {
deps: {
link: vi.fn(),
match,
suggest: vi.fn().mockResolvedValue({
...SUGGESTION,
best: null,
alternatives: [],
}),
},
});
expect(res.outcome).toBe("no_match");
expect(match).not.toHaveBeenCalled();
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
expect(orderUpdate).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({ quote_match_method: "ai_no_match" }),
);
});
it("records an AI failure on the order and does not throw", async () => {
const pb = makePb();
mockAdminPb(pb);
const res = await matchOrder("ord1", {
deps: {
link: vi.fn(),
match: vi.fn().mockRejectedValue(new Error("AI exploded")),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
expect(res.outcome).toBe("error");
expect(res.error).toBe("AI exploded");
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
const patch = orderUpdate.mock.calls[0][1] as { ai_quote_match: { status: string; error: string } };
expect(patch.ai_quote_match.status).toBe("error");
expect(patch.ai_quote_match.error).toBe("AI exploded");
});
it("skips a placeholder-only order and writes nothing", async () => {
const pb = makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{ ...ORDER_LINES[0], description: "See attached" },
]),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const match = vi.fn();
const res = await matchOrder("ord1", {
deps: { link: vi.fn(), match, suggest: vi.fn().mockResolvedValue(SUGGESTION) },
});
expect(res.outcome).toBe("skipped");
expect(match).not.toHaveBeenCalled();
expect(pb.collection(ORDERS).update).not.toHaveBeenCalled();
expect(pb.collection(ORDER_ITEMS).update).not.toHaveBeenCalled();
});
it("skips an order that already has a quote", async () => {
const pb = makePb({
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "q-other" }),
getList: vi.fn().mockResolvedValue({ items: [] }),
getFullList: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const res = await matchOrder("ord1", {
deps: {
link: vi.fn(),
match: vi.fn(),
suggest: vi.fn(),
},
});
expect(res.outcome).toBe("skipped");
expect(pb.collection(ORDERS).update).not.toHaveBeenCalled();
});
it("reports that the migration has not run yet", async () => {
const pb = makePb();
pb.collections.getOne = vi.fn().mockResolvedValue({ fields: [{ name: "quote" }] });
mockAdminPb(pb);
const res = await matchOrder("ord1", { deps: {} });
expect(res.outcome).toBe("error");
expect(res.error).toContain("pb:migrate:order-assistant");
});
});
describe("runOrderAssistantTick", () => {
it("returns disabled when the order assistant is off", async () => {
vi.mocked(getAiSettings).mockResolvedValue(
baseSettings({ order_assistant_enabled: false }) as never,
);
const res = await runOrderAssistantTick();
expect(res).toEqual({ ran: false, reason: "disabled" });
});
it("returns disabled when the global AI switch is off", async () => {
vi.mocked(getAiSettings).mockResolvedValue(baseSettings({ enabled: false }) as never);
const res = await runOrderAssistantTick();
expect(res).toEqual({ ran: false, reason: "disabled" });
});
it("returns not_due when the interval has not elapsed", async () => {
vi.mocked(getAiSettings).mockResolvedValue(
baseSettings({ order_assistant_last_run_at: new Date().toISOString() }) as never,
);
const res = await runOrderAssistantTick();
expect(res).toEqual({ ran: false, reason: "not_due" });
});
it("stops after 10 orders", async () => {
const pb = makePb({
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER }),
// Deliberately ignores perPage — the tick's own cap must stop it.
getList: vi.fn().mockResolvedValue({
items: Array.from({ length: 25 }, (_, i) => ({ ...ORDER, id: `ord${i}` })),
}),
getFullList: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
});
mockAdminPb(pb);
const res = await runOrderAssistantTick({
suggest: vi.fn().mockResolvedValue(SUGGESTION),
match: vi.fn().mockResolvedValue(aiDecision()),
link: vi.fn(),
});
expect(res.ran).toBe(true);
expect(res.summary?.orders_seen).toBe(10);
});
it("writes a summary with the run stamp", async () => {
const pb = makePb();
mockAdminPb(pb);
const res = await runOrderAssistantTick({
suggest: vi.fn().mockResolvedValue(SUGGESTION),
match: vi.fn().mockResolvedValue(aiDecision()),
link: vi.fn(),
});
expect(res.ran).toBe(true);
expect(recordOrderAssistantRun).toHaveBeenCalledWith({ last_run_at: expect.any(String) });
expect(recordOrderAssistantRun).toHaveBeenCalledWith({
summary: expect.objectContaining({ orders_seen: expect.any(Number) }),
});
});
});