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

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