import type { Prisma } from '../../generated/prisma'; import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; import type { NotificationsService } from '../notifications/notifications.service'; import { VOICE_ERROR_CODES } from './voice-error-codes'; import { VOICE_ERROR_TEXTS_DE } from './voice-error-texts'; import { VoiceEventHandlers } from './voice.handlers'; function readyEvent(): AnyDomainEventEnvelope<'VoiceCaptureReady'> { return { id: 'ev-1', type: 'VoiceCaptureReady', aggregateType: 'VoiceCapture', aggregateId: 'vc-1', payload: { captureId: 'vc-1', colonyId: 'col-1' }, actorUserId: null, correlationId: 'corr-1', occurredAt: new Date('2026-10-16T08:00:00.000Z'), attempts: 0, }; } function failedEvent( code: (typeof VOICE_ERROR_CODES)[number], ): AnyDomainEventEnvelope<'VoiceCaptureFailed'> { return { ...readyEvent(), type: 'VoiceCaptureFailed', payload: { captureId: 'vc-1', code }, } as unknown as AnyDomainEventEnvelope<'VoiceCaptureFailed'>; } describe('VoiceEventHandlers', () => { let tx: { voiceCapture: { findUnique: jest.Mock } }; let notifications: { record: jest.Mock; recordForPermissionOnce: jest.Mock; }; let handlers: VoiceEventHandlers; const asTx = () => tx as unknown as Prisma.TransactionClient; beforeEach(() => { tx = { voiceCapture: { findUnique: jest.fn().mockResolvedValue({ ownerUserId: 'u-1', colonyId: 'col-1', status: 'READY', colony: { code: 'Volk 7' }, }), }, }; notifications = { record: jest.fn().mockResolvedValue({ id: 'n-1' }), recordForPermissionOnce: jest.fn().mockResolvedValue([]), }; handlers = new VoiceEventHandlers( notifications as unknown as NotificationsService, ); }); it('writes one HINWEIS for the owner only once per capture', async () => { await handlers.notifyReady(readyEvent(), asTx()); expect(notifications.record).toHaveBeenCalledTimes(1); expect(notifications.record).toHaveBeenCalledWith( { userId: 'u-1', priority: 'HINWEIS', category: 'SUGGESTION', message: 'Sprachnotiz für Volk 7 ist fertig', relatedType: 'VoiceCapture', relatedId: 'vc-1', link: '/inspections/new?colonyId=col-1&voice=vc-1', requiredPermissions: ['voice-captures:use'], dedupeKey: 'voice-ready:vc-1', }, tx, ); }); it('reads the colony name from the database, not from the payload', async () => { await handlers.notifyReady(readyEvent(), asTx()); expect(tx.voiceCapture.findUnique).toHaveBeenCalledWith({ where: { id: 'vc-1' }, select: { ownerUserId: true, colonyId: true, status: true, colony: { select: { code: true } }, }, }); expect(Object.keys(readyEvent().payload).sort()).toEqual([ 'captureId', 'colonyId', ]); }); function withStatus(status: string): void { tx.voiceCapture.findUnique.mockResolvedValue({ ownerUserId: 'u-1', colonyId: 'col-1', status, colony: { code: 'Volk 7' }, }); } it.each(['CONFIRMED', 'DISCARDED', 'EXPIRED'])( 'sends no ready notice for a %s note', async (status) => { withStatus(status); await handlers.notifyReady(readyEvent(), asTx()); expect(notifications.record).not.toHaveBeenCalled(); }, ); it.each(['CONFIRMED', 'DISCARDED', 'TRANSCRIBED'])( 'sends no failure notice to the user for a %s note', async (status) => { withStatus(status); await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx()); expect(notifications.record).not.toHaveBeenCalled(); }, ); it('still tells operators when a waiting note reports EXTRACT_DISABLED', async () => { withStatus('TRANSCRIBED'); await handlers.notifyFailed(failedEvent('EXTRACT_DISABLED'), asTx()); expect(notifications.record).not.toHaveBeenCalled(); expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(1); }); it('writes nothing when the capture is gone', async () => { tx.voiceCapture.findUnique.mockResolvedValue(null); await handlers.notifyReady(readyEvent(), asTx()); await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx()); expect(notifications.record).not.toHaveBeenCalled(); }); it('the failure text names the next step per code', async () => { withStatus('FAILED'); await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx()); const [input, passedTx] = notifications.record.mock.calls[0] as [ Record, unknown, ]; expect(passedTx).toBe(tx); expect(input).toMatchObject({ userId: 'u-1', priority: 'WARNUNG', category: 'SUGGESTION', message: 'Sprachnotiz für Volk 7 konnte nicht ausgewertet werden. Es war nichts zu verstehen. Bitte noch einmal aufnehmen.', link: '/voice', requiredPermissions: ['voice-captures:use'], dedupeKey: 'voice-failed:vc-1:NO_SPEECH', }); }); it('has a non-empty text for every error code', () => { for (const code of VOICE_ERROR_CODES) { expect(VOICE_ERROR_TEXTS_DE[code].length).toBeGreaterThan(0); } }); it('tells operators only when the evaluation is off, without a user in the text', async () => { withStatus('FAILED'); await handlers.notifyFailed(failedEvent('NO_SPEECH'), asTx()); expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled(); await handlers.notifyFailed(failedEvent('EXTRACT_DISABLED'), asTx()); expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(1); const [permission, input] = notifications.recordForPermissionOnce.mock .calls[0] as [string, { message: string }]; expect(permission).toBe('system:manage'); expect(input.message).not.toMatch(/Volk|vc-1|u-1/); }); it('registers both handlers under stable ids', () => { const ready = Reflect.get(VoiceEventHandlers.prototype, 'notifyReady'); const failed = Reflect.get(VoiceEventHandlers.prototype, 'notifyFailed'); expect(domainEventHandlerMetadata(ready as object)).toEqual([ { types: ['VoiceCaptureReady'], handlerId: 'voice.notify-ready' }, ]); expect(domainEventHandlerMetadata(failed as object)).toEqual([ { types: ['VoiceCaptureFailed'], handlerId: 'voice.notify-failed' }, ]); }); });