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 { addQuickDraftLine, getStockItemSupplierHistory, listOpenDrafts, removeQuickDraftLine, 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([]); }); }); describe("addQuickDraftLine", () => { it("creates a new draft PO when none exists for the supplier", 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", }); const result = await addQuickDraftLine(pbMock as never, { supplierId, stockItemId: "item-1", quantity: 50, costPrice: 18.5, }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.createdDraft).toBe(true); expect(result.purchaseNumber).toMatch(/^PO\d{6}\d{2}STK$/); const purchases = await pbMock.collection("stock_purchases").getFullList(); expect(purchases).toHaveLength(1); expect(purchases[0].supplier).toBe(supplierId); expect(purchases[0].status).toBe("draft"); const lines = await pbMock.collection("stock_purchase_items").getFullList(); expect(lines).toHaveLength(1); expect(lines[0].stock_item).toBe("item-1"); expect(lines[0].quantity).toBe(50); expect(lines[0].cost_price).toBe(18.5); expect(lines[0].description).toBe("115mm Cutting Disc"); }); it("appends to an existing open draft for the supplier", 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", }); await pbMock.collection("stock_items").create({ id: "item-2", sku: "STK-WR-6", name: "Wire rope 6mm", }); // Existing draft for this supplier. await pbMock.collection("stock_purchases").create({ id: "purchase-existing", supplier: supplierId, status: "draft", purchase_number: "PO26070101STK", }); const result = await addQuickDraftLine(pbMock as never, { supplierId, stockItemId: "item-2", quantity: 20, costPrice: 145, }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.createdDraft).toBe(false); expect(result.purchaseId).toBe("purchase-existing"); const lines = await pbMock.collection("stock_purchase_items").getFullList(); expect(lines).toHaveLength(1); expect(lines[0].stock_item).toBe("item-2"); }); it("ignores drafts that are not in 'draft' status when finding an open one", 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", }); // Sent/received drafts must NOT be appended to — should create a new one. await pbMock.collection("stock_purchases").create({ id: "purchase-sent", supplier: supplierId, status: "sent", purchase_number: "PO26070101STK", }); const result = await addQuickDraftLine(pbMock as never, { supplierId, stockItemId: "item-1", quantity: 50, costPrice: 18.5, }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.createdDraft).toBe(true); expect(result.purchaseId).not.toBe("purchase-sent"); const purchases = await pbMock.collection("stock_purchases").getFullList(); expect(purchases).toHaveLength(2); }); it("creates a new stock_item when newStockItem is supplied, then adds the line", async () => { const supplierId = ( await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" }) ).id; const result = await addQuickDraftLine(pbMock as never, { supplierId, quantity: 10, costPrice: 5.5, newStockItem: { name: "Brand-new Widget", sku: "STK-BNW-1" }, }); expect(result.ok).toBe(true); if (!result.ok) return; const items = await pbMock.collection("stock_items").getFullList(); expect(items).toHaveLength(1); expect(items[0].name).toBe("Brand-new Widget"); expect(items[0].sku).toBe("STK-BNW-1"); expect(items[0].is_active).toBe(true); expect(items[0].current_qty).toBe(0); expect(items[0].supplier).toBe(supplierId); const lines = await pbMock.collection("stock_purchase_items").getFullList(); expect(lines).toHaveLength(1); expect(lines[0].stock_item).toBe(items[0].id); expect(lines[0].description).toBe("Brand-new Widget"); }); it("rejects non-positive quantities", async () => { const supplierId = ( await pbMock.collection("suppliers").create({ suppliername: "S" }) ).id; const result = await addQuickDraftLine(pbMock as never, { supplierId, stockItemId: "item-1", quantity: 0, costPrice: 10, }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("validation"); }); it("rejects negative cost", async () => { const supplierId = ( await pbMock.collection("suppliers").create({ suppliername: "S" }) ).id; const result = await addQuickDraftLine(pbMock as never, { supplierId, stockItemId: "item-1", quantity: 1, costPrice: -5, }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("validation"); }); it("rejects when both stockItemId and newStockItem are missing", async () => { const supplierId = ( await pbMock.collection("suppliers").create({ suppliername: "S" }) ).id; const result = await addQuickDraftLine(pbMock as never, { supplierId, quantity: 1, costPrice: 1, }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("validation"); }); it("returns supplier_not_found when the supplier id doesn't exist", async () => { const result = await addQuickDraftLine(pbMock as never, { supplierId: "does-not-exist", stockItemId: "item-1", quantity: 1, costPrice: 1, }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("supplier_not_found"); }); it("returns stock_item_not_found when the stock_item id doesn't exist", async () => { const supplierId = ( await pbMock.collection("suppliers").create({ suppliername: "S" }) ).id; const result = await addQuickDraftLine(pbMock as never, { supplierId, stockItemId: "does-not-exist", quantity: 1, costPrice: 1, }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("stock_item_not_found"); }); }); describe("removeQuickDraftLine", () => { it("deletes the line and returns ok", async () => { const supplierId = ( await pbMock.collection("suppliers").create({ suppliername: "S" }) ).id; await pbMock.collection("stock_items").create({ id: "item-1", sku: "STK-CD-115", name: "115mm Cutting Disc", }); const purchase = await pbMock.collection("stock_purchases").create({ supplier: supplierId, status: "draft", purchase_number: "PO26070101STK", }); const line = await pbMock.collection("stock_purchase_items").create({ purchase: purchase.id, stock_item: "item-1", description: "115mm Cutting Disc", quantity: 10, }); const result = await removeQuickDraftLine(pbMock as never, line.id); expect(result).toEqual({ ok: true }); const remaining = await pbMock.collection("stock_purchase_items").getFullList(); expect(remaining).toEqual([]); }); it("returns line_not_found when the line id doesn't exist", async () => { const result = await removeQuickDraftLine(pbMock as never, "does-not-exist"); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("line_not_found"); }); }); describe("listOpenDrafts", () => { it("returns an empty array when there are no draft stock_purchases", async () => { const drafts = await listOpenDrafts(pbMock as never); expect(drafts).toEqual([]); }); it("returns draft purchases with supplier name + lines denormalised, sorted by created desc", async () => { const supplierA = ( await pbMock.collection("suppliers").create({ name: "Steel & Pipe Co" }) ).id; const supplierB = ( await pbMock.collection("suppliers").create({ name: "Hardware Hub" }) ).id; // First (older) draft. const purchaseA = await pbMock.collection("stock_purchases").create({ supplier: supplierA, status: "draft", purchase_number: "PO26072801STK", created: "2026-07-28T08:00:00.000Z", }); // Second (newer) draft. const purchaseB = await pbMock.collection("stock_purchases").create({ supplier: supplierB, status: "draft", purchase_number: "PO26073001STK", created: "2026-07-30T09:00:00.000Z", }); // Two lines on purchaseA, one line on purchaseB. await pbMock.collection("stock_purchase_items").create({ purchase: purchaseA.id, description: "115mm Cutting Disc", quantity: 50, cost_price: 18.5, }); await pbMock.collection("stock_purchase_items").create({ purchase: purchaseA.id, description: "Wire rope 6mm", quantity: 20, cost_price: 145, }); await pbMock.collection("stock_purchase_items").create({ purchase: purchaseB.id, description: "Angle grinder", quantity: 1, cost_price: 1500, }); const drafts = await listOpenDrafts(pbMock as never); // Newest first. expect(drafts).toHaveLength(2); expect(drafts[0]).toMatchObject({ id: purchaseB.id, purchase_number: "PO26073001STK", supplier: supplierB, supplier_name: "Hardware Hub", created: "2026-07-30T09:00:00.000Z", }); expect(drafts[0].lines).toHaveLength(1); expect(drafts[0].lines[0]).toMatchObject({ description: "Angle grinder", quantity: 1, cost_price: 1500, }); expect(drafts[1]).toMatchObject({ id: purchaseA.id, purchase_number: "PO26072801STK", supplier: supplierA, supplier_name: "Steel & Pipe Co", created: "2026-07-28T08:00:00.000Z", }); expect(drafts[1].lines).toHaveLength(2); expect( drafts[1].lines.map((l) => l.description).sort(), ).toEqual(["115mm Cutting Disc", "Wire rope 6mm"]); }); it("ignores non-draft stock_purchases in the result", async () => { const supplierId = ( await pbMock.collection("suppliers").create({ name: "S" }) ).id; // Sent and received rows must NOT appear in the drafts list. await pbMock.collection("stock_purchases").create({ supplier: supplierId, status: "sent", purchase_number: "PO26072801STK", created: "2026-07-28T08:00:00.000Z", }); await pbMock.collection("stock_purchases").create({ supplier: supplierId, status: "received", purchase_number: "PO26072901STK", created: "2026-07-29T08:00:00.000Z", }); const drafts = await listOpenDrafts(pbMock as never); expect(drafts).toEqual([]); }); it("returns an empty lines array for a draft with no child items", async () => { const supplierId = ( await pbMock.collection("suppliers").create({ name: "Hardware Hub" }) ).id; // A brand-new draft with no lines yet (possible if the row was // created by the agent, not by the modal — or after a mass delete). const purchase = await pbMock.collection("stock_purchases").create({ supplier: supplierId, status: "draft", purchase_number: "PO26073001STK", created: "2026-07-30T09:00:00.000Z", }); const drafts = await listOpenDrafts(pbMock as never); expect(drafts).toHaveLength(1); expect(drafts[0].id).toBe(purchase.id); expect(drafts[0].supplier_name).toBe("Hardware Hub"); expect(drafts[0].lines).toEqual([]); }); });