WFDASH-WEBAPP/src/lib/server/price-list-service.test.ts
Dave 47d58db0c9 feat(mcp): add price_list.add_items tool for agent-driven catalogue upsert
Add a new MCP tool that lets the agent read a supplier PDF, quote PDF,
or pasted supplier email with its native vision, extract the per-line
fields, and commit them to the price_list catalogue in one round-trip.

Why: the only pre-existing path was operator upload via /pricelist ->
n8n ingest -> raw_price_list -> AI extraction -> price_list. That flow
is async, human-in-the-loop, and gates on n8n being healthy. This
tool gives the agent a synchronous shortcut for ad-hoc catalogue
updates -- paste a PDF, get the catalogue updated, no async hops.

The agent does the OCR; the tool does the commit. No LLM work moves
into the MCP server.

Behaviour:

- Dedupe by (sku, supplier) -- exact match. Different suppliers with
  the same SKU get separate rows.
- Upsert policy: cost_price overwrites (latest price wins).
  Description, selling_price, unit, category, subcategory, brand,
  markup, stock_qty, search_text, sku_normalised also patched in
  place on update.
- sku_normalised auto-computed (uppercase, strip non-alphanumeric --
  same canonical form price-list-match.ts uses).
- search_text auto-computed (lowercased, space-separated concat of
  sku + description + brand + supplier + category + subcategory +
  synonyms).
- Skipped items returned with per-item reason (missing_sku,
  missing_supplier, invalid_cost_price, pb_write_failed). Never
  silent.
- Bulk cap 500 items per call; larger lists rejected with
  too_many_items reason.
- source_file_id + source_label supported so new rows can be
  traced back to a raw_price_list upload (optional).
- Supplier stored verbatim on price_list.supplier (free-text field,
  no FK resolution -- same as the live schema).

Files:

- src/lib/server/price-list-service.ts (new) -- addPriceListItems +
  buildSearchText + computeSellingPrice helpers
- src/lib/server/price-list-service.test.ts (new) -- 17 tests
  covering validation, upsert, dedupe filter shape, normalisation
- src/app/api/mcp/route.ts -- new tool definition + dispatch block
- src/app/api/mcp/route.test.ts -- 2 dispatch tests + tool-list
  assertion update
- .agents/skills/wfdash-giellie/SKILL.md -- new section "8. Price-
  list catalogue" documenting input/output shapes + failure modes

Tests:

- 134/134 pass across the affected files
- 1775/1778 in the full suite (3 pre-existing failures unrelated
  to this change: pocketbase-admin.test.ts PB mock mismatch,
  quotes/items/[id]/attachments/upload/route.test.ts timeout,
  supplier-form.test.tsx race -- all reproducible on main)
- npm run build clean
2026-08-10 12:45:05 +02:00

393 lines
No EOL
11 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
const { createMock, updateMock, getListMock } = vi.hoisted(() => {
return {
createMock: vi.fn(),
updateMock: vi.fn(),
getListMock: vi.fn(),
};
});
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: (name: string) => {
if (name !== "price_list") {
throw new Error(`Unexpected collection in test: ${name}`);
}
return {
getList: getListMock,
create: createMock,
update: updateMock,
};
},
}),
}));
import {
addPriceListItems,
buildSearchText,
computeSellingPrice,
MAX_ADD_ITEMS,
type AddPriceListItemsInput,
} from "@/lib/server/price-list-service";
describe("buildSearchText", () => {
it("concatenates every searchable field lowercased and space-separated", () => {
const out = buildSearchText({
sku: "BRG-6205",
description: "Deep groove ball bearing 6205",
brand: "SKF",
supplier: "Afrisan",
category: "Bearings",
subcategory: "Ball bearings",
});
expect(out).toBe(
"brg-6205 deep groove ball bearing 6205 skf afrisan bearings ball bearings",
);
});
it("includes synonyms from specifications when present", () => {
const out = buildSearchText({
sku: "BRG-6205",
description: "Deep groove ball bearing 6205",
brand: "SKF",
supplier: "Afrisan",
specifications: { synonyms: ["6205", "DDU", "CM"] },
});
expect(out).toContain("6205");
expect(out).toContain("ddu");
expect(out).toContain("cm");
});
it("handles empty fields without crashing", () => {
expect(
buildSearchText({ sku: "", description: "", supplier: "" }),
).toBe("");
});
});
describe("computeSellingPrice", () => {
it("applies the markup percentage", () => {
expect(computeSellingPrice(100, 25)).toBe(125);
expect(computeSellingPrice(89.5, 30)).toBeCloseTo(116.35, 2);
});
it("rounds to 2 decimal places", () => {
expect(computeSellingPrice(99.99, 17.5)).toBeCloseTo(117.49, 2);
});
it("returns the cost price unchanged when markup is missing or non-positive", () => {
expect(computeSellingPrice(100, undefined)).toBe(100);
expect(computeSellingPrice(100, 0)).toBe(100);
expect(computeSellingPrice(100, -5)).toBe(100);
});
it("returns 0 for negative cost prices (caller should have rejected upstream)", () => {
expect(computeSellingPrice(-1, 25)).toBe(0);
});
});
describe("addPriceListItems — input validation", () => {
beforeEach(() => {
createMock.mockReset();
updateMock.mockReset();
getListMock.mockReset();
});
it("returns no_items when items array is empty", async () => {
const result = await addPriceListItems({ items: [] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("no_items");
});
it("returns too_many_items when batch exceeds the cap", async () => {
const items = Array.from({ length: MAX_ADD_ITEMS + 1 }, (_, i) => ({
sku: `BRG-${i}`,
description: "x",
cost_price: 1,
supplier_name: "Acme",
}));
const result = await addPriceListItems({ items });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("too_many_items");
});
});
describe("addPriceListItems — per-item validation", () => {
beforeEach(() => {
createMock.mockReset();
updateMock.mockReset();
getListMock.mockReset();
// No existing rows by default
getListMock.mockResolvedValue({ items: [] });
});
it("skips items missing sku", async () => {
const result = await addPriceListItems({
items: [
{
sku: "",
description: "Bolt",
cost_price: 5,
supplier_name: "Acme",
},
],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.skipped).toEqual([
{ index: 0, sku: "", reason: "missing_sku" },
]);
expect(result.added).toBe(0);
expect(createMock).not.toHaveBeenCalled();
}
});
it("skips items missing supplier_name", async () => {
const result = await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 5,
supplier_name: "",
},
],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.skipped[0]?.reason).toBe("missing_supplier");
}
});
it("skips items with non-positive cost_price", async () => {
const result = await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 0,
supplier_name: "Acme",
},
{
sku: "BRG-6206",
description: "Bearing",
cost_price: -10,
supplier_name: "Acme",
},
],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.skipped.every((s) => s.reason === "invalid_cost_price")).toBe(true);
expect(result.skipped.length).toBe(2);
}
});
it("processes a mix of valid and invalid items, returning only the valid ones as ids", async () => {
let createdCount = 0;
createMock.mockImplementation(async () => {
createdCount += 1;
return { id: `pl_${createdCount}` };
});
// For the final stats query, mark all rows as fresh
getListMock.mockImplementation(async (_, __, opts) => {
const filter = opts?.filter ?? "";
if (filter.includes("sku =") || filter.includes("id =")) {
// First call: dedupe lookup. Second: stats lookup.
if (filter.startsWith("id =") || filter.includes(" || ")) {
// Stats query — return everything as fresh
return {
items: [{ id: "pl_1", created: "2026-08-09T00:00:00.000Z", updated: "2026-08-09T00:00:00.000Z" }],
};
}
return { items: [] };
}
return { items: [] };
});
const result = await addPriceListItems({
items: [
{ sku: "BRG-6205", description: "Bearing", cost_price: 89.5, supplier_name: "Acme" },
{ sku: "", description: "x", cost_price: 1, supplier_name: "Acme" }, // skipped
{ sku: "BRG-6206", description: "Bearing", cost_price: 95, supplier_name: "Acme" },
],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(createMock).toHaveBeenCalledTimes(2);
expect(result.skipped.length).toBe(1);
expect(result.ids.length).toBe(2);
}
});
});
describe("addPriceListItems — upsert behaviour", () => {
beforeEach(() => {
createMock.mockReset();
updateMock.mockReset();
getListMock.mockReset();
});
it("inserts a new row when (sku, supplier) does not exist", async () => {
let listCall = 0;
getListMock.mockImplementation(async () => {
listCall += 1;
if (listCall === 1) return { items: [] }; // dedupe lookup
// Stats query — return the fresh row
return {
items: [
{
id: "pl_new",
created: "2026-08-09T00:00:00.000Z",
updated: "2026-08-09T00:00:00.000Z",
},
],
};
});
createMock.mockResolvedValue({ id: "pl_new" });
const result = await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Deep groove ball bearing",
cost_price: 89.5,
supplier_name: "Afrisan",
},
],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(createMock).toHaveBeenCalledTimes(1);
expect(updateMock).not.toHaveBeenCalled();
expect(result.added).toBe(1);
expect(result.updated).toBe(0);
expect(result.ids).toEqual(["pl_new"]);
// Inspect the row we wrote — confirm normalisation + search_text
const written = createMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(written.sku).toBe("BRG-6205");
expect(written.sku_normalised).toBe("BRG6205");
expect(written.supplier).toBe("Afrisan");
expect(written.search_text).toContain("brg-6205");
expect(written.search_text).toContain("deep groove ball bearing");
expect(written.search_text).toContain("afrisan");
}
});
it("updates the existing row when (sku, supplier) already exists", async () => {
let listCall = 0;
getListMock.mockImplementation(async () => {
listCall += 1;
if (listCall === 1) {
// dedupe lookup — row exists
return { items: [{ id: "pl_existing" }] };
}
// Stats query — return updated row (created != updated)
return {
items: [
{
id: "pl_existing",
created: "2026-08-01T00:00:00.000Z",
updated: "2026-08-09T00:00:00.000Z",
},
],
};
});
updateMock.mockResolvedValue({ id: "pl_existing" });
const result = await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Updated description",
cost_price: 99,
supplier_name: "Afrisan",
},
],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(updateMock).toHaveBeenCalledTimes(1);
expect(updateMock).toHaveBeenCalledWith("pl_existing", expect.objectContaining({
sku: "BRG-6205",
cost_price: 99,
supplier: "Afrisan",
}));
expect(createMock).not.toHaveBeenCalled();
expect(result.added).toBe(0);
expect(result.updated).toBe(1);
}
});
it("supports the source_file_id and source_label fields", async () => {
let listCall = 0;
getListMock.mockImplementation(async () => {
listCall += 1;
if (listCall === 1) return { items: [] };
return {
items: [
{
id: "pl_new",
created: "2026-08-09T00:00:00.000Z",
updated: "2026-08-09T00:00:00.000Z",
},
],
};
});
createMock.mockResolvedValue({ id: "pl_new" });
await addPriceListItems({
source_file_id: "raw_abc",
source_label: "AFRIQ-INA64721",
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 89.5,
supplier_name: "Afrisan",
},
],
});
const written = createMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(written.source_file_id).toBe("raw_abc");
expect(written.source_file).toBe("AFRIQ-INA64721");
});
it("uses the same (sku, supplier) filter for dedupe — different suppliers don't collide", async () => {
let listCall = 0;
getListMock.mockImplementation(async () => {
listCall += 1;
if (listCall === 1) return { items: [] }; // dedupe: no row for (BRG-6205, Orapi)
// Stats: brand-new row
return {
items: [
{
id: "pl_orapi",
created: "2026-08-09T00:00:00.000Z",
updated: "2026-08-09T00:00:00.000Z",
},
],
};
});
createMock.mockResolvedValue({ id: "pl_orapi" });
await addPriceListItems({
items: [
{
sku: "BRG-6205",
description: "Bearing",
cost_price: 95,
supplier_name: "Orapi",
},
],
});
// The dedupe lookup filter must include BOTH sku AND supplier
const dedupeFilter = (getListMock.mock.calls[0]?.[2] as { filter?: string })?.filter ?? "";
expect(dedupeFilter).toContain("sku = \"BRG-6205\"");
expect(dedupeFilter).toContain("supplier = \"Orapi\"");
});
});