import { describe, it, expect, beforeEach, vi } from "vitest"; // --- PocketBase mock --------------------------------------------------------- // Pattern lifted from orders-service.test.ts: a minimal in-memory store with // just enough query semantics to drive getDashboardData's aggregation. 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]; }; // Tokenise the filter enough for the dashboard queries to work. The // dashboard uses these shapes: // "(status = \"a\" || status = \"b\") && is_template != true" // "created >= \"2026-07-01T00:00:00.000Z\"" // "follow_up_due != \"\" && follow_up_due <= \"...\" && status != \"...\" && is_template != true" // We split on `&&` / `||`, then match each leaf as `field op "value"`. const matchesLeaf = ( item: Record, clause: string, ): boolean => { // PB accepts quoted or unquoted RHS values; capture both. const m = clause.match(/^(\w+)\s*(>=|<=|!=|=)\s*(?:"([^"]*)"|(\S+))$/); if (!m) return true; // unrecognised leaf — fail open (matches everything) const [, field, op, quoted, unquoted] = m; const expected = quoted ?? unquoted ?? ""; const actual = item[field]; const a = actual instanceof Date ? actual.toISOString() : String(actual ?? ""); switch (op) { case "=": return a === expected; case "!=": return a !== expected; case ">=": return a >= expected; case "<=": return a <= expected; default: return true; } }; const matchesGroup = ( item: Record, group: string, ): boolean => { if (!group.trim()) return true; const alts = group.split("||").map((s) => s.trim()).filter(Boolean); return alts.some((alt) => { const leaves = alt.split("&&").map((s) => s.trim()).filter(Boolean); return leaves.every((leaf) => matchesLeaf(item, leaf)); }); }; const where = (name: string, filter: string): Record[] => { const items = Array.from(getOrCreateMap(name).values()); if (!filter || !filter.trim()) return items; return items.filter((item) => matchesGroup(item, filter)); }; const pbMock = { autoCancellation: () => undefined, collection: (name: string) => ({ async getList( page = 1, perPage = 25, opts: { filter?: string; sort?: string; fields?: string } = {}, ) { const all = where(name, opts.filter || ""); if (opts.sort) { const sign = opts.sort.startsWith("-") ? -1 : 1; const field = opts.sort.replace(/^-/, ""); all.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; }); } const totalItems = all.length; const totalPages = Math.max(1, Math.ceil(totalItems / perPage)); const start = (page - 1) * perPage; const items = all.slice(start, start + perPage); return { items, totalItems, totalPages, page, perPage }; }, async getFullList(opts: { filter?: string; fields?: string } = {}) { return where(name, opts.filter || ""); }, 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; }, }), }; 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 { getDashboardData } from "./dashboard-service"; beforeEach(() => { clearStore(); }); /* ============================================================================ * Helpers * ========================================================================== */ function isoDaysAgo(n: number): string { const d = new Date("2026-08-05T12:00:00.000Z"); d.setUTCDate(d.getUTCDate() - n); return d.toISOString(); } /** * Lazy-init the backing map for `name` and return it. We can't use * `pbMock.collection(name).getList(...)` to initialise because the test * bodies then want predictable ids and write directly to the map. */ function getMap(name: string): Map> { pbMock.collection(name).getList(1, 1); return (store as Record>>)[name]; } async function seedBasic() { const customersMap = getMap("customers"); const ordersMap = getMap("orders"); const orderItemsMap = getMap("order_items"); const quotesMap = getMap("quotes"); const stockMap = getMap("stock_items"); customersMap.set("c-1", { id: "c-1", customer: "Acme", created: isoDaysAgo(1) }); customersMap.set("c-2", { id: "c-2", customer: "Bravo", created: isoDaysAgo(14) }); ordersMap.set("o-1", { id: "o-1", created: isoDaysAgo(1) }); ordersMap.set("o-2", { id: "o-2", created: isoDaysAgo(20) }); orderItemsMap.set("oi-1", { id: "oi-1", created: isoDaysAgo(1), item_status: "delivered", selling_price: 100, quantity: 2, }); for (let i = 0; i < 10; i += 1) { quotesMap.set(`q-${i}`, { id: `q-${i}`, status: i % 2 === 0 ? "emailed" : "approved", is_template: false, created: isoDaysAgo(7), customer: "c-1", }); } stockMap.set("s-1", { id: "s-1", current_qty: 0, min_qty: 5, is_active: true }); stockMap.set("s-2", { id: "s-2", current_qty: 2, min_qty: 5, is_active: true }); } /* ============================================================================ * Tests * ========================================================================== */ describe("getDashboardData", () => { it("returns counts and week buckets from a small seed", async () => { await seedBasic(); const data = await getDashboardData(new Date("2026-08-05T12:00:00.000Z")); expect(data.stats.totalCustomers).toBe(2); expect(data.stats.ordersThisMonth).toBeGreaterThanOrEqual(1); expect(data.stats.totalRevenue).toBeGreaterThan(0); expect(data.customersByWeek.length).toBe(12); expect(data.ordersByWeek.length).toBe(8); expect(data.fulfilledByWeek.length).toBe(8); expect(data.quotesByWeek.length).toBe(12); expect(data.revenueByWeek.length).toBe(12); }); it("buckets orders into Monday-anchored weeks and increments the right bucket", async () => { const customersMap = getMap("customers"); // Two customers in the most-recent week customersMap.set("c-recent-1", { id: "c-recent-1", customer: "Recent A", created: "2026-08-04T12:00:00.000Z" }); customersMap.set("c-recent-2", { id: "c-recent-2", customer: "Recent B", created: "2026-08-03T12:00:00.000Z" }); // One customer ~2 weeks ago customersMap.set("c-old", { id: "c-old", customer: "Old", created: "2026-07-22T12:00:00.000Z" }); const data = await getDashboardData(new Date("2026-08-05T12:00:00.000Z")); const lastWeek = data.customersByWeek.at(-1); const secondLast = data.customersByWeek.at(-2); expect(lastWeek?.count).toBeGreaterThanOrEqual(2); // The old customer should NOT be in the most-recent bucket expect((lastWeek?.count ?? 0) + (secondLast?.count ?? 0)).toBeLessThanOrEqual(3); }); it("counts fulfilled items only from the terminal-status set", async () => { const orderItemsMap = getMap("order_items"); orderItemsMap.set("oi-d1", { id: "oi-d1", created: isoDaysAgo(1), item_status: "delivered" }); orderItemsMap.set("oi-d2", { id: "oi-d2", created: isoDaysAgo(2), item_status: "invoiced" }); orderItemsMap.set("oi-d3", { id: "oi-d3", created: isoDaysAgo(3), item_status: "completed" }); orderItemsMap.set("oi-d4", { id: "oi-d4", created: isoDaysAgo(4), item_status: "checked_in" }); orderItemsMap.set("oi-d5", { id: "oi-d5", created: isoDaysAgo(5), item_status: "new" }); orderItemsMap.set("oi-d6", { id: "oi-d6", created: isoDaysAgo(6), item_status: "cancelled" }); const data = await getDashboardData(new Date("2026-08-05T12:00:00.000Z")); const fulfilledTotal = data.fulfilledByWeek.reduce((s, w) => s + w.count, 0); expect(fulfilledTotal).toBe(4); }); it("computes revenue from selling_price × quantity in the sample window", async () => { const orderItemsMap = getMap("order_items"); orderItemsMap.set("oi-r1", { id: "oi-r1", created: isoDaysAgo(1), selling_price: 50, quantity: 4 }); orderItemsMap.set("oi-r2", { id: "oi-r2", created: isoDaysAgo(2), selling_price: 0, quantity: 10 }); orderItemsMap.set("oi-r3", { id: "oi-r3", created: isoDaysAgo(3), selling_price: 100, quantity: 2 }); const data = await getDashboardData(new Date("2026-08-05T12:00:00.000Z")); const revenueTotal = data.revenueByWeek.reduce((s, w) => s + w.count, 0); expect(revenueTotal).toBeCloseTo(400, 2); }); it("groups quotes into status buckets and surfaces top customers", async () => { const quotesMap = getMap("quotes"); const customersMap = getMap("customers"); customersMap.set("c-a", { id: "c-a", customer: "AlphaCorp" }); customersMap.set("c-b", { id: "c-b", customer: "BetaCorp" }); // 6 emailed + 3 approved for AlphaCorp, 2 emailed for BetaCorp for (let i = 0; i < 6; i += 1) { quotesMap.set(`q-a-${i}`, { id: `q-a-${i}`, status: "emailed", is_template: false, created: isoDaysAgo(7), customer: "c-a", expand: { customer: { customer: "AlphaCorp" } }, }); } for (let i = 0; i < 3; i += 1) { quotesMap.set(`q-a-appr-${i}`, { id: `q-a-appr-${i}`, status: "approved", is_template: false, created: isoDaysAgo(7), customer: "c-a", expand: { customer: { customer: "AlphaCorp" } }, }); } for (let i = 0; i < 2; i += 1) { quotesMap.set(`q-b-${i}`, { id: `q-b-${i}`, status: "emailed", is_template: false, created: isoDaysAgo(7), customer: "c-b", expand: { customer: { customer: "BetaCorp" } }, }); } const data = await getDashboardData(new Date("2026-08-05T12:00:00.000Z")); const emailedBucket = data.quoteStatusBuckets.find((b) => b.status === "emailed"); const approvedBucket = data.quoteStatusBuckets.find((b) => b.status === "approved"); expect(emailedBucket?.count).toBe(8); expect(approvedBucket?.count).toBe(3); expect(data.topCustomers[0]?.name).toBe("AlphaCorp"); expect(data.topCustomers[0]?.count).toBe(9); expect(data.topCustomers[1]?.name).toBe("BetaCorp"); expect(data.topCustomers[1]?.count).toBe(2); }); it("excludes templates from status buckets and active counts", async () => { const quotesMap = getMap("quotes"); for (let i = 0; i < 5; i += 1) { quotesMap.set(`q-${i}`, { id: `q-${i}`, status: "emailed", is_template: false, created: isoDaysAgo(7), }); } // Templates should NOT count for (let i = 0; i < 3; i += 1) { quotesMap.set(`qt-${i}`, { id: `qt-${i}`, status: "emailed", is_template: true, created: isoDaysAgo(7), }); } const data = await getDashboardData(new Date("2026-08-05T12:00:00.000Z")); const emailedBucket = data.quoteStatusBuckets.find((b) => b.status === "emailed"); expect(emailedBucket?.count).toBe(5); }); it("sums stock totals, low-stock, and out-of-stock correctly", async () => { const stockMap = getMap("stock_items"); stockMap.set("s-1", { id: "s-1", current_qty: 0, min_qty: 5, is_active: true }); stockMap.set("s-2", { id: "s-2", current_qty: 5, min_qty: 5, is_active: true }); stockMap.set("s-3", { id: "s-3", current_qty: 6, min_qty: 5, is_active: true }); stockMap.set("s-4", { id: "s-4", current_qty: 3, min_qty: 5, is_active: true }); stockMap.set("s-5", { id: "s-5", current_qty: 100, min_qty: 5, is_active: true }); const data = await getDashboardData(new Date("2026-08-05T12:00:00.000Z")); expect(data.stockSummary.totalItems).toBe(5); expect(data.stockSummary.totalQty).toBe(114); expect(data.stockSummary.outOfStockCount).toBe(1); expect(data.stockSummary.lowStockCount).toBe(2); // 5 (at-min) and 3 (below-min) }); it("returns follow-ups only for due dates in non-terminal statuses", async () => { const quotesMap = getMap("quotes"); const customersMap = getMap("customers"); customersMap.set("c-1", { id: "c-1", customer: "Acme" }); quotesMap.set("q-overdue", { id: "q-overdue", quote_number: "Q-001", follow_up_due: "2026-08-01T00:00:00.000Z", status: "emailed", is_template: false, customer: "c-1", expand: { customer: { customer: "Acme" } }, }); // Future follow-up: should be excluded by the <= now filter quotesMap.set("q-future", { id: "q-future", quote_number: "Q-002", follow_up_due: "2099-01-01T00:00:00.000Z", status: "emailed", is_template: false, }); // Empty follow_up_due: excluded quotesMap.set("q-empty", { id: "q-empty", quote_number: "Q-003", follow_up_due: "", status: "emailed", is_template: false, }); // Complete status: excluded quotesMap.set("q-complete", { id: "q-complete", quote_number: "Q-004", follow_up_due: "2026-08-01T00:00:00.000Z", status: "complete", is_template: false, }); const data = await getDashboardData(new Date("2026-08-05T12:00:00.000Z")); expect(data.followUps).toHaveLength(1); expect(data.followUps[0]?.id).toBe("q-overdue"); expect(data.followUps[0]?.customer_name).toBe("Acme"); }); it("flags stats.sampled when the 500-row sample is a subset of a larger matching set", async () => { const quotesMap = getMap("quotes"); // 510 quotes → sample limit (500) is hit, so sampled=true for (let i = 0; i < 510; i += 1) { quotesMap.set(`q-${i}`, { id: `q-${i}`, status: "emailed", is_template: false, created: isoDaysAgo(7), }); } const data = await getDashboardData(new Date("2026-08-05T12:00:00.000Z")); expect(data.stats.sampled).toBe(true); }); it("does not flag sampled when all matching rows fit in the sample window", async () => { const quotesMap = getMap("quotes"); for (let i = 0; i < 50; i += 1) { quotesMap.set(`q-${i}`, { id: `q-${i}`, status: "emailed", is_template: false, created: isoDaysAgo(7), }); } const data = await getDashboardData(new Date("2026-08-05T12:00:00.000Z")); expect(data.stats.sampled).toBe(false); }); });