import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { berlinToday } from '../src/common/dates'; import { AuditLogService } from '../src/audit/audit-log.service'; import { PrismaService } from '../src/prisma/prisma.service'; import { sealVoiceBytes, sealVoiceText, voiceAad, } from '../src/voice/core/voice-crypto'; import { sealTranscript } from '../src/voice/core/transcript'; import { login } from './utils/api'; import { createTestApp } from './utils/create-test-app'; const SPOKEN = 'Koenigin gesehen, Stifte da, vier Brutwaben, ruhig, Futter niedrig, Honigraum kommt naechste Woche drauf'; const WEBM = Buffer.concat([ Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.from('opus-bytes-of-a-spoken-note'), ]); interface Body { id?: string; code?: string; } /** * Parallel-safe: own users, colonies and apiary found by the suffix; every * query is scoped to them and afterAll deletes by suffix. */ describe('Voice capture confirmation in POST /inspections (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; const emailOf = (name: string) => `voice-conf-${name}-${suffix}@example.com`; const tokens: Record = {}; const userIds: Record = {}; let apiaryId: string; let colonyId: string; let otherColonyId: string; let keyCounter = 0; const nextKey = () => `voice-conf-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`; const as = (name: string) => ({ Authorization: `Bearer ${tokens[name]}` }); const colonyIds = () => [colonyId, otherColonyId]; async function createUser(name: string): Promise { const user = await prisma.user.create({ data: { email: emailOf(name), passwordHash: await bcrypt.hash(password, 12), name: `Voice Conf ${name}`, role: { connect: { key: 'BEEKEEPER' } }, }, }); userIds[name] = user.id; tokens[name] = (await login(app, emailOf(name), password)).accessToken; } /** A capture with sealed content and an audio row, as the pipeline leaves it. */ async function seedCapture( owner: string, status: string, options: { colony?: string } = {}, ): Promise { const capture = await prisma.voiceCapture.create({ data: { ownerUserId: userIds[owner], colonyId: options.colony ?? colonyId, inspectionDate: new Date(`${berlinToday()}T00:00:00.000Z`), source: 'AUDIO', status: status as never, audioMime: 'audio/webm', audioBytes: WEBM.length, durationSec: 30, contentExpiresAt: new Date(Date.now() + 86_400_000), claimedAt: status === 'TRANSCRIBING' || status === 'EXTRACTING' ? new Date() : null, claimToken: status === 'TRANSCRIBING' || status === 'EXTRACTING' ? 'running-token' : null, }, }); await prisma.voiceCapture.update({ where: { id: capture.id }, data: { transcriptEnc: sealTranscript(capture.id, { v: 1, text: SPOKEN }), suggestionEnc: sealVoiceText( JSON.stringify({ suggestions: [], unclear: [], dropped: 0 }), voiceAad(capture.id, 'suggestion'), ), }, }); await prisma.voiceCaptureAudio.create({ data: { captureId: capture.id, dataEnc: new Uint8Array( sealVoiceBytes(WEBM, voiceAad(capture.id, 'audio')), ), }, }); return capture.id; } function createInspection( who: string, body: Record, key = nextKey(), ) { return request(app.getHttpServer()) .post('/api/v1/inspections') .set(as(who)) .set('Idempotency-Key', key) .send({ colonyId, date: berlinToday(), ...body }); } const inspectionsOfColony = () => prisma.inspection.findMany({ where: { colonyId } }); beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); await createUser('alice'); await createUser('bob'); apiaryId = ( await prisma.apiary.create({ data: { name: `Voice Conf Stand ${suffix}` }, }) ).id; colonyId = ( await prisma.colony.create({ data: { code: `VCONF-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }) ).id; otherColonyId = ( await prisma.colony.create({ data: { code: `VCONF-B-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }) ).id; }); afterAll(async () => { const ids = Object.values(userIds); const inspectionIds = ( await prisma.inspection.findMany({ where: { colonyId: { in: colonyIds() } }, select: { id: true }, }) ).map((inspection) => inspection.id); await prisma.voiceCapture.deleteMany({ where: { ownerUserId: { in: ids } }, }); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'Inspection', aggregateId: { in: inspectionIds }, }, }); await prisma.calendarSyncOutboxEntry.deleteMany({ where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } }, }); await prisma.inspection.deleteMany({ where: { colonyId: { in: colonyIds() } }, }); await prisma.suggestion.deleteMany({ where: { colonyId: { in: colonyIds() } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: ids } } }); await prisma.idempotencyRecord.deleteMany({ where: { userId: { in: ids } }, }); await prisma.refreshToken.deleteMany({ where: { userId: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds() } } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await app.close(); }); beforeEach(async () => { await prisma.voiceCapture.deleteMany({ where: { ownerUserId: { in: Object.values(userIds) } }, }); await prisma.inspection.deleteMany({ where: { colonyId: { in: colonyIds() } }, }); }); it('confirms the voice capture and wipes its content in the same transaction', async () => { const id = await seedCapture('alice', 'READY'); const response = await createInspection('alice', { voiceCaptureId: id, colonyStrength: 12, }).expect(201); const inspectionId = (response.body as Body).id; expect(inspectionId).toBeDefined(); expect(response.body).not.toHaveProperty('voiceCaptureId'); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id } }); expect(row).toMatchObject({ status: 'CONFIRMED', inspectionId, transcriptEnc: null, suggestionEnc: null, claimToken: null, claimedAt: null, }); expect(row.contentDeletedAt).not.toBeNull(); expect( await prisma.voiceCaptureAudio.count({ where: { captureId: id } }), ).toBe(0); const audit = await prisma.auditLog.findMany({ where: { entityType: 'VoiceCapture', entityId: id }, }); expect(audit).toHaveLength(1); expect(JSON.stringify(audit)).not.toContain('Koenigin'); }); it.each(['QUEUED', 'TRANSCRIBED', 'FAILED'])( 'confirms a %s capture too', async (status) => { const id = await seedCapture('alice', status); await createInspection('alice', { voiceCaptureId: id }).expect(201); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, }); expect(row.status).toBe('CONFIRMED'); expect(row.transcriptEnc).toBeNull(); }, ); it.each(['TRANSCRIBING', 'EXTRACTING'])( 'takes a %s capture away from a running job: its claim is gone', async (status) => { const id = await seedCapture('alice', status); await createInspection('alice', { voiceCaptureId: id }).expect(201); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, }); expect(row).toMatchObject({ status: 'CONFIRMED', claimToken: null }); }, ); it('answers 409 VOICE_CAPTURE_GONE for a discarded, expired or foreign capture and writes no inspection', async () => { const discarded = await seedCapture('alice', 'DISCARDED'); const expired = await seedCapture('alice', 'EXPIRED'); const foreign = await seedCapture('bob', 'READY'); for (const id of [discarded, expired, foreign, 'does-not-exist']) { const response = await createInspection('alice', { voiceCaptureId: id, }).expect(409); expect((response.body as Body).code).toBe('VOICE_CAPTURE_GONE'); } expect(await inspectionsOfColony()).toHaveLength(0); const untouched = await prisma.voiceCapture.findUniqueOrThrow({ where: { id: foreign }, }); expect(untouched.status).toBe('READY'); expect(untouched.transcriptEnc).not.toBeNull(); }); it('answers 409 when the capture belongs to another colony and keeps its content', async () => { const id = await seedCapture('alice', 'READY', { colony: otherColonyId }); const response = await createInspection('alice', { voiceCaptureId: id, }).expect(409); expect((response.body as Body).code).toBe('VOICE_CAPTURE_COLONY_MISMATCH'); expect(await inspectionsOfColony()).toHaveLength(0); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id } }); expect(row.status).toBe('READY'); expect(row.transcriptEnc).not.toBeNull(); }); it('rolls back inspection and confirmation when the transaction fails after the confirmation ran', async () => { const id = await seedCapture('alice', 'READY'); const auditLog: AuditLogService = app.get(AuditLogService); const original = auditLog.record.bind( auditLog, ) as AuditLogService['record']; // The confirmation's own audit entry is its last write: failing there // proves the earlier UPDATE and audio DELETE are rolled back with it. const spy = jest .spyOn(auditLog, 'record') .mockImplementation((input, tx) => { if (input.entityType === 'VoiceCapture') { return Promise.reject(new Error('boom after confirm')); } return original(input, tx); }); try { const response = await createInspection('alice', { voiceCaptureId: id, }); expect(response.status).toBe(500); } finally { spy.mockRestore(); } expect(await inspectionsOfColony()).toHaveLength(0); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id } }); expect(row).toMatchObject({ status: 'READY', inspectionId: null }); expect(row.transcriptEnc).not.toBeNull(); expect(row.contentDeletedAt).toBeNull(); expect( await prisma.voiceCaptureAudio.count({ where: { captureId: id } }), ).toBe(1); }); it('rejects a malformed voiceCaptureId with 400', async () => { await createInspection('alice', { voiceCaptureId: 'a b/c' }).expect(400); await createInspection('alice', { voiceCaptureId: 'x'.repeat(41) }).expect( 400, ); }); it('replays the stored answer for the same Idempotency-Key without failing on the confirmed capture', async () => { const id = await seedCapture('alice', 'READY'); const key = nextKey(); const first = await createInspection( 'alice', { voiceCaptureId: id }, key, ).expect(201); const second = await createInspection( 'alice', { voiceCaptureId: id }, key, ).expect(201); expect((second.body as Body).id).toBe((first.body as Body).id); expect(await inspectionsOfColony()).toHaveLength(1); }); it('answers 409 for a second inspection with a new key on the same capture', async () => { const id = await seedCapture('alice', 'READY'); await createInspection('alice', { voiceCaptureId: id }).expect(201); const response = await createInspection('alice', { voiceCaptureId: id, }).expect(409); expect((response.body as Body).code).toBe('VOICE_CAPTURE_GONE'); expect(await inspectionsOfColony()).toHaveLength(1); }); it('creates an inspection without voiceCaptureId exactly as before and leaves other captures alone', async () => { const id = await seedCapture('alice', 'READY'); await createInspection('alice', { colonyStrength: 10 }).expect(201); expect(await inspectionsOfColony()).toHaveLength(1); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id } }); expect(row.status).toBe('READY'); }); });