diff --git a/src/lib/server/order-item-context-service.test.ts b/src/lib/server/order-item-context-service.test.ts new file mode 100644 index 0000000..4ebca20 --- /dev/null +++ b/src/lib/server/order-item-context-service.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getOrderItemContext } from "@/lib/server/order-item-context-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(), +})); + +interface CollectionMock { + getOne: ReturnType; + getList: ReturnType; +} + +function makePb(collections: Record) { + return { + collection: (name: string) => { + const c = collections[name]; + if (!c) throw new Error(`unexpected collection: ${name}`); + return c; + }, + }; +} + +const ORD = "order_items"; +const Q = "quote_items"; +const STK = "stock_items"; +const SUP = "suppliers"; + +describe("getOrderItemContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns all-null context when the target has no SKU and no description", async () => { + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue({ id: "x", sku: "", description: "" }), getList: vi.fn() }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.last_quote).toBeNull(); + expect(ctx.last_order).toBeNull(); + expect(ctx.last_invoiced).toBeNull(); + expect(ctx.stock).toBeNull(); + expect(ctx.suppliers).toEqual([]); + expect(ctx.cheapest).toBeNull(); + expect(ctx.match).toEqual({ sku: "", match_kind: "none", match_confidence: 0 }); + }); + + it("populates last_quote from the SKU-based quote_items join", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill 6mm", quote_item: "qi0" }; + const quoteList = { + items: [{ + id: "qi1", + quote: "q1", + cost_price: 100, + selling_price: 150, + created: "2026-08-01T10:00:00Z", + expand: { quote: { quote_number: "Q-26080101", status: "approved", customer_name: "Acme", company: "Acme Ltd" } }, + }], + }; + const emptyList = { items: [] }; + const supplierList = { items: [{ id: "sup1", suppliername: "Fastener Co" }] }; + + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(quoteList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.match).toEqual({ sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 }); + expect(ctx.last_quote?.quote_number).toBe("Q-26080101"); + expect(ctx.last_quote?.cost_price).toBe(100); + expect(ctx.last_quote?.selling_price).toBe(150); + expect(ctx.last_quote?.markup_percent).toBe(50); + }); + + it("marks stock status as 'low' when current_qty <= min_qty and > 0", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" }; + const emptyList = { items: [] }; + const supplierList = { items: [] }; + const stockList = { + items: [{ id: "s1", name: "Drill 6mm", sku: "DRL-6MM", current_qty: 5, min_qty: 10, selling_price: 140, markup: 40 }], + }; + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(stockList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.stock?.status).toBe("low"); + }); + + it("marks stock status as 'out' when current_qty <= 0", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" }; + const emptyList = { items: [] }; + const supplierList = { items: [] }; + const stockList = { + items: [{ id: "s1", name: "Drill 6mm", sku: "DRL-6MM", current_qty: 0, min_qty: 5, selling_price: 140, markup: 40 }], + }; + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(stockList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.stock?.status).toBe("out"); + }); + + it("populates last_invoiced when an invoiced row exists", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" }; + const emptyList = { items: [] }; + const supplierList = { items: [] }; + const invoicedList = { + items: [{ + id: "oi2", + order: "o1", + supplier_invoice_number: "INV-99", + customer_invoice_number: "C-INV-7", + invoiced_date: "2026-08-02T10:00:00Z", + cost_price: 105, + quantity: 10, + qty_invoiced: 10, + expand: { order: { order_number: "ORD-26080201" } }, + }], + }; + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn((_n: string, _p: unknown, opts: { sort?: string }) => opts?.sort === "-invoiced_date" ? invoicedList : emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.last_invoiced?.supplier_invoice_number).toBe("INV-99"); + expect(ctx.last_invoiced?.cost_price).toBe(105); + }); +});