feat: Sprachnotizen-API (T7 L2 Task 7)

Upload (Audio nur per Magic Bytes, versiegelt, 2 MiB), Text-Weg,
Liste, Abruf, Wiederholen, Verwerfen, alle verwerfen, Konfiguration und
Zähler unter /voice-captures. Rechte voice-captures:use plus
inspections:create (eigener Guard, API-Schlüssel ausgeschlossen),
Zähler nur mit system:manage; jede Abfrage auf den Besitzer begrenzt.
Grenzen je Benutzer unter Advisory-Lock (10 offen, 30 je Tag, 10 je
60 s), Wiederholen ebenfalls begrenzt und gedrosselt, Audio für QUEUED
nur unter 24 h. Verwerfen löscht Inhalt und Claim sofort. Keine Inhalte
in Log oder Audit. Wächter gegen Sprachinhalte in Suche, Export und
Berichten erweitert.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 10:10:54 +02:00
1 parent 19550d1394
commit 0b0bc3c6dc
21 files changed
+2843 -12

No files matched your search

+131 -11
View File
@@ -5,6 +5,7 @@ import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { sealTranscript } from '../src/voice/core/transcript';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
@@ -67,6 +68,9 @@ describe('Search and filters (e2e)', () => {
});
afterAll(async () => {
await prisma.voiceCapture.deleteMany({
where: { ownerUserId: readonlyId },
});
await prisma.colony.deleteMany({
where: { id: { in: [colonyId, dissolvedColonyId] } },
});
@@ -88,6 +92,48 @@ describe('Search and filters (e2e)', () => {
).toBe(true);
});
it('returns nothing of a voice capture, neither by its text nor by its id', async () => {
const spoken = `Stifte${Date.now()}x`;
const capture = await prisma.voiceCapture.create({
data: {
ownerUserId: readonlyId,
colonyId,
inspectionDate: new Date(),
source: 'TEXT',
status: 'TRANSCRIBED',
contentExpiresAt: new Date(Date.now() + 86_400_000),
},
});
await prisma.voiceCapture.update({
where: { id: capture.id },
data: {
transcriptEnc: sealTranscript(capture.id, {
v: 1,
text: `${spoken} Koenigin gesehen`,
}),
lastErrorCode: 'EXTRACT_DISABLED',
},
});
// The colony code is found (so the colony is in play), the capture never.
for (const term of [spoken, capture.id, 'EXTRACT_DISABLED', uniqueCode]) {
const response = await request(app.getHttpServer())
.get(`/api/v1/search?q=${term}`)
.set('Authorization', `Bearer ${readonlyToken}`)
.expect(200);
const results = response.body as SearchResultBody[];
const text = JSON.stringify(results);
expect(text).not.toContain(capture.id);
expect(text).not.toContain(spoken);
for (const entry of results) {
expect(Object.keys(entry).sort()).toEqual(['id', 'label', 'type']);
expect(['Apiary', 'Colony', 'Queen', 'Task', 'HoneyBatch']).toContain(
entry.type,
);
}
}
});
it('filters colonies by status', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/colonies?status=DISSOLVED&apiaryId=${apiaryId}`)
@@ -101,29 +147,103 @@ describe('Search and filters (e2e)', () => {
describe('Voice capture content guard', () => {
// VoiceCapture holds personal data that is deleted after at most 7 days. It
// must never be reachable through search or any export/report builder.
const sourceRoots = ['search', 'export'].map((dir) =>
path.resolve(__dirname, '../src', dir),
);
const srcRoot = path.resolve(__dirname, '../src');
// Every place that builds a document or a list for the user or another
// system, by directory: nothing here may touch the voice tables.
const builderDirs = [
'search',
'export',
'erp-export',
'compliance',
'label-templates',
'statistics',
'dashboard',
'public',
'qr',
'calendar',
'calendar-sync',
'webhooks',
'shop-sync',
'documents',
'import',
];
// Files that build a PDF, CSV or download wherever they live. Found by
// content, so a new report builder in any folder is covered without an
// edit here. src/voice itself and src/backup (which lists the voice models
// as excluded) are the only places allowed to name the model.
const builderMarkers =
/PDFDocument|pdf-lib|pdfkit|application\/pdf|text\/csv|spreadsheetml|Content-Disposition|toCsv|csv-stringify/;
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(full);
return entry.name.endsWith('.ts') ? [full] : [];
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
? [full]
: [];
});
}
it('VoiceCapture appears in no search provider and no export', () => {
const files = sourceRoots.flatMap(sourceFiles);
function mentionsVoiceCapture(file: string): boolean {
return /voiceCapture|VoiceCapture/.test(fs.readFileSync(file, 'utf8'));
}
function relative(file: string): string {
return path.relative(srcRoot, file).split(path.sep).join('/');
}
it('VoiceCapture appears in no search provider, export or report directory', () => {
const files = builderDirs.flatMap((dir) =>
sourceFiles(path.join(srcRoot, dir)),
);
expect(files.length).toBeGreaterThan(0);
for (const file of files) {
expect({
file: path.basename(file),
mentions: /voiceCapture|VoiceCapture/.test(
fs.readFileSync(file, 'utf8'),
),
}).toEqual({ file: path.basename(file), mentions: false });
file: relative(file),
mentions: mentionsVoiceCapture(file),
}).toEqual({
file: relative(file),
mentions: false,
});
}
});
it('VoiceCapture appears in no file that builds a PDF, CSV or download, wherever it lives', () => {
const builders = sourceFiles(srcRoot).filter((file) => {
const name = relative(file);
return (
!name.startsWith('voice/') &&
!name.startsWith('backup/') &&
builderMarkers.test(fs.readFileSync(file, 'utf8'))
);
});
// The scan must find the known builders, or it checks nothing.
expect(builders.map(relative)).toEqual(
expect.arrayContaining([
expect.stringMatching(/^export\//),
expect.stringMatching(/^compliance\/pdf\//),
]),
);
for (const file of builders) {
expect({
file: relative(file),
mentions: mentionsVoiceCapture(file),
}).toEqual({
file: relative(file),
mentions: false,
});
}
});
it('SearchService names no voice type, field or table', () => {
const service = fs.readFileSync(
path.join(srcRoot, 'search/search.service.ts'),
'utf8',
);
expect(service).not.toMatch(/voice|transcript|suggestionEnc/i);
});
});
@@ -0,0 +1,899 @@
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;
}
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<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();
});
});
});