Files
ApiCore/ApiCore-Backend/test/voice-captures.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 462ac46d1e fix: Sprachnotizen werden bei ausgeschalteter Aufnahme abgelehnt
POST /voice-captures und /voice-captures/text antworten mit 403
VOICE_RECORDING_DISABLED („Sprachnotizen sind gerade ausgeschaltet.“),
solange VOICE_RECORDING_ENABLED nicht true ist; Liste, Abruf,
Wiederholen und Verwerfen bleiben möglich (Ruling 14).

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:36:47 +02:00

938 lines
32 KiB
TypeScript

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<App>;
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<string, string> = {};
const userIds: Record<string, string> = {};
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<void> {
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;
}
const originalRecording = process.env.VOICE_RECORDING_ENABLED;
beforeAll(async () => {
// Notes are only accepted while recording is on; this file turns it on.
process.env.VOICE_RECORDING_ENABLED = 'true';
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();
if (originalRecording === undefined) {
delete process.env.VOICE_RECORDING_ENABLED;
} else {
process.env.VOICE_RECORDING_ENABLED = originalRecording;
}
});
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('recording switch', () => {
it('refuses new notes while off but still lists, reads and discards', async () => {
const id = ((await sendText('alice').expect(201)).body as CaptureBody).id;
process.env.VOICE_RECORDING_ENABLED = 'false';
try {
const up = await upload('alice').expect(403);
expect(up.body).toMatchObject({
code: 'VOICE_RECORDING_DISABLED',
message: 'Sprachnotizen sind gerade ausgeschaltet.',
});
await sendText('alice').expect(403);
await request(server())
.get('/api/v1/voice-captures')
.set(as('alice'))
.expect(200);
await request(server())
.get(`/api/v1/voice-captures/${id}`)
.set(as('alice'))
.expect(200);
await request(server())
.post(`/api/v1/voice-captures/${id}/discard`)
.set(as('alice'))
.expect(204);
} finally {
process.env.VOICE_RECORDING_ENABLED = 'true';
}
});
});
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<string> {
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();
});
});
});