Task 8: POST /inspections nimmt voiceCaptureId; die Notiz wird zuletzt in derselben Transaktion bestätigt (jeder offene Status, Ruling 1), Inhalt, Audio und Claim gelöscht; fremde oder geschlossene Notiz 409 VOICE_CAPTURE_GONE, anderes Volk 409 VOICE_CAPTURE_COLONY_MISMATCH; Wiederholung mit gleichem Schlüssel idempotent. Task 9: Ereignisse VoiceCaptureReady/VoiceCaptureFailed (nur IDs und Endcodes), Meldungen „Sprachnotiz für … ist fertig“ bzw. Fehlerhinweis mit nächstem Schritt, Betreiberhinweis bei abgeschalteter Auswertung; Handler auch im Worker registriert (sonst gingen die Ereignisse ungehört durch); keine Weiterleitung an Webhooks. Task 11: Job voice-transcribe mit Phasen fetch (Audio nur im Speicher entschlüsselt in ein RAM-Arbeitsverzeichnis 0700/0600) und ingest (Ergebnis gegen Claim geprüft, Transkript versiegelt, Audio gelöscht, Auswertung geweckt; Stille/zu lang enden FAILED); Ereignisse genau einmal in derselben Transaktion. Aufbewahrung: Audio 24 h, Inhalt bis contentExpiresAt, deaktivierte Besitzer, Zeilen 90 Tage; im täglichen Cleanup inkl. leerer Job-Läufe (Ruling 10). Gates: Backend lint, typecheck, 4471 Unit-Tests, build; e2e 103 Suites (einziger Fehler: Wetter-Test gegen echte API). Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
195 lines
6.4 KiB
TypeScript
195 lines
6.4 KiB
TypeScript
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<string, unknown>,
|
|
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' },
|
|
]);
|
|
});
|
|
});
|