import { HttpException, Logger } from '@nestjs/common'; import { openVoiceBytes, voiceAad } from '../core/voice-crypto'; import { openTranscript } from '../core/transcript'; import { VoiceCapturesService } from './voice-captures.service'; const WEBM = Buffer.concat([ Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.from('opus-bytes-of-a-spoken-note'), ]); const PNG = Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from('not audio'), ]); const SECRET_TEXT = 'Koenigin gesehen, Stifte da, vier Brutwaben, ruhig, Futter niedrig'; function file(buffer: Buffer, mimetype = 'audio/webm') { return { buffer, size: buffer.length, mimetype, } as unknown as Express.Multer.File; } /** The first argument of a recorded call, typed by the caller. */ function firstArg(mock: jest.Mock, call = 0): T { return (mock.mock.calls as unknown[][])[call][0] as T; } function codeOf(error: unknown): unknown { return ( (error as HttpException).getResponse() as { code?: string } | undefined )?.code; } async function failureOf(promise: Promise): Promise { try { await promise; } catch (error) { return error as HttpException; } throw new Error('expected the call to fail'); } describe('VoiceCapturesService', () => { const originalEnv = { ...process.env }; let service: VoiceCapturesService; let tx: { $executeRaw: jest.Mock; $queryRaw: jest.Mock; colony: { findFirst: jest.Mock }; voiceCapture: { create: jest.Mock; update: jest.Mock; findFirst: jest.Mock; }; voiceCaptureAudio: { create: jest.Mock }; }; let prisma: { $transaction: jest.Mock; voiceCapture: { findFirst: jest.Mock; findMany: jest.Mock }; }; let auditLog: { record: jest.Mock }; let counts: { open: number; today: number; minute: number }; beforeEach(() => { jest.useFakeTimers({ now: new Date('2026-10-16T10:00:00.000Z') }); process.env.JWT_ACCESS_SECRET = 'j'.repeat(40); process.env.VOICE_ENCRYPTION_KEY = 'v'.repeat(40); counts = { open: 0, today: 0, minute: 0 }; const findFirst = jest.fn(); tx = { $executeRaw: jest.fn().mockResolvedValue(1), $queryRaw: jest.fn().mockImplementation(() => Promise.resolve([counts])), colony: { findFirst: jest.fn().mockResolvedValue({ id: 'colony-1' }) }, voiceCapture: { create: jest.fn().mockResolvedValue({ id: 'cap-1' }), update: jest.fn().mockResolvedValue({ id: 'cap-1' }), findFirst, }, voiceCaptureAudio: { create: jest.fn().mockResolvedValue({}) }, }; prisma = { $transaction: jest .fn() .mockImplementation((fn: (client: typeof tx) => unknown) => fn(tx)), voiceCapture: { findFirst, findMany: jest.fn() }, }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; service = new VoiceCapturesService(prisma as never, auditLog as never); }); afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); process.env = { ...originalEnv }; }); const uploadDto = { colonyId: 'colony-1', inspectionDate: '2026-10-16', durationSec: 42, }; describe('upload', () => { it('stores the sealed audio under the capture id and answers QUEUED', async () => { await expect( service.upload('user-1', file(WEBM), uploadDto), ).resolves.toEqual({ id: 'cap-1', status: 'QUEUED', }); const created = firstArg<{ data: object }>(tx.voiceCapture.create).data; expect(created).toMatchObject({ ownerUserId: 'user-1', colonyId: 'colony-1', source: 'AUDIO', status: 'QUEUED', audioMime: 'audio/webm', audioBytes: WEBM.length, durationSec: 42, }); const sealed = firstArg<{ data: { captureId: string; dataEnc: Uint8Array }; }>(tx.voiceCaptureAudio.create).data; expect(sealed.captureId).toBe('cap-1'); expect( openVoiceBytes(Buffer.from(sealed.dataEnc), voiceAad('cap-1', 'audio')), ).toEqual(WEBM); expect(Buffer.from(sealed.dataEnc).equals(WEBM)).toBe(false); }); it('takes the per-user advisory lock before it counts', async () => { await service.upload('user-1', file(WEBM), uploadDto); const lockOrder = [ tx.$executeRaw.mock.invocationCallOrder[0], tx.$queryRaw.mock.invocationCallOrder[0], ]; expect(lockOrder[0]).toBeLessThan(lockOrder[1]); const strings = ( tx.$executeRaw.mock.calls[0] as unknown[] )[0] as string[]; expect(strings.join('?')).toContain('pg_advisory_xact_lock'); }); it('audits the creation without any content', async () => { await service.upload('user-1', file(WEBM), uploadDto); const entry = firstArg(auditLog.record); expect(entry).toMatchObject({ entityType: 'VoiceCapture', entityId: 'cap-1', action: 'CREATE', userId: 'user-1', }); expect(JSON.stringify(entry)).not.toContain('opus-bytes'); }); it('checks magic bytes, not the declared type', async () => { await expect( service.upload('user-1', file(WEBM, 'image/png'), uploadDto), ).resolves.toMatchObject({ status: 'QUEUED' }); expect( firstArg<{ data: { audioMime: string } }>(tx.voiceCapture.create).data .audioMime, ).toBe('audio/webm'); const error = await failureOf( service.upload('user-1', file(PNG, 'audio/webm'), uploadDto), ); expect(error.getStatus()).toBe(415); expect(codeOf(error)).toBe('UNSUPPORTED_AUDIO'); }); it('rejects a file above 2 MB with 413 TOO_LARGE', async () => { const big = Buffer.concat([WEBM, Buffer.alloc(2_097_152)]); const error = await failureOf( service.upload('user-1', file(big), uploadDto), ); expect(error.getStatus()).toBe(413); expect(codeOf(error)).toBe('TOO_LARGE'); }); it('rejects a dead or unknown colony with 400', async () => { tx.colony.findFirst.mockResolvedValue(null); const error = await failureOf( service.upload('user-1', file(WEBM), uploadDto), ); expect(error.getStatus()).toBe(400); expect(tx.voiceCapture.create).not.toHaveBeenCalled(); }); it.each([ ['in the future', '2026-10-17'], ['older than 7 days', '2026-10-08'], ['not a day', '2026-02-30'], ])('rejects an inspectionDate %s', async (_label, inspectionDate) => { const error = await failureOf( service.upload('user-1', file(WEBM), { ...uploadDto, inspectionDate }), ); expect(error.getStatus()).toBe(400); }); it.each(['2026-10-16', '2026-10-09'])( 'accepts the inspectionDate %s (today and seven days back, Berlin day)', async (inspectionDate) => { await expect( service.upload('user-1', file(WEBM), { ...uploadDto, inspectionDate, }), ).resolves.toMatchObject({ status: 'QUEUED' }); }, ); it('limits open captures to ten', async () => { counts.open = 10; const error = await failureOf( service.upload('user-1', file(WEBM), uploadDto), ); expect(error.getStatus()).toBe(429); expect(codeOf(error)).toBe('TOO_MANY_OPEN'); expect((error.getResponse() as { message: string }).message).toBe( 'Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.', ); }); it('limits new captures to thirty per UTC day', async () => { counts.today = 30; const error = await failureOf( service.upload('user-1', file(WEBM), uploadDto), ); expect(error.getStatus()).toBe(429); expect(codeOf(error)).toBe('TOO_MANY_OPEN'); }); it('limits ten new captures per minute per user', async () => { counts.minute = 10; const error = await failureOf( service.upload('user-1', file(WEBM), uploadDto), ); expect(error.getStatus()).toBe(429); expect(tx.voiceCapture.create).not.toHaveBeenCalled(); }); it('counts only the caller and uses the database clock', async () => { await service.upload('user-1', file(WEBM), uploadDto); const query = (tx.$queryRaw.mock.calls[0] as unknown[])[0] as { sql: string; values: unknown[]; }; expect(query.values).toContain('user-1'); expect(query.sql).toContain('now()'); expect(query.sql).toContain('"ownerUserId"'); }); }); describe('submitText', () => { const textDto = { colonyId: 'colony-1', inspectionDate: '2026-10-16', text: SECRET_TEXT, }; it('stores the sealed transcript and wakes the extraction loop', async () => { await expect(service.submitText('user-1', textDto)).resolves.toEqual({ id: 'cap-1', status: 'TRANSCRIBED', }); expect( firstArg<{ data: object }>(tx.voiceCapture.create).data, ).toMatchObject({ source: 'TEXT', status: 'TRANSCRIBED', }); const stored = firstArg<{ data: { transcriptEnc: string } }>( tx.voiceCapture.update, ).data.transcriptEnc; expect(stored).not.toContain('Koenigin'); expect(openTranscript('cap-1', stored).text).toBe(SECRET_TEXT); const wake = (tx.$executeRaw.mock.calls as unknown[][]).find((call) => JSON.stringify(call).includes('pg_notify'), ); expect(wake).toBeDefined(); expect(JSON.stringify(wake)).toContain('voice_extraction'); }); it('normalises the text and enforces 20 to 4000 characters', async () => { const short = await failureOf( service.submitText('user-1', { ...textDto, text: 'zu kurz' }), ); expect(short.getStatus()).toBe(400); const padded = await failureOf( service.submitText('user-1', { ...textDto, text: `${'\u0000'.repeat(30)}kurz`, }), ); expect(padded.getStatus()).toBe(400); const long = await failureOf( service.submitText('user-1', { ...textDto, text: 'a'.repeat(4001) }), ); expect(long.getStatus()).toBe(400); await expect( service.submitText('user-1', { ...textDto, text: 'a'.repeat(4000) }), ).resolves.toMatchObject({ status: 'TRANSCRIBED' }); }); it('shares the colony, date and limit rules with the upload', async () => { counts.open = 10; const error = await failureOf(service.submitText('user-1', textDto)); expect(codeOf(error)).toBe('TOO_MANY_OPEN'); }); it('never logs the transcript', async () => { const spies = (['log', 'warn', 'error', 'debug', 'verbose'] as const).map( (level) => jest .spyOn(Logger.prototype, level) .mockImplementation(() => undefined), ); await service.submitText('user-1', textDto); await failureOf( service.submitText('user-1', { ...textDto, inspectionDate: '2026-10-30', }), ); counts.open = 10; await failureOf(service.submitText('user-1', textDto)); const logged = JSON.stringify(spies.flatMap((spy) => spy.mock.calls)); expect(logged).not.toContain('Koenigin'); expect(JSON.stringify(auditLog.record.mock.calls)).not.toContain( 'Koenigin', ); }); }); describe('foreign ids', () => { it.each(['get', 'retry', 'discard'] as const)( 'rejects a foreign id with 404 on %s', async (method) => { tx.voiceCapture.findFirst.mockResolvedValue(null); const error = await failureOf(service[method]('user-1', 'cap-9')); expect(error.getStatus()).toBe(404); expect( firstArg<{ where: object }>(tx.voiceCapture.findFirst).where, ).toEqual({ id: 'cap-9', ownerUserId: 'user-1', }); }, ); }); });