408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
|
|
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);
|
|||
|
|
});
|
|||
|
|
});
|