import { describe, expect, it } from 'vitest'; import type { Tx } from './types.js'; import { dashboard } from './analytics.js'; type EolPartModel = { id: string; mpn: string; eolDate: Date | null; manufacturerId: string; manufacturer: { name: string }; }; type FakeArgs = { partCount: number; stateRows: { state: string; count: number; totalPrice: number }[]; parts: { id: string; state: string; binId: string | null; createdAt: Date; partModelId: string; }[]; pastEolModels: EolPartModel[]; upcomingEolModels: EolPartModel[]; bins: { id: string; name: string; room: { name: string; site: { name: string } } }[]; // Admin-only inputs. Ignored when isAdmin=false path is exercised. repairs?: { performedAt: Date }[]; custodyGroups?: { custodianId: string | null; count: number }[]; users?: { id: string; username: string }[]; }; function makeTx(args: FakeArgs): Tx { const tx = { part: { count: async () => args.partCount, groupBy: async (q: { by: string[]; where?: { custodianId?: unknown } }) => { if (q.by.includes('custodianId')) { return (args.custodyGroups ?? []).map((g) => ({ custodianId: g.custodianId, _count: { _all: g.count }, })); } return args.stateRows.map((s) => ({ state: s.state, _count: { _all: s.count }, _sum: { price: s.totalPrice }, })); }, findMany: async () => args.parts, }, partModel: { findMany: async (q: { where?: { eolDate?: { gt?: Date; lte?: Date; not?: unknown } } }) => { const gt = q.where?.eolDate?.gt; if (gt !== undefined) return args.upcomingEolModels; return args.pastEolModels; }, }, bin: { findMany: async () => args.bins, }, repair: { count: async (q: { where?: { performedAt?: { gte: Date } } }) => { const gte = q.where?.performedAt?.gte; if (!gte) return 0; return (args.repairs ?? []).filter((r) => r.performedAt >= gte).length; }, findMany: async (q: { where?: { performedAt?: { gte: Date } } }) => { const gte = q.where?.performedAt?.gte; if (!gte) return args.repairs ?? []; return (args.repairs ?? []).filter((r) => r.performedAt >= gte); }, }, user: { findMany: async () => args.users ?? [], }, }; return tx as unknown as Tx; } const now = new Date('2026-04-16T00:00:00.000Z'); const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000); const daysAhead = (n: number) => new Date(now.getTime() + n * 24 * 60 * 60 * 1000); const EMPTY: FakeArgs = { partCount: 0, stateRows: [], parts: [], pastEolModels: [], upcomingEolModels: [], bins: [], }; describe('analytics.dashboard — base fields', () => { it('aggregates totals and state counts', async () => { const tx = makeTx({ ...EMPTY, partCount: 5, stateRows: [ { state: 'SPARE', count: 3, totalPrice: 1500 }, { state: 'DEPLOYED', count: 2, totalPrice: 8000 }, ], }); const r = await dashboard(tx, { isAdmin: false }); expect(r.totalParts).toBe(5); expect(r.byState).toEqual([ { state: 'SPARE', count: 3, totalPrice: 1500 }, { state: 'DEPLOYED', count: 2, totalPrice: 8000 }, ]); }); it('buckets parts by age correctly', async () => { const tx = makeTx({ ...EMPTY, partCount: 4, parts: [ { id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), partModelId: 'pm' }, { id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), partModelId: 'pm' }, { id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' }, { id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' }, ], }); const r = await dashboard(tx, { isAdmin: false }); const byLabel = Object.fromEntries(r.ageBuckets.map((b) => [b.label, b.count])); expect(byLabel['0–30d']).toBe(1); expect(byLabel['31–90d']).toBe(1); expect(byLabel['1–2y']).toBe(1); expect(byLabel['2y+']).toBe(1); expect(r.ageBuckets.reduce((s, b) => s + b.count, 0)).toBe(4); }); it('ranks top bins and labels them site/room/bin', async () => { const tx = makeTx({ ...EMPTY, partCount: 4, parts: [ { id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' }, { id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' }, { id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), partModelId: 'pm' }, { id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' }, ], bins: [ { id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } }, { id: 'b2', name: 'B2', room: { name: 'Lab', site: { name: 'HQ' } } }, ], }); const r = await dashboard(tx, { isAdmin: false }); expect(r.topBins).toEqual([ { binId: 'b1', label: 'HQ / Lab / A1', count: 2 }, { binId: 'b2', label: 'HQ / Lab / B2', count: 1 }, ]); }); it('flags part models whose EOL has passed and have deployed parts', async () => { const tx = makeTx({ ...EMPTY, partCount: 3, parts: [ { id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' }, { id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' }, { id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' }, ], pastEolModels: [ { id: 'pm1', mpn: 'ACM-100', eolDate: daysAgo(30), manufacturerId: 'm1', manufacturer: { name: 'Acme' }, }, { id: 'pm2', mpn: 'BET-200', eolDate: daysAgo(10), manufacturerId: 'm2', manufacturer: { name: 'Beta' }, }, { id: 'pm3', mpn: 'GAM-300', eolDate: daysAgo(5), manufacturerId: 'm3', manufacturer: { name: 'Gamma' }, }, ], }); const r = await dashboard(tx, { isAdmin: false }); expect(r.deployedPastEol.map((m) => m.mpn)).toEqual(['ACM-100', 'BET-200']); expect(r.deployedPastEol[0]).toMatchObject({ partModelId: 'pm1', manufacturerName: 'Acme', deployedCount: 2, }); expect(r.deployedPastEol[1]).toMatchObject({ partModelId: 'pm2', manufacturerName: 'Beta', deployedCount: 1, }); }); }); describe('analytics.dashboard — upcomingEol', () => { it('lists models with upcoming EOL sorted by date, filters zero-deployed', async () => { const tx = makeTx({ ...EMPTY, partCount: 3, parts: [ { id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' }, { id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' }, { id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' }, ], upcomingEolModels: [ { id: 'pm2', mpn: 'LATER', eolDate: daysAhead(150), manufacturerId: 'm1', manufacturer: { name: 'Acme' }, }, { id: 'pm1', mpn: 'SOONER', eolDate: daysAhead(45), manufacturerId: 'm1', manufacturer: { name: 'Acme' }, }, { id: 'pm3', mpn: 'NODEP', eolDate: daysAhead(30), manufacturerId: 'm1', manufacturer: { name: 'Acme' }, }, ], }); const r = await dashboard(tx, { isAdmin: false }); expect(r.upcomingEol.map((m) => m.mpn)).toEqual(['SOONER', 'LATER']); expect(r.upcomingEol[0]).toMatchObject({ partModelId: 'pm1', deployedCount: 1 }); expect(r.upcomingEol[1]).toMatchObject({ partModelId: 'pm2', deployedCount: 2 }); }); }); describe('analytics.dashboard — isAdmin gating', () => { it('omits operations when isAdmin is false', async () => { const tx = makeTx(EMPTY); const r = await dashboard(tx, { isAdmin: false }); expect(r.operations).toBeUndefined(); }); it('returns operations with expected shape when isAdmin is true', async () => { const tx = makeTx({ ...EMPTY, repairs: [{ performedAt: daysAgo(1) }], custodyGroups: [{ custodianId: 'u1', count: 1 }], users: [{ id: 'u1', username: 'alice' }], }); const r = await dashboard(tx, { isAdmin: true }); expect(r.operations).toBeDefined(); expect(r.operations).toMatchObject({ repairs7d: 1, repairs30d: 1, }); expect(r.operations!.repairsTrend30d).toHaveLength(30); expect(r.operations!.custodyBacklog).toEqual([ { userId: 'u1', username: 'alice', count: 1 }, ]); }); }); describe('analytics.dashboard — operations fields', () => { it('repairsTrend30d has 30 entries and zero-fills empty days', async () => { // Anchor the repairs to real "now" so they land inside the dashboard's // 30-day window regardless of when the test runs. const realNow = new Date(); const realDaysAgo = (n: number) => new Date(realNow.getTime() - n * 24 * 60 * 60 * 1000); const tx = makeTx({ ...EMPTY, repairs: [{ performedAt: realDaysAgo(2) }, { performedAt: realDaysAgo(10) }], }); const r = await dashboard(tx, { isAdmin: true }); const trend = r.operations!.repairsTrend30d; expect(trend).toHaveLength(30); const totalCount = trend.reduce((s, d) => s + d.count, 0); expect(totalCount).toBe(2); // Chronological order: earliest first, today last for (let i = 1; i < trend.length; i++) { expect(trend[i]!.date >= trend[i - 1]!.date).toBe(true); } }); });