WFDASH-WEBAPP/src/lib/server/dashboard-service.test.ts

408 lines
15 KiB
TypeScript
Raw Normal View History

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<string, Record<string, unknown>>;
const store: Record<string, StoreMap> = {};
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<string, unknown>,
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<string, unknown>,
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<string, unknown>[] => {
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<string, unknown>) {
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<string, Record<string, unknown>> {
pbMock.collection(name).getList(1, 1);
return (store as Record<string, Map<string, Record<string, unknown>>>)[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);
});
});