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> = {}) { 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(); }); });