397 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
|