import { describe, it, expect, beforeEach, vi } from "vitest"; const { store, pbMock, clearStore } = vi.hoisted(() => { type StoreMap = Map>; const store: Record = {}; let seq = 0; const nextId = () => `mock-${++seq}`; const getOrCreateMap = (name: string): StoreMap => { if (!store[name]) store[name] = new Map(); return store[name]; }; const where = (name: string, filter: string): Record[] => { const items = Array.from(getOrCreateMap(name).values()); if (!filter || !filter.trim()) return items; // Split on &&, then for each conjunct split on ||. Parens are // grouping-only and can be stripped — none of the filters used // here have nested groups mixing ANDs and ORs. const conjuncts = filter.split("&&").map((c) => c.trim()); return items.filter((item) => conjuncts.every((conjunct) => { const disjuncts = conjunct.split("||").map((d) => d.trim()); return disjuncts.some((d) => { const clause = d.replace(/^\(/, "").replace(/\)$/, "").trim(); const m = clause.match(/^(\w+)\s*(=|!=|~|>=?|<=?)\s*(.+)$/); if (!m) return true; const [, field, op, rawValue] = m; let value: string | number | boolean; if (rawValue.startsWith('"') && rawValue.endsWith('"')) { value = rawValue.slice(1, -1); } else if (rawValue === "true") { value = true; } else if (rawValue === "false") { value = false; } else { value = Number(rawValue); } const actual = item[field]; if (op === "=") return actual == value; if (op === "!=") return actual != value; if (op === "~") { return ( typeof actual === "string" && typeof value === "string" && actual.toLowerCase().includes(value.toLowerCase()) ); } if (op === ">") return Number(actual) > Number(value); if (op === "<") return Number(actual) < Number(value); return true; }); }), ); }; const pbMock = { collection: (name: string) => ({ async getList( _page = 1, perPage = 25, opts: { filter?: string; sort?: string; expand?: string } = {}, ) { let items = where(name, opts.filter || ""); if (opts.sort) { const desc = opts.sort.startsWith("-"); const field = desc ? opts.sort.slice(1) : opts.sort; items = [...items].sort((a, b) => { const av = a[field] as number | string | undefined; const bv = b[field] as number | string | undefined; if (av === bv) return 0; if (av === undefined) return 1; if (bv === undefined) return -1; const cmp = av < bv ? -1 : 1; return desc ? -cmp : cmp; }); } const sliced = items.slice(0, perPage); if (opts.expand) { const expandFields = opts.expand.split(",").map((f) => f.trim()); for (const item of sliced) { const expandObj: Record = {}; for (const field of expandFields) { const relatedId = item[field]; if (relatedId === undefined || relatedId === null) continue; for (const collName of Object.keys(store)) { const related = store[collName].get(String(relatedId)); if (related) { expandObj[field] = related; break; } } } item.expand = expandObj; } } return { items: sliced, totalItems: items.length, page: 1, perPage }; }, async getFullList(opts: { filter?: string } = {}) { return where(name, opts.filter || ""); }, async getOne(id: string) { const item = getOrCreateMap(name).get(id); if (!item) { const err = new Error("not found") as Error & { status: number }; err.status = 404; throw err; } return item; }, async create(data: Record) { const item = { id: nextId(), created: new Date().toISOString(), updated: new Date().toISOString(), ...data, }; getOrCreateMap(name).set(item.id, item); return item; }, async update(id: string, data: Record) { const map = getOrCreateMap(name); const existing = map.get(id); if (!existing) { const err = new Error("not found") as Error & { status: number }; err.status = 404; throw err; } for (const [key, value] of Object.entries(data)) { existing[key] = value; } existing.updated = new Date().toISOString(); return { ...existing }; }, async delete(id: string) { const map = getOrCreateMap(name); if (!map.has(id)) { const err = new Error("not found") as Error & { status: number }; err.status = 404; throw err; } map.delete(id); return { id }; }, }), }; const clearStore = () => { for (const key of Object.keys(store)) delete store[key]; seq = 0; }; return { store, pbMock, clearStore }; }); vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) })); vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), })); import { getStockItemSupplierHistory, searchStockItemsForQuickDraft, } from "./stock-quick-draft-service"; beforeEach(() => clearStore()); describe("searchStockItemsForQuickDraft", () => { it("returns active stock_items whose sku or name matches the query", async () => { await pbMock.collection("stock_items").create({ sku: "STK-CD-115", name: "115mm Cutting Disc", is_active: true, }); await pbMock.collection("stock_items").create({ sku: "STK-WR-6", name: "Wire rope 6mm", is_active: true, }); await pbMock.collection("stock_items").create({ sku: "STK-CD-230", name: "230mm Cutting Disc", is_active: true, }); const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10); expect(results).toHaveLength(2); expect(results.map((r) => r.sku).sort()).toEqual(["STK-CD-115", "STK-CD-230"]); }); it("excludes inactive items", async () => { await pbMock.collection("stock_items").create({ sku: "STK-CD-115", name: "115mm Cutting Disc", is_active: false, }); const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10); expect(results).toEqual([]); }); it("clamps limit to a max of 25", async () => { for (let i = 0; i < 30; i++) { await pbMock.collection("stock_items").create({ sku: `STK-X-${i}`, name: `Cutting disc variant ${i}`, is_active: true, }); } const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 100); expect(results).toHaveLength(25); }); it("returns narrow projection fields", async () => { const created = await pbMock.collection("stock_items").create({ sku: "STK-CD-115", name: "115mm Cutting Disc", description: "For angle grinder", unit: "ea", is_active: true, }); const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10); expect(results[0]).toEqual({ id: created.id, sku: "STK-CD-115", name: "115mm Cutting Disc", description: "For angle grinder", unit: "ea", }); }); }); describe("getStockItemSupplierHistory", () => { it("returns empty when the item has no purchase history", async () => { await pbMock.collection("stock_items").create({ id: "item-1", sku: "STK-CD-115", name: "115mm Cutting Disc", is_active: true, }); const history = await getStockItemSupplierHistory(pbMock as never, "item-1"); expect(history).toEqual([]); }); it("aggregates by supplier: lastCost, lastDate, timesBought", async () => { const supplierId = ( await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" }) ).id; await pbMock.collection("stock_items").create({ id: "item-1", sku: "STK-CD-115", name: "115mm Cutting Disc", is_active: true, }); const purchase1 = await pbMock.collection("stock_purchases").create({ supplier: supplierId, status: "received", purchase_number: "PO26070101STK", created: "2026-06-10T08:00:00.000Z", }); const purchase2 = await pbMock.collection("stock_purchases").create({ supplier: supplierId, status: "received", purchase_number: "PO26071401STK", created: "2026-07-14T08:00:00.000Z", }); await pbMock.collection("stock_purchase_items").create({ purchase: purchase1.id, stock_item: "item-1", cost_price: 18.5, quantity: 10, quantity_received: 10, }); await pbMock.collection("stock_purchase_items").create({ purchase: purchase2.id, stock_item: "item-1", cost_price: 19.0, quantity: 20, quantity_received: 20, }); const history = await getStockItemSupplierHistory(pbMock as never, "item-1"); expect(history).toHaveLength(1); expect(history[0]).toEqual({ supplierId, supplierName: "Steel & Pipe Co", lastCost: 19.0, lastDate: "2026-07-14T08:00:00.000Z", timesBought: 2, }); }); it("returns one entry per supplier, sorted by lastDate desc", async () => { const supA = ( await pbMock.collection("suppliers").create({ suppliername: "Supplier A" }) ).id; const supB = ( await pbMock.collection("suppliers").create({ suppliername: "Supplier B" }) ).id; await pbMock.collection("stock_items").create({ id: "item-1", sku: "STK-CD-115", is_active: true, }); const purchaseA = await pbMock.collection("stock_purchases").create({ supplier: supA, status: "received", purchase_number: "PO26070101STK", created: "2026-06-10T08:00:00.000Z", }); const purchaseB = await pbMock.collection("stock_purchases").create({ supplier: supB, status: "received", purchase_number: "PO26071501STK", created: "2026-07-15T08:00:00.000Z", }); await pbMock.collection("stock_purchase_items").create({ purchase: purchaseA.id, stock_item: "item-1", cost_price: 18.5, quantity_received: 10, }); await pbMock.collection("stock_purchase_items").create({ purchase: purchaseB.id, stock_item: "item-1", cost_price: 21.0, quantity_received: 5, }); const history = await getStockItemSupplierHistory(pbMock as never, "item-1"); expect(history).toHaveLength(2); expect(history[0].supplierId).toBe(supB); expect(history[1].supplierId).toBe(supA); }); it("only counts lines with quantity_received > 0", async () => { const supA = ( await pbMock.collection("suppliers").create({ suppliername: "Supplier A" }) ).id; await pbMock.collection("stock_items").create({ id: "item-1", sku: "STK-CD-115", is_active: true, }); const purchase = await pbMock.collection("stock_purchases").create({ supplier: supA, status: "draft", purchase_number: "PO26070101STK", created: "2026-07-01T08:00:00.000Z", }); await pbMock.collection("stock_purchase_items").create({ purchase: purchase.id, stock_item: "item-1", cost_price: 18.5, quantity_received: 0, }); const history = await getStockItemSupplierHistory(pbMock as never, "item-1"); expect(history).toEqual([]); }); });