db8e86b749
FMs move to a separate application. Drops Fm/FmPart tables + Repair.fmId column, deletes FM_OPENED/FM_CLOSED PartEvent rows, strips FM enums + webhook events + shared contracts, removes FM routes/services/pages/UI, and collapses dashboard admin ops to Repairs 7d/30d + trend + custody backlog. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
297 lines
9.6 KiB
TypeScript
297 lines
9.6 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|