WFDASH-WEBAPP/src/lib/server/dashboard-service.test.ts
SJCoetz 12fa65a481 feat: add new order, invoice, and dashboard management tools
- Add server-side `/api/v1/dashboard` endpoint to reduce client-side payload and round trips
- Implement invoice upload and order-item SKU update APIs with complete test suites
- Refactor order and order-items list endpoints (including MCP tools) to support proper PocketBase filter and sort params
- Fix pagination validation for order-items route to clamp page ≥1 and perPage 1-500
- Add redirects in next.config.ts to map underscore collection paths to hyphenated API routes
- Improve buyer tasks page to avoid redundant tab count refetches when switching tabs
- Refactor collection confirm components with editable SKUs and updated accessibility labels
- Add cached admin PocketBase auth to minimize repeated authentication calls
- Add performance testing scripts for dashboard endpoint latency
- Update quotes list route to default to excluding archived counts
- Fix photo strip component text and labels for non-invoice documents
- Refactor sprint page to lazy load supplier/customer data for pricing modal
- Clean up unused imports in test files
- Add full test coverage for all new APIs, services, and components
2026-08-05 23:00:54 +02:00

408 lines
No EOL
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
});