test: Ende-zu-Ende-Ablauf der Sprachnotizen (T7 L6 Task 23)
Upload → fetch (Arbeitsordner) → Ersatz-Whisper → ingest → Auswertung mit FakeStructuredModel → genau eine Meldung → Übernahme in die Durchsicht (Inhalt gelöscht); fremder Nutzer 404, READONLY/API_CLIENT 403; parallele Claims je Aufnahme genau einmal; Text-Weg; der Log-Mitschnitt enthält nie das Gesagte. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6b1b3cfa90
commit
947a46f435
1 file changed
+416
@@ -0,0 +1,416 @@
|
||||
import { INestApplication, Logger } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import { berlinToday } from '../src/common/dates';
|
||||
import { VoiceTranscribeJob } from '../src/jobs/voice-transcribe.job';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { claimVoiceCaptures } from '../src/voice/claims/voice-claims';
|
||||
import { openTranscript } from '../src/voice/core/transcript';
|
||||
import { FakeStructuredModel } from '../src/voice/extraction/fake-structured-model';
|
||||
import { VoiceExtractionService } from '../src/voice/extraction/voice-extraction.service';
|
||||
import { login } from './utils/api';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { drainDomainEvents } from './utils/domain-events';
|
||||
|
||||
const WEBM = Buffer.concat([
|
||||
Buffer.from([0x1a, 0x45, 0xdf, 0xa3]),
|
||||
Buffer.from('opus-bytes-of-a-spoken-note'),
|
||||
]);
|
||||
// Words that must never show up in a log line.
|
||||
const SPOKEN =
|
||||
'Königin gesehen, Sperrbrutzellen sichtbar, vier Brutwaben belegt';
|
||||
const QUOTE = 'Königin gesehen';
|
||||
const FORBIDDEN_IN_LOGS = ['Königin', 'Sperrbrutzellen', 'Brutwaben'];
|
||||
const ANSWER = {
|
||||
findings: [
|
||||
{ field: 'queenSeen', value: true, quote: QUOTE, corrected: false },
|
||||
],
|
||||
unclear: [],
|
||||
};
|
||||
|
||||
interface IdBody {
|
||||
id: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole voice flow against the test database: upload, fetch, a stand-in
|
||||
* for Whisper, ingest, extraction with a FakeStructuredModel (no network, no
|
||||
* key), the ready notification and the confirmation in POST /inspections.
|
||||
* Shares the database with parallel e2e files: own users/roles/colonies with
|
||||
* a unique suffix, every claim/extraction scoped by `filter`, cleanup by suffix.
|
||||
*/
|
||||
describe('Voice flow (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
let job: VoiceTranscribeJob;
|
||||
let workDir: string;
|
||||
const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
const emailOf = (name: string) => `voice-flow-${name}-${suffix}@example.com`;
|
||||
const tokens: Record<string, string> = {};
|
||||
const userIds: Record<string, string> = {};
|
||||
const roleKey = `VOICE_FLOW_ONLY_VOICE_${suffix}`;
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
let keyCounter = 0;
|
||||
let addressCounter = 0;
|
||||
const originalRecording = process.env.VOICE_RECORDING_ENABLED;
|
||||
const originalLimit = process.env.VOICE_DAILY_EXTRACTIONS;
|
||||
const originalWorkDir = process.env.VOICE_WORK_DIR;
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
const as = (name: string) => ({ Authorization: `Bearer ${tokens[name]}` });
|
||||
const nextKey = () =>
|
||||
`voice-flow-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`;
|
||||
const mine = Prisma.sql`"ownerUserId" IN (SELECT "id" FROM "User" WHERE "email" LIKE ${`%${suffix}%`})`;
|
||||
const row = (id: string) =>
|
||||
prisma.voiceCapture.findUniqueOrThrow({ where: { id } });
|
||||
const audioCount = (id: string) =>
|
||||
prisma.voiceCaptureAudio.count({ where: { captureId: id } });
|
||||
const readyNotifications = (id: string) =>
|
||||
prisma.notification.findMany({
|
||||
where: { relatedType: 'VoiceCapture', relatedId: id },
|
||||
});
|
||||
const extract = (model: FakeStructuredModel) =>
|
||||
new VoiceExtractionService(prisma, model).runOnce(undefined, {
|
||||
filter: mine,
|
||||
});
|
||||
|
||||
function upload(who: string) {
|
||||
addressCounter += 1;
|
||||
return request(server())
|
||||
.post('/api/v1/voice-captures')
|
||||
.set(as(who))
|
||||
.set('X-Forwarded-For', `10.7.${addressCounter % 250}.9`)
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.field('colonyId', colonyId)
|
||||
.field('inspectionDate', berlinToday())
|
||||
.field('durationSec', '30')
|
||||
.attach('audio', WEBM, {
|
||||
filename: 'note.webm',
|
||||
contentType: 'audio/webm',
|
||||
});
|
||||
}
|
||||
|
||||
const sendText = (who: string, text = SPOKEN) =>
|
||||
request(server())
|
||||
.post('/api/v1/voice-captures/text')
|
||||
.set(as(who))
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send({ colonyId, inspectionDate: berlinToday(), text });
|
||||
|
||||
const createInspection = (who: string, body: Record<string, unknown>) =>
|
||||
request(server())
|
||||
.post('/api/v1/inspections')
|
||||
.set(as(who))
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send({ colonyId, date: berlinToday(), ...body });
|
||||
|
||||
async function whisperOut(id: string, text = SPOKEN) {
|
||||
await writeFile(
|
||||
path.join(workDir, 'out', `${id}.json`),
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
captureId: id,
|
||||
text,
|
||||
durationSec: 28.4,
|
||||
language: 'de',
|
||||
languageProbability: 0.98,
|
||||
segments: [{ start: 0, end: 6, text }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function createUser(name: string, role: { key?: string; id?: string }) {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: emailOf(name),
|
||||
passwordHash: await bcrypt.hash(password, 4),
|
||||
name: `Voice flow ${name}`,
|
||||
role: { connect: role.key ? { key: role.key } : { id: role.id } },
|
||||
},
|
||||
});
|
||||
userIds[name] = user.id;
|
||||
tokens[name] = (await login(app, emailOf(name), password)).accessToken;
|
||||
}
|
||||
|
||||
/** fetch (own rows, private work dir) -> Whisper stand-in -> ingest. */
|
||||
async function transcribe(id: string): Promise<void> {
|
||||
const fetched = await job.runFetchPhase({ filter: mine });
|
||||
expect(fetched).toMatchObject({ claimed: 1, written: 1 });
|
||||
expect((await readdir(path.join(workDir, 'in'))).sort()).toEqual(
|
||||
[`${id}.webm`, 'manifest.json'].sort(),
|
||||
);
|
||||
await whisperOut(id);
|
||||
await job.ingest();
|
||||
}
|
||||
|
||||
async function cleanOwn(): Promise<void> {
|
||||
const ids = Object.values(userIds);
|
||||
const captures = await prisma.voiceCapture.findMany({
|
||||
where: { ownerUserId: { in: ids } },
|
||||
select: { id: true },
|
||||
});
|
||||
const captureIds = captures.map((c) => c.id);
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateType: 'VoiceCapture', aggregateId: { in: captureIds } },
|
||||
});
|
||||
await prisma.notification.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.voiceExtractionCall.deleteMany({
|
||||
where: { captureId: { in: captureIds } },
|
||||
});
|
||||
await prisma.voiceCapture.deleteMany({
|
||||
where: { ownerUserId: { in: ids } },
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanInspections(): Promise<void> {
|
||||
const inspections = await prisma.inspection.findMany({
|
||||
where: { colonyId },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = inspections.map((i) => i.id);
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateType: 'Inspection', aggregateId: { in: ids } },
|
||||
});
|
||||
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||
where: { sourceType: 'INSPECTION', sourceId: { in: ids } },
|
||||
});
|
||||
await prisma.inspection.deleteMany({ where: { colonyId } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
workDir = await mkdtemp(path.join(tmpdir(), 'voice-flow-e2e-'));
|
||||
process.env.VOICE_WORK_DIR = workDir;
|
||||
// Notes are only accepted while recording is on; this file turns it on.
|
||||
process.env.VOICE_RECORDING_ENABLED = 'true';
|
||||
process.env.VOICE_DAILY_EXTRACTIONS = '10000';
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
job = new VoiceTranscribeJob(prisma);
|
||||
|
||||
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 },
|
||||
});
|
||||
await createUser('alice', { key: 'BEEKEEPER' });
|
||||
await createUser('bob', { key: 'BEEKEEPER' });
|
||||
await createUser('readonly', { key: 'READONLY' });
|
||||
await createUser('apiclient', { key: 'API_CLIENT' });
|
||||
await createUser('onlyvoice', { id: onlyVoice.id });
|
||||
apiaryId = (
|
||||
await prisma.apiary.create({
|
||||
data: { name: `Voice Flow Stand ${suffix}` },
|
||||
})
|
||||
).id;
|
||||
colonyId = (
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `VFLOW-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
})
|
||||
).id;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanOwn();
|
||||
await cleanInspections();
|
||||
});
|
||||
|
||||
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((u) => u.id);
|
||||
users.forEach((u, index) => {
|
||||
userIds[`cleanup-${index}`] = u.id;
|
||||
});
|
||||
await cleanOwn();
|
||||
await cleanInspections();
|
||||
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||
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.role.deleteMany({ where: { key: roleKey } });
|
||||
await prisma.colony.deleteMany({ where: { code: `VFLOW-${suffix}` } });
|
||||
await prisma.apiary.deleteMany({
|
||||
where: { name: `Voice Flow Stand ${suffix}` },
|
||||
});
|
||||
await app.close();
|
||||
const restore = (key: string, value: string | undefined) => {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
};
|
||||
restore('VOICE_RECORDING_ENABLED', originalRecording);
|
||||
restore('VOICE_DAILY_EXTRACTIONS', originalLimit);
|
||||
restore('VOICE_WORK_DIR', originalWorkDir);
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs upload to confirmation, deletes the content and never logs it', async () => {
|
||||
const loggerSpies = (
|
||||
['log', 'warn', 'error', 'debug', 'verbose', 'fatal'] as const
|
||||
).map((level) => jest.spyOn(Logger.prototype, level).mockImplementation());
|
||||
const consoleSpies = (
|
||||
['log', 'warn', 'error', 'info', 'debug'] as const
|
||||
).map((level) => jest.spyOn(console, level).mockImplementation());
|
||||
try {
|
||||
// Upload -> QUEUED
|
||||
const up = await upload('alice').expect(201);
|
||||
const id = (up.body as IdBody).id;
|
||||
expect(up.body).toEqual({ id, status: 'QUEUED' });
|
||||
expect(await audioCount(id)).toBe(1);
|
||||
|
||||
// fetch -> file + manifest; Whisper stand-in; ingest -> TRANSCRIBED
|
||||
await transcribe(id);
|
||||
const transcribed = await row(id);
|
||||
expect(transcribed.status).toBe('TRANSCRIBED');
|
||||
expect(await audioCount(id)).toBe(0);
|
||||
expect(openTranscript(id, transcribed.transcriptEnc as string).text).toBe(
|
||||
SPOKEN,
|
||||
);
|
||||
expect(transcribed.transcriptEnc).not.toContain('Königin');
|
||||
expect(await readdir(path.join(workDir, 'in'))).toEqual([]);
|
||||
expect(await readdir(path.join(workDir, 'out'))).toEqual([]);
|
||||
|
||||
// extraction -> READY, exactly one "ist fertig" notification
|
||||
const model = new FakeStructuredModel([FakeStructuredModel.ok(ANSWER)]);
|
||||
await extract(model);
|
||||
expect((await row(id)).status).toBe('READY');
|
||||
expect(model.requests).toHaveLength(1);
|
||||
await drainDomainEvents(app, [id]);
|
||||
await drainDomainEvents(app, [id]);
|
||||
const notifications = await readyNotifications(id);
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]).toMatchObject({
|
||||
userId: userIds.alice,
|
||||
message: `Sprachnotiz für VFLOW-${suffix} ist fertig`,
|
||||
});
|
||||
|
||||
// foreign user: 404; wrong roles: 403
|
||||
await request(server())
|
||||
.get(`/api/v1/voice-captures/${id}`)
|
||||
.set(as('bob'))
|
||||
.expect(404);
|
||||
for (const who of ['readonly', 'apiclient', 'onlyvoice']) {
|
||||
await request(server())
|
||||
.get(`/api/v1/voice-captures/${id}`)
|
||||
.set(as(who))
|
||||
.expect(403);
|
||||
}
|
||||
const owner = await request(server())
|
||||
.get(`/api/v1/voice-captures/${id}`)
|
||||
.set(as('alice'))
|
||||
.expect(200);
|
||||
expect(owner.body).toMatchObject({
|
||||
status: 'READY',
|
||||
transcript: SPOKEN,
|
||||
suggestions: [{ field: 'queenSeen', value: true, quote: QUOTE }],
|
||||
});
|
||||
|
||||
// confirmation in POST /inspections wipes the content
|
||||
const created = await createInspection('alice', {
|
||||
voiceCaptureId: id,
|
||||
colonyStrength: 12,
|
||||
}).expect(201);
|
||||
const confirmed = await row(id);
|
||||
expect(confirmed).toMatchObject({
|
||||
status: 'CONFIRMED',
|
||||
inspectionId: (created.body as IdBody).id,
|
||||
transcriptEnc: null,
|
||||
suggestionEnc: null,
|
||||
});
|
||||
expect(confirmed.contentDeletedAt).not.toBeNull();
|
||||
expect(await audioCount(id)).toBe(0);
|
||||
|
||||
// text path -> TRANSCRIBED -> READY
|
||||
const textId = ((await sendText('alice').expect(201)).body as IdBody).id;
|
||||
expect((await row(textId)).status).toBe('TRANSCRIBED');
|
||||
await extract(new FakeStructuredModel([FakeStructuredModel.ok(ANSWER)]));
|
||||
expect((await row(textId)).status).toBe('READY');
|
||||
|
||||
const logged = JSON.stringify([
|
||||
...loggerSpies.flatMap((spy) => spy.mock.calls),
|
||||
...consoleSpies.flatMap((spy) => spy.mock.calls),
|
||||
]);
|
||||
for (const word of FORBIDDEN_IN_LOGS) {
|
||||
expect(logged).not.toContain(word);
|
||||
}
|
||||
} finally {
|
||||
[...loggerSpies, ...consoleSpies].forEach((spy) => spy.mockRestore());
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses READONLY and API_CLIENT on upload and text', async () => {
|
||||
for (const who of ['readonly', 'apiclient']) {
|
||||
expect((await upload(who)).status).toBe(403);
|
||||
expect((await sendText(who)).status).toBe(403);
|
||||
}
|
||||
expect(
|
||||
await prisma.voiceCapture.count({
|
||||
where: { ownerUserId: { in: Object.values(userIds) } },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('claims every capture exactly once across parallel fetch runs', async () => {
|
||||
const ids: string[] = [];
|
||||
for (let n = 0; n < 6; n += 1) {
|
||||
const id = ((await upload('alice').expect(201)).body as IdBody).id;
|
||||
ids.push(id);
|
||||
}
|
||||
const options = { limit: 2, maxTotalSec: 100_000, filter: mine };
|
||||
const batches = await Promise.all(
|
||||
[0, 1, 2, 3].map(async () => {
|
||||
const got: string[] = [];
|
||||
for (;;) {
|
||||
const claims = await claimVoiceCaptures(
|
||||
prisma,
|
||||
'transcribe',
|
||||
options,
|
||||
);
|
||||
if (claims.length === 0) {
|
||||
return got;
|
||||
}
|
||||
got.push(...claims.map((claim) => claim.id));
|
||||
}
|
||||
}),
|
||||
);
|
||||
const all = batches.flat();
|
||||
expect(all).toHaveLength(ids.length);
|
||||
expect([...all].sort()).toEqual([...ids].sort());
|
||||
const rows = await prisma.voiceCapture.findMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
expect(new Set(rows.map((r) => r.claimToken)).size).toBe(ids.length);
|
||||
expect(rows.every((r) => r.claimedAt !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it('text path reaches READY without audio', async () => {
|
||||
const id = ((await sendText('alice').expect(201)).body as IdBody).id;
|
||||
expect(await audioCount(id)).toBe(0);
|
||||
await extract(new FakeStructuredModel([FakeStructuredModel.ok(ANSWER)]));
|
||||
expect((await row(id)).status).toBe('READY');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user