import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { Prisma } from '../generated/prisma'; import { berlinToday } from '../src/common/dates'; import { PrismaService } from '../src/prisma/prisma.service'; import { 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 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 SPOKEN = 'Koenigin gesehen, Stifte da, vier Brutwaben, ruhig, Futter niedrig, Honigraum kommt naechste Woche drauf'; interface CaptureBody { id: string; status: string; colonyId?: string; colonyName?: string; lastErrorCode?: string | null; transcript?: string | null; suggestions?: { field: string; value: unknown; quote: string }[] | null; unclear?: string[]; dropped?: number; code?: string; message?: string; } /** * Runs in parallel with the other e2e suites on one database: everything * here belongs to this file's own users (found by the suffix in the e-mail), * every query is scoped to them, and afterAll deletes by suffix. */ describe('Voice captures (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; const emailOf = (name: string) => `voice-e2e-${name}-${suffix}@example.com`; const roleKey = `VOICE_E2E_ONLY_VOICE_${suffix}`; const inspectionsRoleKey = `VOICE_E2E_ONLY_INSPECTIONS_${suffix}`; const tokens: Record = {}; const userIds: Record = {}; let apiaryId: string; let colonyId: string; let deadColonyId: string; let keyCounter = 0; const server = () => app.getHttpServer(); const as = (name: string) => ({ Authorization: `Bearer ${tokens[name]}` }); const nextKey = () => `voice-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`; const today = () => berlinToday(); let addressCounter = 0; const nextAddress = () => { addressCounter += 1; return `10.${Math.floor(addressCounter / 250)}.${addressCounter % 250}.7`; }; function upload( who: string, options: { bytes?: Buffer; type?: string; colony?: string; date?: string; duration?: string; key?: string; } = {}, ) { return ( request(server()) .post('/api/v1/voice-captures') .set(as(who)) // Every upload comes from its own address: the IP throttle (10 a minute) // is not what these tests are about. .set('X-Forwarded-For', nextAddress()) .set('Idempotency-Key', options.key ?? nextKey()) .field('colonyId', options.colony ?? colonyId) .field('inspectionDate', options.date ?? today()) .field('durationSec', options.duration ?? '30') .attach('audio', options.bytes ?? WEBM, { filename: 'note.webm', contentType: options.type ?? 'audio/webm', }) ); } function sendText(who: string, text = SPOKEN, extra: object = {}) { return request(server()) .post('/api/v1/voice-captures/text') .set(as(who)) .set('Idempotency-Key', nextKey()) .send({ colonyId, inspectionDate: today(), text, ...extra }); } async function createUser( name: string, role: { key?: string; id?: string }, ): Promise { const passwordHash = await bcrypt.hash(password, 12); const user = await prisma.user.create({ data: { email: emailOf(name), passwordHash, name: `Voice E2E ${name}`, role: { connect: role.key ? { key: role.key } : { id: role.id } }, }, }); userIds[name] = user.id; tokens[name] = (await login(app, emailOf(name), password)).accessToken; } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const onlyVoice = await prisma.role.create({ data: { key: roleKey, name: 'Nur Sprache' }, }); const permission = await prisma.permission.findUniqueOrThrow({ where: { key: 'voice-captures:use' }, }); await prisma.rolePermission.create({ data: { roleId: onlyVoice.id, permissionId: permission.id }, }); const onlyInspections = await prisma.role.create({ data: { key: inspectionsRoleKey, name: 'Nur Durchsicht' }, }); const inspectionsPermission = await prisma.permission.findUniqueOrThrow({ where: { key: 'inspections:create' }, }); await prisma.rolePermission.create({ data: { roleId: onlyInspections.id, permissionId: inspectionsPermission.id, }, }); await createUser('alice', { key: 'BEEKEEPER' }); await createUser('bob', { key: 'BEEKEEPER' }); await createUser('limits', { key: 'BEEKEEPER' }); await createUser('admin', { key: 'ADMIN' }); await createUser('readonly', { key: 'READONLY' }); await createUser('onlyvoice', { id: onlyVoice.id }); await createUser('onlyinspections', { id: onlyInspections.id }); const apiary = await prisma.apiary.create({ data: { name: `Voice E2E Stand ${suffix}` }, }); apiaryId = apiary.id; colonyId = ( await prisma.colony.create({ data: { code: `VOICE-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }) ).id; deadColonyId = ( await prisma.colony.create({ data: { code: `VOICE-DEAD-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', status: 'DISSOLVED', }, }) ).id; }); afterAll(async () => { // By suffix, so a failed setup leaks nothing. const users = await prisma.user.findMany({ where: { email: { contains: suffix } }, select: { id: true }, }); const ids = users.map((user) => user.id); await prisma.voiceCapture.deleteMany({ where: { ownerUserId: { in: ids } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: ids }, entityType: 'VoiceCapture' }, }); 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.role.deleteMany({ where: { key: { in: [roleKey, inspectionsRoleKey] } }, }); await prisma.colony.deleteMany({ where: { code: { in: [`VOICE-${suffix}`, `VOICE-DEAD-${suffix}`] } }, }); await prisma.apiary.deleteMany({ where: { name: `Voice E2E Stand ${suffix}` }, }); await app.close(); }); const ownRows = (name: string) => prisma.voiceCapture.findMany({ where: { ownerUserId: userIds[name] } }); beforeEach(async () => { // Only the rows of this file's own users; other suites are untouched. await prisma.voiceCapture.deleteMany({ where: { ownerUserId: { in: Object.values(userIds) } }, }); }); describe('rights', () => { it('needs voice-captures:use AND inspections:create on every user endpoint', async () => { const calls: [string, string][] = [ ['get', '/api/v1/voice-captures'], ['get', '/api/v1/voice-captures/config'], ['get', '/api/v1/voice-captures/some-id'], ['post', '/api/v1/voice-captures/some-id/retry'], ['post', '/api/v1/voice-captures/some-id/discard'], ['post', '/api/v1/voice-captures/discard-all'], ]; for (const who of ['readonly', 'onlyvoice', 'onlyinspections']) { for (const [method, url] of calls) { const response = await request(server()) [method as 'get' | 'post'](url) .set(as(who)); expect({ who, url, status: response.status }).toEqual({ who, url, status: 403, }); } expect((await upload(who)).status).toBe(403); expect((await sendText(who)).status).toBe(403); } }); it('lets only system:manage read the counters', async () => { await request(server()) .get('/api/v1/voice-captures/stats') .set(as('alice')) .expect(403); const response = await request(server()) .get('/api/v1/voice-captures/stats') .set(as('admin')) .expect(200); expect(response.body).toEqual({ byStatus: expect.any(Object) as unknown, byError: expect.any(Object) as unknown, tokens: { input: expect.any(Number) as unknown, output: expect.any(Number) as unknown, }, byModel: expect.any(Object) as unknown, }); }); it('answers no-store', async () => { const response = await request(server()) .get('/api/v1/voice-captures') .set(as('alice')) .expect(200); expect(response.headers['cache-control']).toContain('no-store'); }); }); it('runs the database session in UTC, which the plain now() writes rely on', async () => { const rows = await prisma.$queryRaw<{ TimeZone: string }[]>`SHOW TimeZone`; expect(rows[0].TimeZone).toBe('UTC'); }); describe('upload', () => { it('stores the audio sealed and answers QUEUED', async () => { const response = await upload('alice').expect(201); expect(response.body).toEqual({ id: expect.any(String) as unknown, status: 'QUEUED', }); const id = (response.body as CaptureBody).id; const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, include: { audio: true }, }); expect(row).toMatchObject({ ownerUserId: userIds.alice, colonyId, source: 'AUDIO', status: 'QUEUED', audioMime: 'audio/webm', audioBytes: WEBM.length, durationSec: 30, attempts: 0, transcriptEnc: null, }); expect(row.contentExpiresAt.getTime()).toBeGreaterThan( row.createdAt.getTime() + 6.9 * 86_400_000, ); expect(row.audio).not.toBeNull(); expect(Buffer.from(row.audio!.dataEnc).includes(WEBM)).toBe(false); }); it('audits the creation without content', async () => { const id = ((await upload('alice').expect(201)).body as CaptureBody).id; const entries = await prisma.auditLog.findMany({ where: { entityType: 'VoiceCapture', entityId: id }, }); expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ action: 'CREATE', userId: userIds.alice, }); expect(JSON.stringify(entries[0].afterData)).not.toContain('opus'); }); it('is idempotent for a repeated key', async () => { const key = nextKey(); const first = await upload('alice', { key }).expect(201); const second = await upload('alice', { key }).expect(201); expect((second.body as CaptureBody).id).toBe( (first.body as CaptureBody).id, ); expect(await ownRows('alice')).toHaveLength(1); }); it('takes WebM bytes with a wrong Content-Type and refuses PNG bytes as audio', async () => { const ok = await upload('alice', { type: 'image/png' }).expect(201); const id = (ok.body as CaptureBody).id; const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, }); expect(row.audioMime).toBe('audio/webm'); const bad = await upload('alice', { bytes: PNG, type: 'audio/webm', }).expect(415); expect((bad.body as CaptureBody).code).toBe('UNSUPPORTED_AUDIO'); }); it('refuses a file above 2 MB with 413 TOO_LARGE', async () => { const big = Buffer.concat([WEBM, Buffer.alloc(2_097_152)]); const response = await upload('alice', { bytes: big }).expect(413); expect((response.body as CaptureBody).code).toBe('TOO_LARGE'); expect(await ownRows('alice')).toHaveLength(0); }); it('refuses a request without audio, a dead colony and bad dates', async () => { await request(server()) .post('/api/v1/voice-captures') .set(as('alice')) .field('colonyId', colonyId) .field('inspectionDate', today()) .field('durationSec', '30') .expect(400); await upload('alice', { colony: deadColonyId }).expect(400); await upload('alice', { colony: 'does-not-exist' }).expect(400); await upload('alice', { date: '2099-01-01' }).expect(400); await upload('alice', { date: '2020-01-01' }).expect(400); await upload('alice', { duration: '999' }).expect(400); expect(await ownRows('alice')).toHaveLength(0); }); }); describe('text', () => { it('stores the sealed text as TRANSCRIBED and gives it back to the owner only', async () => { const created = await sendText('alice').expect(201); const id = (created.body as CaptureBody).id; expect(created.body).toEqual({ id, status: 'TRANSCRIBED' }); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, }); expect(row.source).toBe('TEXT'); expect(row.transcriptEnc).not.toBeNull(); expect(row.transcriptEnc).not.toContain('Koenigin'); const detail = await request(server()) .get(`/api/v1/voice-captures/${id}`) .set(as('alice')) .expect(200); expect(detail.body).toMatchObject({ id, status: 'TRANSCRIBED', transcript: SPOKEN, suggestions: null, colonyName: `VOICE-${suffix}`, inspectionDate: today(), }); }); it('refuses a text outside 20 to 4000 characters', async () => { await sendText('alice', 'zu kurz').expect(400); await sendText('alice', 'a'.repeat(4001)).expect(400); await sendText('alice', 'a'.repeat(4000)).expect(201); }); }); describe('limits', () => { it('allows ten open notes and answers the eleventh with 429 TOO_MANY_OPEN', async () => { for (let index = 0; index < 10; index += 1) { await sendText('limits').expect(201); } const response = await sendText('limits').expect(429); expect(response.body).toMatchObject({ code: 'TOO_MANY_OPEN', message: 'Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.', }); expect(await ownRows('limits')).toHaveLength(10); }); it('counts per user: another person is not blocked', async () => { for (let index = 0; index < 10; index += 1) { await sendText('limits').expect(201); } await sendText('alice').expect(201); }); it('does not count finished notes as open, but counts them per day and minute', async () => { for (let index = 0; index < 10; index += 1) { const id = ((await sendText('limits').expect(201)).body as CaptureBody) .id; await request(server()) .post(`/api/v1/voice-captures/${id}/discard`) .set(as('limits')) .expect(204); } // No note is open any more, but ten were created within the minute. await sendText('limits').expect(429); }); it('limits new notes to thirty per UTC day', async () => { const startOfUtcDay = new Date().setUTCHours(0, 0, 0, 0); const createdAt = new Date(Math.max(Date.now() - 90_000, startOfUtcDay)); await prisma.voiceCapture.createMany({ data: Array.from({ length: 30 }, () => ({ ownerUserId: userIds.limits, colonyId, inspectionDate: new Date(`${today()}T00:00:00.000Z`), source: 'TEXT' as const, status: 'DISCARDED' as const, contentExpiresAt: new Date(Date.now() + 86_400_000), createdAt, })), }); const response = await sendText('limits').expect(429); expect((response.body as CaptureBody).code).toBe('TOO_MANY_OPEN'); }); }); describe('list and get', () => { it('lists only the own notes, newest first, without content', async () => { const first = ((await sendText('alice').expect(201)).body as CaptureBody) .id; const second = ((await upload('alice').expect(201)).body as CaptureBody) .id; await sendText('bob').expect(201); const response = await request(server()) .get('/api/v1/voice-captures') .set(as('alice')) .expect(200); const items = response.body as CaptureBody[]; expect(items.map((item) => item.id)).toEqual([second, first]); for (const item of items) { expect(item).not.toHaveProperty('transcript'); expect(item).not.toHaveProperty('suggestions'); expect(item.colonyName).toBe(`VOICE-${suffix}`); } expect(JSON.stringify(items)).not.toContain('Koenigin'); }); it('filters by status and refuses an unknown status', async () => { await sendText('alice').expect(201); await upload('alice').expect(201); const queued = await request(server()) .get('/api/v1/voice-captures?status=QUEUED') .set(as('alice')) .expect(200); expect((queued.body as CaptureBody[]).map((item) => item.status)).toEqual( ['QUEUED'], ); await request(server()) .get('/api/v1/voice-captures?status=NOPE') .set(as('alice')) .expect(400); }); it('serves config before the :id route', async () => { const response = await request(server()) .get('/api/v1/voice-captures/config') .set(as('alice')) .expect(200); expect(response.body).toEqual({ recordingEnabled: expect.any(Boolean) as unknown, extractionEnabled: expect.any(Boolean) as unknown, model: expect.stringMatching(/^claude-/) as unknown, retention: { audioHours: 24, contentDays: 7, rowDays: 90 }, }); }); it('reports extraction as off while a note is parked with EXTRACT_DISABLED', async () => { await sendText('alice').expect(201); await prisma.voiceCapture.updateMany({ where: { ownerUserId: userIds.alice }, data: { lastErrorCode: 'EXTRACT_DISABLED' }, }); const response = await request(server()) .get('/api/v1/voice-captures/config') .set(as('alice')) .expect(200); expect( (response.body as { extractionEnabled: boolean }).extractionEnabled, ).toBe(false); }); it('shows suggestions of a READY note and hides content while it is being worked on', async () => { const id = ((await sendText('alice').expect(201)).body as CaptureBody).id; const payload = { suggestions: [ { field: 'queenSeen', value: true, quote: 'Koenigin gesehen', corrected: false, truncated: false, }, { field: 'broodFrames', value: 4, quote: 'vier Brutwaben', corrected: false, truncated: false, }, ], unclear: ['Etwas zum Futter'], dropped: 1, truncated: 0, }; await prisma.voiceCapture.update({ where: { id }, data: { status: 'READY', suggestionEnc: sealVoiceText( JSON.stringify(payload), voiceAad(id, 'suggestion'), ), }, }); const ready = await request(server()) .get(`/api/v1/voice-captures/${id}`) .set(as('alice')) .expect(200); expect(ready.body).toMatchObject({ status: 'READY', transcript: SPOKEN, unclear: ['Etwas zum Futter'], dropped: 1, }); expect((ready.body as CaptureBody).suggestions).toEqual([ { field: 'queenSeen', value: true, quote: 'Koenigin gesehen', corrected: false, truncated: false, }, { field: 'broodFrames', value: 4, quote: 'vier Brutwaben', corrected: false, truncated: false, }, ]); await prisma.voiceCapture.update({ where: { id }, data: { status: 'EXTRACTING' }, }); const working = await request(server()) .get(`/api/v1/voice-captures/${id}`) .set(as('alice')) .expect(200); expect(working.body).toMatchObject({ transcript: null, suggestions: null, }); }); }); describe('foreign ids', () => { it('answers 404 to another person on get, retry and discard, and changes nothing', async () => { const id = ((await sendText('alice').expect(201)).body as CaptureBody).id; await request(server()) .get(`/api/v1/voice-captures/${id}`) .set(as('bob')) .expect(404); await request(server()) .post(`/api/v1/voice-captures/${id}/retry`) .set('X-Forwarded-For', nextAddress()) .set(as('bob')) .expect(404); await request(server()) .post(`/api/v1/voice-captures/${id}/discard`) .set(as('bob')) .expect(404); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, }); expect(row.status).toBe('TRANSCRIBED'); expect(row.transcriptEnc).not.toBeNull(); await request(server()) .get('/api/v1/voice-captures/never-existed') .set(as('alice')) .expect(404); }); }); describe('retry', () => { async function failed( who: string, data: Prisma.VoiceCaptureUncheckedUpdateInput, options: { audio?: boolean; text?: boolean } = {}, ): Promise { const { audio = true, text = false } = options; const id = ((await upload(who).expect(201)).body as CaptureBody).id; if (!audio) { await prisma.voiceCaptureAudio.deleteMany({ where: { captureId: id } }); } await prisma.voiceCapture.update({ where: { id }, data: { status: 'FAILED', attempts: 3, ...(text ? { transcriptEnc: sealTranscript(id, { v: 1, text: SPOKEN }) } : {}), ...data, }, }); return id; } it('puts a failed transcription back to QUEUED with attempts 0', async () => { const id = await failed('alice', { lastErrorCode: 'TRANSCRIBE_FAILED' }); const response = await request(server()) .post(`/api/v1/voice-captures/${id}/retry`) .set('X-Forwarded-For', nextAddress()) .set(as('alice')) .expect(200); expect(response.body).toMatchObject({ id, status: 'QUEUED', lastErrorCode: null, }); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, }); expect(row).toMatchObject({ status: 'QUEUED', attempts: 0, lastErrorCode: null, claimToken: null, }); }); it('puts a failed extraction with text back to TRANSCRIBED with attempts 0', async () => { const id = await failed( 'alice', { lastErrorCode: 'EXTRACT_FAILED' }, { audio: false, text: true }, ); const response = await request(server()) .post(`/api/v1/voice-captures/${id}/retry`) .set('X-Forwarded-For', nextAddress()) .set(as('alice')) .expect(200); expect((response.body as CaptureBody).status).toBe('TRANSCRIBED'); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, }); expect(row.attempts).toBe(0); }); it('counts a retry against the open limit', async () => { const id = await failed( 'limits', { lastErrorCode: 'EXTRACT_FAILED' }, { audio: false, text: true }, ); // Rows made directly: ten API calls would also hit the per-minute limit. await prisma.voiceCapture.createMany({ data: Array.from({ length: 10 }, () => ({ ownerUserId: userIds.limits, colonyId, inspectionDate: new Date(`${today()}T00:00:00.000Z`), source: 'TEXT' as const, status: 'TRANSCRIBED' as const, contentExpiresAt: new Date(Date.now() + 86_400_000), })), }); const response = await request(server()) .post(`/api/v1/voice-captures/${id}/retry`) .set('X-Forwarded-For', nextAddress()) .set(as('limits')) .expect(429); expect((response.body as CaptureBody).code).toBe('TOO_MANY_OPEN'); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, }); expect(row.status).toBe('FAILED'); }); it('refuses to retry a transcription whose audio is older than 24 hours', async () => { const id = await failed('alice', { lastErrorCode: 'TRANSCRIBE_FAILED' }); await prisma.voiceCaptureAudio.update({ where: { captureId: id }, data: { createdAt: new Date(Date.now() - 25 * 3_600_000) }, }); const response = await request(server()) .post(`/api/v1/voice-captures/${id}/retry`) .set('X-Forwarded-For', nextAddress()) .set(as('alice')) .expect(409); expect((response.body as CaptureBody).code).toBe('VOICE_CAPTURE_GONE'); }); it('refuses a failed note without content, an expired one and one that is not failed', async () => { const noContent = await failed( 'alice', { lastErrorCode: 'NO_SPEECH' }, { audio: false }, ); const gone = await request(server()) .post(`/api/v1/voice-captures/${noContent}/retry`) .set('X-Forwarded-For', nextAddress()) .set(as('alice')) .expect(409); expect((gone.body as CaptureBody).code).toBe('VOICE_CAPTURE_GONE'); const expired = await failed('alice', { lastErrorCode: 'TRANSCRIBE_FAILED', contentExpiresAt: new Date(Date.now() - 60_000), }); const late = await request(server()) .post(`/api/v1/voice-captures/${expired}/retry`) .set('X-Forwarded-For', nextAddress()) .set(as('alice')) .expect(409); expect((late.body as CaptureBody).code).toBe('VOICE_CAPTURE_GONE'); const queued = ((await upload('alice').expect(201)).body as CaptureBody) .id; await request(server()) .post(`/api/v1/voice-captures/${queued}/retry`) .set('X-Forwarded-For', nextAddress()) .set(as('alice')) .expect(409); }); }); describe('discard', () => { it('deletes audio, transcript and suggestion at once and is idempotent', async () => { const audioId = ((await upload('alice').expect(201)).body as CaptureBody) .id; const textId = ((await sendText('alice').expect(201)).body as CaptureBody) .id; await prisma.voiceCapture.update({ where: { id: textId }, data: { status: 'READY', suggestionEnc: sealVoiceText('{"suggestions":[]}', 'x'), }, }); for (const id of [audioId, textId]) { await request(server()) .post(`/api/v1/voice-captures/${id}/discard`) .set(as('alice')) .expect(204); await request(server()) .post(`/api/v1/voice-captures/${id}/discard`) .set(as('alice')) .expect(204); } const rows = await prisma.voiceCapture.findMany({ where: { id: { in: [audioId, textId] } }, include: { audio: true }, }); expect(rows).toHaveLength(2); for (const row of rows) { expect(row).toMatchObject({ status: 'DISCARDED', transcriptEnc: null, suggestionEnc: null, audio: null, claimToken: null, }); expect(row.contentDeletedAt).not.toBeNull(); } }); it('also stops a note a worker has claimed', async () => { const id = ((await upload('alice').expect(201)).body as CaptureBody).id; await prisma.voiceCapture.update({ where: { id }, data: { status: 'TRANSCRIBING', claimToken: 'token-of-a-running-job', claimedAt: new Date(), }, }); await request(server()) .post(`/api/v1/voice-captures/${id}/discard`) .set(as('alice')) .expect(204); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, }); expect(row).toMatchObject({ status: 'DISCARDED', claimToken: null, claimedAt: null, }); }); it('leaves a confirmed note as it is', async () => { const id = ((await sendText('alice').expect(201)).body as CaptureBody).id; const inspection = await prisma.inspection.create({ data: { colonyId, userId: userIds.alice, date: new Date() }, }); await prisma.voiceCapture.update({ where: { id }, data: { status: 'CONFIRMED', inspectionId: inspection.id, transcriptEnc: null, }, }); await request(server()) .post(`/api/v1/voice-captures/${id}/discard`) .set(as('alice')) .expect(204); const row = await prisma.voiceCapture.findUniqueOrThrow({ where: { id }, }); expect(row.status).toBe('CONFIRMED'); await prisma.voiceCapture.delete({ where: { id } }); await prisma.inspection.delete({ where: { id: inspection.id } }); }); it('discard-all touches only the caller and counts what it discarded', async () => { const mine = [ ((await sendText('alice').expect(201)).body as CaptureBody).id, ((await upload('alice').expect(201)).body as CaptureBody).id, ]; const done = ((await sendText('alice').expect(201)).body as CaptureBody) .id; await prisma.voiceCapture.update({ where: { id: done }, data: { status: 'EXPIRED', transcriptEnc: null }, }); const others = ((await sendText('bob').expect(201)).body as CaptureBody) .id; const response = await request(server()) .post('/api/v1/voice-captures/discard-all') .set(as('alice')) .expect(200); expect(response.body).toEqual({ discarded: 2 }); const rows = await prisma.voiceCapture.findMany({ where: { id: { in: [...mine, done, others] } }, include: { audio: true }, }); const byId = new Map(rows.map((row) => [row.id, row])); for (const id of mine) { expect(byId.get(id)).toMatchObject({ status: 'DISCARDED', transcriptEnc: null, audio: null, }); } expect(byId.get(done)?.status).toBe('EXPIRED'); expect(byId.get(others)).toMatchObject({ status: 'TRANSCRIBED' }); expect(byId.get(others)?.transcriptEnc).not.toBeNull(); }); }); });