Files
Vector/apps/api/src/services/analytics.test.ts
T
josh db8e86b749
CI / Lint · Typecheck · Test · Build (push) Failing after 36s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Has been skipped
feat: remove FM feature from Vector
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>
2026-04-19 18:46:40 -04:00

297 lines
9.6 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, 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['030d']).toBe(1);
expect(byLabel['3190d']).toBe(1);
expect(byLabel['12y']).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);
}
});
});