import { describe, it, expect, beforeEach, vi } from "vitest"; // --- PocketBase mock --------------------------------------------------------- // The mock state has to be hoisted to the top of the file because Vitest // hoists `vi.mock` factories above all imports. The state lives in a // `vi.hoisted` block so the factory can close over it. 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; const clauses = filter.split("&&").map((c) => c.trim()); return items.filter((item) => clauses.every((clause) => { const m = clause.match(/^(\w+)\s*=\s*"?([^"]+)"?$/); if (!m) return true; const [, field, raw] = m; const value = raw.replace(/^"|"$/g, ""); if (field === "year" || field === "month") { return Number(item[field]) === Number(value); } return String(item[field] ?? "") === value; }), ); }; const pbMock = { collection: (name: string) => ({ async getList( _page = 1, _perPage = 25, opts: { filter?: string; sort?: string } = {}, ) { const items = where(name, opts.filter || ""); if (opts.sort) { const sign = opts.sort.startsWith("-") ? -1 : 1; const field = opts.sort.replace(/^-/, ""); items.sort((a, b) => { const av = a[field] as string | number | undefined; const bv = b[field] as string | number | undefined; if (av === bv) return 0; if (av === undefined) return 1; if (bv === undefined) return -1; return (av > bv ? 1 : -1) * sign; }); } return { items, totalItems: items.length, page: 1, perPage: _perPage }; }, async getFullList() { return where(name, ""); }, async getFirstListItem(filter: string) { const items = where(name, filter); if (items.length === 0) { const err = new Error("not found") as Error & { status: number }; err.status = 404; throw err; } return items[0]; }, 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) { if (name === "order_counters") { const map = getOrCreateMap(name); for (const item of map.values()) { if (item.year === data.year && item.month === data.month) { const err = new Error("unique constraint") as Error & { status: number }; err.status = 409; throw err; } } } 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)) { if (key.endsWith("+")) { const real = key.slice(0, -1); existing[real] = Number(existing[real] ?? 0) + Number(value); } else { existing[key] = value; } } existing.updated = new Date().toISOString(); return { ...existing }; }, }), }; const clearStore = () => { for (const key of Object.keys(store)) delete store[key]; seq = 0; }; return { store, pbMock, clearStore }; }); vi.mock("pocketbase", () => { return { default: vi.fn().mockImplementation(() => pbMock), }; }); vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), })); import { createOrderFromQuote, getOrderItemsCollectionName, listCollectionPool, } from "./orders-service"; beforeEach(() => { clearStore(); }); // --- Tests ------------------------------------------------------------------- describe("getOrderItemsCollectionName", () => { it("defaults to 'order_items' when env var is unset", () => { const original = process.env.ORDER_ITEMS_COLLECTION; delete process.env.ORDER_ITEMS_COLLECTION; try { expect(getOrderItemsCollectionName()).toBe("order_items"); } finally { if (original !== undefined) process.env.ORDER_ITEMS_COLLECTION = original; } }); it("returns the env var when set (e.g. order_items_v2)", () => { process.env.ORDER_ITEMS_COLLECTION = "order_items_v2"; try { expect(getOrderItemsCollectionName()).toBe("order_items_v2"); } finally { delete process.env.ORDER_ITEMS_COLLECTION; } }); }); describe("createOrderFromQuote", () => { it("returns the existing order when one already references the quote", async () => { // Pre-seed an existing order in the store; dedupe check should find it // and short-circuit before the rest of the pipeline runs. await pbMock.collection("orders").create({ id: "order-1", quote: "quote-abc", order_number: "ORD-2025-01-001", }); const result = await createOrderFromQuote("quote-abc"); expect(result.already_exists).toBe(true); expect(result.order.id).toBe("order-1"); expect(result.created_items).toBe(0); }); it("throws when the quote has no items", async () => { await pbMock.collection("quotes").create({ id: "quote-empty", company: "comp-1", customer: "cust-1", notes: "", }); // No quote_items rows → service must throw. await expect(createOrderFromQuote("quote-empty")).rejects.toThrow( /no items to convert/, ); }); it("creates an order, copies every quote_item, and sets the right defaults", async () => { // Seed the dependencies in the mock store. await pbMock.collection("quotes").create({ id: "quote-99", company: "comp-7", customer: "cust-3", notes: "Rush", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "quote-99", description: "Widget", quantity: 3, selling_price: 100, supplier: "sup-1", cost_price: 50, markup: 10, notes: "front", }); await pbMock.collection("quote_items").create({ id: "qi-2", quote: "quote-99", description: "Sprocket", quantity: 2, selling_price: 75, supplier: "sup-2", cost_price: 30, markup: 0, notes: "", }); const result = await createOrderFromQuote("quote-99"); expect(result.already_exists).toBe(false); expect(result.created_items).toBe(2); // Pull the created order directly out of the store. const orders = await pbMock.collection("orders").getFullList(); const created = orders.find((o) => o.quote === "quote-99") as | Record | undefined; expect(created).toBeDefined(); expect(created?.quote).toBe("quote-99"); expect(created?.company).toBe("comp-7"); expect(created?.customer).toBe("cust-3"); expect(created?.quote_matching_attempted).toBe(true); expect(created?.is_repair_order).toBe(false); expect(created?.invoiced).toBe(false); // Starts at ORD-{now-year}-{now-month}-001 because the orders table // is empty for this test (no prior order to extend from). const now = new Date(); const expectedPrefix = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-`; expect(String(created?.order_number)).toBe(`${expectedPrefix}001`); // Order-item payload assertions: the two quote_items should each // have produced one order_item row. const orderItems = await pbMock.collection("order_items").getFullList(); const ourItems = orderItems.filter((o) => o.order === created?.id); expect(ourItems.length).toBe(2); const widget = ourItems.find((o) => o.description === "Widget"); expect(widget).toMatchObject({ po_description: "Widget", quantity: 3, order_qty: 3, qty_ordered: "3", selling_price: 100, supplier: "sup-1", cost_price: 50, markup: 10, markup_percent: 10, item_status: "pending", status: "active", drv_status: "unassigned", notes: "front", quote_item: "qi-1", }); const sprocket = ourItems.find((o) => o.description === "Sprocket"); expect(sprocket?.quote_item).toBe("qi-2"); expect(sprocket?.supplier).toBe("sup-2"); expect(sprocket?.drv_status).toBe("unassigned"); }); it("extends the latest order's sequence (no month-boundary reset)", async () => { // The most recent order is in June; we're now in July. The contract // is that the next order continues June's sequence (NOT a fresh // July-001). The old code's bug was using `now` month, which would // have generated `ORD-2026-07-001` here. await pbMock.collection("orders").create({ order_number: "ORD-2026-06-007", quote: "some-other-quote", }); await pbMock.collection("quotes").create({ id: "quote-seq", company: "c", customer: null, notes: "", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "quote-seq", description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); await createOrderFromQuote("quote-seq"); const orders = await pbMock.collection("orders").getFullList(); const newOrder = orders.find((o) => o.quote === "quote-seq"); expect(newOrder?.order_number).toBe("ORD-2026-06-008"); }); it("starts a fresh month at 001 when the orders table is empty", async () => { // The orders collection is empty → no most-recent order to extend // from. Falls back to wall-clock year/month, sequence at 001. await pbMock.collection("quotes").create({ id: "q-fresh", company: "c", customer: null, notes: "", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "q-fresh", description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); await createOrderFromQuote("q-fresh"); const orders = await pbMock.collection("orders").getFullList(); const newOrder = orders.find((o) => o.quote === "q-fresh"); const now = new Date(); const expected = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-001`; expect(newOrder?.order_number).toBe(expected); }); it("falls back to wall-clock year/month when the latest order_number is unparseable", async () => { // A junk order_number (legacy import, manual edit, or old data with a // different prefix) should NOT break the generator. The service // detects unparseable prefixes and uses now year/month. await pbMock.collection("orders").create({ order_number: "GARBAGE-2026-06-007", quote: "old", }); await pbMock.collection("quotes").create({ id: "q-junk", company: "c", customer: null, notes: "", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "q-junk", description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); await createOrderFromQuote("q-junk"); const orders = await pbMock.collection("orders").getFullList(); const newOrder = orders.find((o) => o.quote === "q-junk"); const now = new Date(); const expected = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-001`; expect(newOrder?.order_number).toBe(expected); }); it("writes to the collection named by ORDER_ITEMS_COLLECTION env var", async () => { process.env.ORDER_ITEMS_COLLECTION = "order_items_v2"; await pbMock.collection("quotes").create({ id: "q", company: "c", customer: null, notes: "", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "q", description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); const result = await createOrderFromQuote("q"); expect(result.order_items_collection).toBe("order_items_v2"); const v2Rows = await pbMock.collection("order_items_v2").getFullList(); expect(v2Rows.length).toBe(1); expect(v2Rows[0].order).toBeDefined(); delete process.env.ORDER_ITEMS_COLLECTION; }); it("concurrent calls produce unique order numbers (no race)", async () => { // Set up the dependencies for a single order. await pbMock.collection("quotes").create({ id: "q-race", company: "c", customer: null, notes: "", }); await pbMock.collection("quote_items").create({ id: "qi-1", quote: "q-race", description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); // Run 5 createOrderFromQuote calls in parallel against distinct // quotes. The counter must give each a unique number even under // simulated concurrency. (Note: the test mock is not actually // concurrent — the `await`s in the implementation interleave the // operations — so the assertion verifies the counter pattern, not // true concurrency. Real concurrency safety comes from PB's // `last_seq+` server-side increment.) const quotes = ["q-race-a", "q-race-b", "q-race-c", "q-race-d", "q-race-e"]; for (const id of quotes) { await pbMock.collection("quotes").create({ id, company: "c", customer: null, notes: "" }); await pbMock.collection("quote_items").create({ id: `qi-${id}`, quote: id, description: "x", quantity: 1, selling_price: 1, cost_price: 0, markup: 0, notes: "", }); } const results = await Promise.all(quotes.map((id) => createOrderFromQuote(id))); const numbers = results.map((r) => String((r.order as unknown as { order_number: string }).order_number)); expect(new Set(numbers).size).toBe(5); }); });