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]>
365 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|