Files
Vector/apps/api/src/services/analytics.test.ts
T
josh 3d77f2846d
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m0s
feat: split Repairs into FM, Repair, and Custody workflows
The old Repairs module had grown ticketing-system features (status lifecycle,
comments, assignee, notes) that duplicate what the external ticketing tool
already owns. Vector only needs to track whether maintenance is open or closed.

- Rename RepairJob -> Fm (OPEN/CLOSED only), drop RepairComment, assignee, notes
- New Repair table: persistent log of physical part swaps, with ingest on
  unknown broken MPN via partModels.upsertByMpn
- New custody model: PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
  states + Part.custodianId, with a "My Custody" page for drop-off
- PartModel.destroyOnFail routes broken parts to the destruction path
- Host lookup on /fms and /repairs accepts hostId XOR assetId
- Wire the dormant webhook emitter: fm.opened, fm.closed, repair.logged
- Single fresh Prisma migration (dev DB was wiped, no backfill)

Tests: 60 passing (custody transitions in parts.test.ts; new fms.test.ts,
repairs.test.ts, custody.test.ts covering happy paths, validation failures,
webhook emissions, and ingest-on-unknown-MPN).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 12:22:56 -04:00

177 lines
5.3 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';
// Minimal in-memory tx double exercising the dashboard() aggregator.
// We only stub the calls dashboard() actually makes; other Prisma methods remain unimplemented.
function makeTx(args: {
partCount: number;
stateRows: { state: string; count: number; totalPrice: number }[];
parts: {
id: string;
state: string;
binId: string | null;
createdAt: Date;
partModelId: string;
}[];
openFms: number;
eolPartModels: {
id: string;
mpn: string;
eolDate: Date | null;
manufacturerId: string;
manufacturer: { name: string };
}[];
bins: { id: string; name: string; room: { name: string; site: { name: string } } }[];
}): Tx {
const tx = {
part: {
count: async () => args.partCount,
groupBy: async () =>
args.stateRows.map((s) => ({
state: s.state,
_count: { _all: s.count },
_sum: { price: s.totalPrice },
})),
findMany: async () => args.parts,
},
fm: {
count: async () => args.openFms,
},
partModel: {
findMany: async () => args.eolPartModels,
},
bin: {
findMany: async () => args.bins,
},
};
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);
describe('analytics.dashboard', () => {
it('aggregates totals, state counts and open FMs', async () => {
const tx = makeTx({
partCount: 5,
stateRows: [
{ state: 'SPARE', count: 3, totalPrice: 1500 },
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
],
parts: [],
openFms: 4,
eolPartModels: [],
bins: [],
});
const r = await dashboard(tx);
expect(r.totalParts).toBe(5);
expect(r.openFms).toBe(4);
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({
partCount: 4,
stateRows: [],
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' },
],
openFms: 0,
eolPartModels: [],
bins: [],
});
const r = await dashboard(tx);
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);
// totals should match
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({
partCount: 4,
stateRows: [],
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' },
],
openFms: 0,
eolPartModels: [],
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);
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({
partCount: 3,
stateRows: [],
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' },
],
openFms: 0,
eolPartModels: [
{
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' },
},
],
bins: [],
});
const r = await dashboard(tx);
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,
});
});
});