WFDASH-WEBAPP/src/lib/server/item-history-service.test.ts
2026-07-29 17:43:59 +02:00

397 lines
16 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
normaliseSkuForHistory,
findMatchingItemsBySku,
findBestDescriptionMatch,
computeCheapest,
daysAgo,
isValidPrice,
} from "@/lib/server/item-history-service";
import { getItemHistory } from "@/lib/server/item-history-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
describe("SKU matching", () => {
it("normaliseSkuForHistory trims, uppercases, and strips leading zeros", () => {
expect(normaliseSkuForHistory(" drl-06mm ")).toBe("DRL-06MM");
expect(normaliseSkuForHistory("DRL-006MM")).toBe("DRL-6MM");
expect(normaliseSkuForHistory("DRL 6MM")).toBe("DRL-6MM");
expect(normaliseSkuForHistory("")).toBe("");
expect(normaliseSkuForHistory("000123")).toBe("123");
expect(normaliseSkuForHistory("DRL-00")).toBe("DRL-00");
expect(normaliseSkuForHistory("0DRL")).toBe("0DRL");
});
it("findMatchingItemsBySku returns exact + normalised matches", async () => {
const target = { sku: "DRL-6MM" };
const candidates = [
{ id: "a", sku: "DRL-6MM" }, // exact
{ id: "b", sku: "DRL-006MM" }, // normalised
{ id: "c", sku: "SCREW-6MM" }, // no match
];
const matched = await findMatchingItemsBySku(target, candidates, "DRL-6MM");
const ids = matched.map((m) => m.id).sort();
expect(ids).toEqual(["a", "b"]);
});
it("findMatchingItemsBySku returns empty when target sku is empty", async () => {
const matched = await findMatchingItemsBySku({ sku: "" }, [{ id: "a", sku: "X" }], "");
expect(matched).toEqual([]);
});
});
describe("description-fuzzy matching", () => {
it("returns null when no candidate clears the 0.6 threshold", () => {
const target = { id: "t", description: "Cable gland M20 brass" };
const candidates = [
{ id: "a", description: "Hammer 16oz claw steel" },
{ id: "b", description: "Paint brush 50mm" },
];
expect(findBestDescriptionMatch(target, candidates, 0.6)).toBeNull();
});
it("returns the best match with the right confidence", () => {
const target = { id: "t", description: "Cable gland M20 brass nickel plated" };
const candidates = [
// Same item, slightly different wording → high Jaccard
{ id: "a", description: "M20 brass cable gland nickel plated" },
// Tangentially related → low
{ id: "b", description: "Cable ties M20 black nylon" },
];
const match = findBestDescriptionMatch(target, candidates, 0.4);
expect(match).not.toBeNull();
expect(match!.item.id).toBe("a");
expect(match!.confidence).toBeGreaterThan(0.6);
});
it("fuzzy match returns a confidence from Jaccard (not artificially capped)", () => {
const target = { id: "t", description: "Cable gland M20 brass plated" };
const candidates = [
{ id: "a", description: "Cable gland brass M20 plated" },
];
const match = findBestDescriptionMatch(target, candidates, 0.0);
expect(match).not.toBeNull();
expect(match!.confidence).toBeGreaterThan(0);
expect(match!.confidence).toBeLessThanOrEqual(1);
});
it("returns a match with confidence 1.0 when threshold is 1.0 and descriptions are identical", () => {
const target = { id: "t", description: "Cable gland M20 brass" };
const candidates = [{ id: "a", description: "Cable gland M20 brass" }];
const match = findBestDescriptionMatch(target, candidates, 1.0);
expect(match).not.toBeNull();
expect(match!.confidence).toBe(1.0);
});
it("skips candidates with no description", () => {
const target = { id: "t", description: "Cable gland M20 brass" };
const candidates = [
{ id: "a" }, // no description field
{ id: "b", description: "Cable gland M20 brass" },
];
const match = findBestDescriptionMatch(target, candidates, 0.5);
expect(match).not.toBeNull();
expect(match!.item.id).toBe("b");
});
});
describe("aggregation helpers", () => {
it("computeCheapest picks the lowest last_cost", () => {
const suppliers = [
{ supplier_id: "a", supplier_name: "A", last_cost: 50, last_bought_at: "", times_bought: 1, last_order_id: "" },
{ supplier_id: "b", supplier_name: "B", last_cost: 42, last_bought_at: "", times_bought: 2, last_order_id: "" },
{ supplier_id: "c", supplier_name: "C", last_cost: 99, last_bought_at: "", times_bought: 5, last_order_id: "" },
];
expect(computeCheapest(suppliers)).toEqual({
supplier_id: "b",
supplier_name: "B",
last_cost: 42,
});
});
it("computeCheapest returns null for empty list", () => {
expect(computeCheapest([])).toBeNull();
});
it("computeCheapest ignores suppliers with zero cost", () => {
const suppliers = [
{ supplier_id: "a", supplier_name: "A", last_cost: 0, last_bought_at: "", times_bought: 1, last_order_id: "" },
{ supplier_id: "b", supplier_name: "B", last_cost: 30, last_bought_at: "", times_bought: 2, last_order_id: "" },
];
expect(computeCheapest(suppliers)?.supplier_id).toBe("b");
});
it("daysAgo returns 0 for now, larger for older", () => {
const now = new Date("2026-07-29T12:00:00Z");
expect(daysAgo("2026-07-29T12:00:00Z", now)).toBe(0);
expect(daysAgo("2026-07-22T12:00:00Z", now)).toBe(7);
expect(daysAgo("2026-06-29T12:00:00Z", now)).toBe(30);
});
it("isValidPrice rejects zero and negative", () => {
expect(isValidPrice(50)).toBe(true);
expect(isValidPrice(0)).toBe(false);
expect(isValidPrice(-1)).toBe(false);
expect(isValidPrice(NaN)).toBe(false);
});
it("computeCheapest returns null when every supplier has zero cost", () => {
const suppliers = [
{ supplier_id: "a", supplier_name: "A", last_cost: 0, last_bought_at: "", times_bought: 1, last_order_id: "" },
];
expect(computeCheapest(suppliers)).toBeNull();
});
it("isValidPrice accepts null and undefined safely", () => {
expect(isValidPrice(null)).toBe(false);
expect(isValidPrice(undefined)).toBe(false);
});
});
describe("getItemHistory service", () => {
it("returns a populated history when the SKU matches across collections", async () => {
const ordersBySupplier = {
items: [
{ id: "po1", supplier: "sup-a", cost_price: 42, updated: "2026-07-11T10:00:00Z" },
{ id: "po2", supplier: "sup-b", cost_price: 48, updated: "2026-07-24T10:00:00Z" },
{ id: "po3", supplier: "sup-a", cost_price: 0, updated: "2026-07-01T10:00:00Z" }, // zero-cost filtered
],
};
const otherQuotes = {
items: [
{
id: "qi1",
quote: "q1",
selling_price: 85,
created: "2026-07-11T10:00:00Z",
expand: {
quote: {
quote_number: "Q-26070112",
status: "approved",
customer_name: "MegaCorp",
company: "MegaCorp (Pty) Ltd",
},
},
},
],
};
const lastSold = { items: [] };
const stock = { items: [{ sku: "DRL-6MM", stock_qty: 240 }] };
const targetItem = {
id: "qi-target",
sku: "DRL-6MM",
description: "Drill bit 6mm HSS",
};
const suppliersById = {
items: [
{ id: "sup-a", suppliername: "Fastener Co" },
{ id: "sup-b", suppliername: "Tool Depot" },
],
};
const mockPb = {
collection: vi.fn((name: string) => {
if (name === "quote_items") {
return {
getOne: vi.fn().mockResolvedValue(targetItem),
getList: vi.fn().mockResolvedValue(otherQuotes),
};
}
if (name === "order_items") {
return {
getList: vi.fn().mockResolvedValue(ordersBySupplier),
};
}
if (name === "pos") {
return {
getList: vi.fn().mockResolvedValue(stock),
};
}
if (name === "suppliers") {
return {
getList: vi.fn().mockResolvedValue(suppliersById),
};
}
return { getList: vi.fn().mockResolvedValue({ items: [] }), getOne: vi.fn() };
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never);
const history = await getItemHistory("qi-target");
expect(history.match.match_kind).toBe("sku_exact");
expect(history.match.match_confidence).toBe(1);
expect(history.suppliers).toHaveLength(2);
expect(history.suppliers.map((s) => s.supplier_name).sort()).toEqual(["Fastener Co", "Tool Depot"]);
expect(history.cheapest).toEqual({ supplier_id: "sup-a", supplier_name: "Fastener Co", last_cost: 42 });
expect(history.other_quotes).toHaveLength(1);
expect(history.other_quotes[0].quote_number).toBe("Q-26070112");
expect(history.stock).toEqual({ in_stock: 240, status: "in_stock" });
expect(history.last_sold).toBeNull();
expect(history.generated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("returns empty match when SKU is empty and no fuzzy hit", async () => {
// Target has a description but no candidate clears the 0.6 Jaccard
// threshold, so the deterministic side produces nothing.
const targetItem = { id: "qi-x", sku: "", description: "Hammer 16oz claw" };
const getList = vi.fn(async (page: number, perPage: number, opts?: { filter?: string }) => {
// The fuzzy-candidates call is the one that filters by `description != ""`.
// The other 4 SKU queries are skipped because effectiveSku is empty.
if (opts?.filter?.includes('description != ""')) {
return {
items: [
{ id: "c1", sku: "PAINT-BRUSH", description: "Paint brush 50mm" },
{ id: "c2", sku: "SCREW-M6", description: "M6 cheese head screw" },
],
};
}
return { items: [] };
});
const getOne = vi.fn().mockResolvedValue(targetItem);
const mockPb = {
collection: vi.fn((name: string) => {
if (name === "quote_items") return { getOne, getList };
return { getList: vi.fn().mockResolvedValue({ items: [] }), getOne: vi.fn() };
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never);
const history = await getItemHistory("qi-x");
expect(history.match.match_kind).toBe("none");
expect(history.match.match_confidence).toBe(0);
expect(history.suppliers).toEqual([]);
expect(history.other_quotes).toEqual([]);
expect(history.cheapest).toBeNull();
// The fuzzy-candidates call was made (we should see description != "" filter).
const candidateCall = getList.mock.calls.find(
(call) => typeof call[2] === "object" && call[2]?.filter?.includes('description != ""')
);
expect(candidateCall).toBeDefined();
});
it("returns a populated history when SKU is empty but description matches via fuzzy", async () => {
// Target has a description and no SKU. A candidate's description is
// similar enough to clear the 0.6 threshold, so the matched SKU
// (ELEC-M20-BR) becomes the effective SKU for the 4 SKU queries.
const targetItem = {
id: "qi-1",
sku: "",
description: "Cable gland M20 brass",
};
const fuzzyCandidates = {
items: [
{ id: "qi-c1", sku: "PAINT-BRUSH", description: "Paint brush 50mm" },
{ id: "qi-c2", sku: "ELEC-M20-BR", description: "Cable gland M20 brass nickel plated" },
{ id: "qi-c3", sku: "SCREW-M6", description: "M6 cheese head screw" },
],
};
const ordersBySupplier = {
items: [
{ id: "po1", supplier: "sup-a", cost_price: 42, updated: "2026-07-11T10:00:00Z" },
],
};
const otherQuotes = { items: [] };
const lastSold = { items: [] };
const stock = { items: [] };
const suppliersById = {
items: [{ id: "sup-a", suppliername: "Fastener Co" }],
};
const getList = vi.fn(async (page: number, perPage: number, opts?: { filter?: string }) => {
if (opts?.filter?.includes('description != ""')) return fuzzyCandidates;
if (opts?.filter?.includes('ELEC-M20-BR')) {
// Whichever of the 4 SKU queries fired — return ordersBySupplier if
// it's the order_items one, otherwise the empty list. The
// service doesn't inspect the filter, so any non-empty result
// surfaces suppliers.
if (opts.filter.startsWith("sku = ")) return ordersBySupplier;
}
// The fuzzy-candidates path uses `description != ""` and returns
// fuzzyCandidates. Suppliers list is also a getList.
if (page === 1 && perPage === 200) return suppliersById;
return { items: [] };
});
const getOne = vi.fn().mockResolvedValue(targetItem);
const mockPb = {
collection: vi.fn((name: string) => {
if (name === "quote_items") return { getOne, getList };
return { getList, getOne };
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never);
const history = await getItemHistory("qi-1");
expect(history.match.match_kind).toBe("description_fuzzy");
expect(history.match.match_confidence).toBeGreaterThan(0.6);
expect(history.match.sku).toBe("ELEC-M20-BR");
expect(history.suppliers).toHaveLength(1);
expect(history.suppliers[0].supplier_name).toBe("Fastener Co");
// Verify at least one of the 4 SKU queries was made with the effective SKU.
const effectiveSkuCall = getList.mock.calls.find(
(call) => typeof call[2] === "object" && call[2]?.filter?.includes("ELEC-M20-BR")
);
expect(effectiveSkuCall).toBeDefined();
});
it("uses SKU when both SKU and description are present (SKU wins)", async () => {
// Target has both. The fuzzy-candidates query should NOT be made —
// SKU is present so we skip the fuzzy path entirely.
const targetItem = {
id: "qi-2",
sku: "DRL-6MM",
description: "Drill bit 6mm HSS",
};
const getList = vi.fn(async (_page: number, _perPage: number, opts?: { filter?: string }) => {
if (opts?.filter?.includes('description != ""')) {
throw new Error("fuzzy candidates should not be fetched when SKU is present");
}
return { items: [] };
});
const getOne = vi.fn().mockResolvedValue(targetItem);
const mockPb = {
collection: vi.fn((name: string) => {
if (name === "quote_items") return { getOne, getList };
return { getList, getOne };
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never);
const history = await getItemHistory("qi-2");
expect(history.match.match_kind).toBe("sku_exact");
expect(history.match.match_confidence).toBe(1);
expect(history.match.sku).toBe("DRL-6MM");
// No fuzzy-candidates call.
const candidateCall = getList.mock.calls.find(
(call) => typeof call[2] === "object" && call[2]?.filter?.includes('description != ""')
);
expect(candidateCall).toBeUndefined();
});
it("bails out when target has no SKU and no description", async () => {
// Both empty — early-return, no PB queries after the target load.
const targetItem = { id: "qi-empty", sku: "", description: "" };
const getList = vi.fn().mockResolvedValue({ items: [] });
const getOne = vi.fn().mockResolvedValue(targetItem);
const mockPb = {
collection: vi.fn((name: string) => {
if (name === "quote_items") return { getOne, getList };
return { getList, getOne };
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never);
const history = await getItemHistory("qi-empty");
expect(history.match.match_kind).toBe("none");
expect(history.suppliers).toEqual([]);
// The fuzzy-candidates call should NOT happen (no description to match on).
const candidateCall = getList.mock.calls.find(
(call) => typeof call[2] === "object" && call[2]?.filter?.includes('description != ""')
);
expect(candidateCall).toBeUndefined();
});
});