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