3d77f2846d
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>
280 lines
8.9 KiB
TypeScript
280 lines
8.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const emitMock = vi.fn();
|
|
vi.mock('../lib/webhook-emitter.js', () => ({
|
|
emit: (...args: unknown[]) => emitMock(...args),
|
|
}));
|
|
|
|
import type { Tx, Actor } from './types.js';
|
|
import { create, update, resolveHost } from './fms.js';
|
|
|
|
const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' };
|
|
|
|
const host = { id: 'host-1', assetId: 'ASSET-001', name: 'rack-1' };
|
|
|
|
function fmRow(overrides: Partial<Record<string, unknown>> = {}) {
|
|
return {
|
|
id: 'fm-1',
|
|
hostId: 'host-1',
|
|
status: 'OPEN',
|
|
problem: 'fans failing',
|
|
openedAt: new Date('2026-04-01T00:00:00Z'),
|
|
closedAt: null,
|
|
host: { ...host },
|
|
problemParts: [] as Array<{
|
|
partId: string;
|
|
part: { id: string; serialNumber: string; partModel: { mpn: string } };
|
|
}>,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
emitMock.mockClear();
|
|
});
|
|
|
|
describe('fms.resolveHost', () => {
|
|
it('resolves by assetId when hostId is absent', async () => {
|
|
const findUnique = vi.fn(async (args: { where: { assetId?: string } }) => {
|
|
if (args.where.assetId === 'ASSET-001') return host;
|
|
return null;
|
|
});
|
|
const tx = { host: { findUnique } } as unknown as Tx;
|
|
|
|
const r = await resolveHost(tx, { assetId: 'ASSET-001' });
|
|
expect(r.id).toBe('host-1');
|
|
expect(findUnique).toHaveBeenCalledWith({ where: { assetId: 'ASSET-001' } });
|
|
});
|
|
|
|
it('rejects when neither hostId nor assetId is provided', async () => {
|
|
const tx = { host: { findUnique: vi.fn() } } as unknown as Tx;
|
|
await expect(resolveHost(tx, {})).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it('throws 404 when assetId does not match any host', async () => {
|
|
const tx = {
|
|
host: { findUnique: vi.fn(async () => null) },
|
|
} as unknown as Tx;
|
|
await expect(
|
|
resolveHost(tx, { assetId: 'MISSING' }),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
});
|
|
|
|
describe('fms.create', () => {
|
|
it('resolves host from assetId and writes the canonical hostId', async () => {
|
|
const created = fmRow();
|
|
const fmCreate = vi.fn();
|
|
fmCreate.mockResolvedValue(created);
|
|
const tx = {
|
|
host: {
|
|
findUnique: async (args: { where: { assetId?: string; id?: string } }) =>
|
|
args.where.assetId === 'ASSET-001' ? host : null,
|
|
},
|
|
fm: { create: fmCreate },
|
|
part: { findMany: async () => [] },
|
|
partEvent: { createMany: vi.fn() },
|
|
} as unknown as Tx;
|
|
|
|
await create(tx, { assetId: 'ASSET-001', problem: 'fans failing' }, actor);
|
|
|
|
const args = fmCreate.mock.calls[0]![0] as { data: { hostId: string } };
|
|
expect(args.data.hostId).toBe('host-1');
|
|
});
|
|
|
|
it('fires fm.opened webhook with the resolved host payload', async () => {
|
|
const created = fmRow();
|
|
const tx = {
|
|
host: { findUnique: async () => host },
|
|
fm: { create: async () => created },
|
|
part: { findMany: async () => [] },
|
|
partEvent: { createMany: vi.fn() },
|
|
} as unknown as Tx;
|
|
|
|
await create(tx, { hostId: 'host-1', problem: 'fans failing' }, actor);
|
|
|
|
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
const call = emitMock.mock.calls[0]![0] as {
|
|
event: string;
|
|
payload: { fm: { id: string; assetId: string; status: string } };
|
|
};
|
|
expect(call.event).toBe('fm.opened');
|
|
expect(call.payload.fm.id).toBe('fm-1');
|
|
expect(call.payload.fm.assetId).toBe('ASSET-001');
|
|
expect(call.payload.fm.status).toBe('OPEN');
|
|
});
|
|
|
|
it('rejects when both hostId and assetId are provided', async () => {
|
|
// The shared-zod CreateFmRequest refine enforces XOR at the boundary; the service
|
|
// itself sees hostId first and resolves it. But if a caller passes both at the service
|
|
// layer (bypassing zod), hostId wins — we guard the boundary case in the shared test
|
|
// suite. Here we assert the combined-input path still fails cleanly when hostId is
|
|
// unknown, so the service never silently picks assetId.
|
|
const tx = {
|
|
host: { findUnique: async () => null },
|
|
fm: { create: vi.fn() },
|
|
partEvent: { createMany: vi.fn() },
|
|
} as unknown as Tx;
|
|
|
|
await expect(
|
|
create(
|
|
tx,
|
|
{ hostId: '00000000-0000-0000-0000-000000000000', assetId: 'ASSET-001', problem: 'x' },
|
|
actor,
|
|
),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
|
|
it('creates FM_OPENED PartEvents for each problem part', async () => {
|
|
const created = fmRow({
|
|
problemParts: [
|
|
{
|
|
partId: 'p-1',
|
|
part: { id: 'p-1', serialNumber: 'SN-1', partModel: { mpn: 'WD-1' } },
|
|
},
|
|
],
|
|
});
|
|
const partEventCreateMany = vi.fn();
|
|
const tx = {
|
|
host: { findUnique: async () => host },
|
|
part: {
|
|
findMany: async () => [{ id: 'p-1', hostId: 'host-1' }],
|
|
},
|
|
fm: { create: async () => created },
|
|
partEvent: { createMany: partEventCreateMany },
|
|
} as unknown as Tx;
|
|
|
|
await create(
|
|
tx,
|
|
{ hostId: 'host-1', problem: 'fans failing', problemPartIds: ['p-1'] },
|
|
actor,
|
|
);
|
|
|
|
expect(partEventCreateMany).toHaveBeenCalledTimes(1);
|
|
const args = partEventCreateMany.mock.calls[0]![0] as {
|
|
data: Array<{ partId: string; type: string; newValue: string }>;
|
|
};
|
|
expect(args.data).toEqual([
|
|
{ partId: 'p-1', userId: 'user-1', type: 'FM_OPENED', newValue: 'fm-1' },
|
|
]);
|
|
});
|
|
|
|
it('rejects a problem part that does not live on the selected host', async () => {
|
|
const tx = {
|
|
host: { findUnique: async () => host },
|
|
part: {
|
|
findMany: async () => [{ id: 'p-1', hostId: 'other-host' }],
|
|
},
|
|
fm: { create: vi.fn() },
|
|
partEvent: { createMany: vi.fn() },
|
|
} as unknown as Tx;
|
|
|
|
await expect(
|
|
create(
|
|
tx,
|
|
{ hostId: 'host-1', problem: 'x', problemPartIds: ['p-1'] },
|
|
actor,
|
|
),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
});
|
|
|
|
describe('fms.update — close flips status + sets closedAt + emits webhook', () => {
|
|
it('closes an OPEN FM and emits FM_CLOSED events per problem part', async () => {
|
|
const current = {
|
|
id: 'fm-1',
|
|
hostId: 'host-1',
|
|
status: 'OPEN',
|
|
problemParts: [{ partId: 'p-1' }, { partId: 'p-2' }],
|
|
host: { ...host },
|
|
};
|
|
const updated = fmRow({
|
|
status: 'CLOSED',
|
|
closedAt: new Date('2026-04-10T00:00:00Z'),
|
|
problemParts: [
|
|
{ partId: 'p-1', part: { id: 'p-1', serialNumber: 'SN-1', partModel: { mpn: 'WD-1' } } },
|
|
{ partId: 'p-2', part: { id: 'p-2', serialNumber: 'SN-2', partModel: { mpn: 'WD-2' } } },
|
|
],
|
|
});
|
|
const fmUpdate = vi.fn();
|
|
fmUpdate.mockResolvedValue(updated);
|
|
const partEventCreateMany = vi.fn();
|
|
const tx = {
|
|
fm: {
|
|
findUnique: async () => current,
|
|
update: fmUpdate,
|
|
},
|
|
partEvent: { createMany: partEventCreateMany },
|
|
} as unknown as Tx;
|
|
|
|
await update(tx, 'fm-1', { status: 'CLOSED' }, actor);
|
|
|
|
const updateArgs = fmUpdate.mock.calls[0]![0] as {
|
|
data: { status?: string; closedAt?: unknown };
|
|
};
|
|
expect(updateArgs.data.status).toBe('CLOSED');
|
|
expect(updateArgs.data.closedAt).toBeInstanceOf(Date);
|
|
|
|
const eventArgs = partEventCreateMany.mock.calls[0]![0] as {
|
|
data: Array<{ partId: string; type: string }>;
|
|
};
|
|
expect(eventArgs.data).toEqual([
|
|
{ partId: 'p-1', userId: 'user-1', type: 'FM_CLOSED', newValue: 'fm-1' },
|
|
{ partId: 'p-2', userId: 'user-1', type: 'FM_CLOSED', newValue: 'fm-1' },
|
|
]);
|
|
|
|
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
expect(emitMock.mock.calls[0]![0]).toMatchObject({
|
|
event: 'fm.closed',
|
|
payload: { fm: { id: 'fm-1', status: 'CLOSED' } },
|
|
});
|
|
});
|
|
|
|
it('reopening a closed FM clears closedAt and re-emits fm.opened', async () => {
|
|
const current = {
|
|
id: 'fm-1',
|
|
hostId: 'host-1',
|
|
status: 'CLOSED',
|
|
problemParts: [],
|
|
host: { ...host },
|
|
};
|
|
const updated = fmRow({ status: 'OPEN', closedAt: null });
|
|
const fmUpdate = vi.fn();
|
|
fmUpdate.mockResolvedValue(updated);
|
|
const tx = {
|
|
fm: { findUnique: async () => current, update: fmUpdate },
|
|
partEvent: { createMany: vi.fn() },
|
|
} as unknown as Tx;
|
|
|
|
await update(tx, 'fm-1', { status: 'OPEN' }, actor);
|
|
|
|
const args = fmUpdate.mock.calls[0]![0] as {
|
|
data: { status?: string; closedAt?: unknown };
|
|
};
|
|
expect(args.data.status).toBe('OPEN');
|
|
expect(args.data.closedAt).toBeNull();
|
|
expect(emitMock.mock.calls[0]![0]).toMatchObject({ event: 'fm.opened' });
|
|
});
|
|
|
|
it('status-unchanged updates do not emit webhooks', async () => {
|
|
const current = {
|
|
id: 'fm-1',
|
|
hostId: 'host-1',
|
|
status: 'OPEN',
|
|
problemParts: [],
|
|
host: { ...host },
|
|
};
|
|
const tx = {
|
|
fm: {
|
|
findUnique: async () => current,
|
|
update: async () => fmRow({ problem: 'new text' }),
|
|
},
|
|
partEvent: { createMany: vi.fn() },
|
|
} as unknown as Tx;
|
|
|
|
await update(tx, 'fm-1', { problem: 'new text' }, actor);
|
|
|
|
expect(emitMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|