Files
ApiCore/ApiCore-Backend/test/voice-confirmation.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 1dc39f7658
Backend Image / build-and-push (push) Successful in 6m51s
Frontend Image / build-and-push (push) Successful in 18m9s
feat: Sprachnotizen übernehmen, melden und abschreiben (T7 L2 Tasks 8, 9, 11)
Task 8: POST /inspections nimmt voiceCaptureId; die Notiz wird zuletzt
in derselben Transaktion bestätigt (jeder offene Status, Ruling 1),
Inhalt, Audio und Claim gelöscht; fremde oder geschlossene Notiz 409
VOICE_CAPTURE_GONE, anderes Volk 409 VOICE_CAPTURE_COLONY_MISMATCH;
Wiederholung mit gleichem Schlüssel idempotent.

Task 9: Ereignisse VoiceCaptureReady/VoiceCaptureFailed (nur IDs und
Endcodes), Meldungen „Sprachnotiz für … ist fertig“ bzw. Fehlerhinweis
mit nächstem Schritt, Betreiberhinweis bei abgeschalteter Auswertung;
Handler auch im Worker registriert (sonst gingen die Ereignisse
ungehört durch); keine Weiterleitung an Webhooks.

Task 11: Job voice-transcribe mit Phasen fetch (Audio nur im Speicher
entschlüsselt in ein RAM-Arbeitsverzeichnis 0700/0600) und ingest
(Ergebnis gegen Claim geprüft, Transkript versiegelt, Audio gelöscht,
Auswertung geweckt; Stille/zu lang enden FAILED); Ereignisse genau
einmal in derselben Transaktion. Aufbewahrung: Audio 24 h, Inhalt bis
contentExpiresAt, deaktivierte Besitzer, Zeilen 90 Tage; im täglichen
Cleanup inkl. leerer Job-Läufe (Ruling 10).

Gates: Backend lint, typecheck, 4471 Unit-Tests, build; e2e 103 Suites
(einziger Fehler: Wetter-Test gegen echte API).

Refs #27

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

365 lines
12 KiB
TypeScript

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