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]>
This commit is contained in:
1 parent
e9c6604e3b
commit
1dc39f7658
35 files changed
+2886
-27
No files matched your search
@@ -3,6 +3,7 @@ import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { DomainEventDispatcher } from '../src/domain-events/domain-event-dispatcher';
|
||||
import { JOB_NAMES } from '../src/jobs/job-names';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
@@ -104,7 +105,7 @@ describe('System API (e2e)', () => {
|
||||
.set(auth(adminToken))
|
||||
.expect(200);
|
||||
const body = response.body as OverviewBody;
|
||||
expect(body.jobs).toHaveLength(5);
|
||||
expect(body.jobs).toHaveLength(JOB_NAMES.length);
|
||||
expect(typeof body.domainEvents.failed).toBe('number');
|
||||
|
||||
await request(server())
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,530 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Client } from 'pg';
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import { WAKE_CHANNELS } from '../src/common/worker-wakeup';
|
||||
import { VoiceTranscribeJob } from '../src/jobs/voice-transcribe.job';
|
||||
import { runCli } from '../src/jobs/run-cli';
|
||||
import { dbSessionConfig } from '../src/prisma/db-session';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { openTranscript } from '../src/voice/core/transcript';
|
||||
import { sealVoiceBytes, voiceAad } from '../src/voice/core/voice-crypto';
|
||||
import {
|
||||
deleteIdleVoiceRuns,
|
||||
deleteOldVoiceRows,
|
||||
} from '../src/voice/voice-retention';
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
const SECRET_TEXT = 'Geheimer Befund Volk sehr unruhig Sperrbrut sichtbar';
|
||||
|
||||
/**
|
||||
* The job halves are called directly with a filter on this file's own
|
||||
* users and a private work dir, so parallel e2e files sharing the database
|
||||
* are never touched. Everything is created with a unique suffix and
|
||||
* removed by it in afterAll.
|
||||
*/
|
||||
describe('voice-transcribe job (e2e)', () => {
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const startedAt = new Date(Date.now() - 1000);
|
||||
let prisma: PrismaService;
|
||||
let job: VoiceTranscribeJob;
|
||||
let workDir: string;
|
||||
let roleId: string;
|
||||
let userId: string;
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
const extraUserIds: string[] = [];
|
||||
|
||||
const mine = Prisma.sql`"ownerUserId" IN (SELECT "id" FROM "User" WHERE "email" LIKE ${`%${suffix}%`})`;
|
||||
const fetch = () => job.runFetchPhase({ filter: mine });
|
||||
const row = (id: string) =>
|
||||
prisma.voiceCapture.findUniqueOrThrow({ where: { id } });
|
||||
const failedEvents = (id: string) =>
|
||||
prisma.domainEvent.count({
|
||||
where: {
|
||||
aggregateType: 'VoiceCapture',
|
||||
aggregateId: id,
|
||||
type: 'VoiceCaptureFailed',
|
||||
},
|
||||
});
|
||||
const audioCount = (id: string) =>
|
||||
prisma.voiceCaptureAudio.count({ where: { captureId: id } });
|
||||
|
||||
async function newUser(label: string, isActive = true): Promise<string> {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `voice-tr-${label}-${suffix}@example.com`,
|
||||
passwordHash: await bcrypt.hash('SuperSecret123!', 4),
|
||||
name: `Voice transcribe ${label}`,
|
||||
roleId,
|
||||
isActive,
|
||||
},
|
||||
});
|
||||
extraUserIds.push(user.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function seed(
|
||||
data: Partial<Prisma.VoiceCaptureUncheckedCreateInput> = {},
|
||||
audio: Buffer | null = Buffer.from('OggS-fake-audio-bytes'),
|
||||
audioCreatedAt?: Date,
|
||||
): Promise<string> {
|
||||
const created = await prisma.voiceCapture.create({
|
||||
data: {
|
||||
ownerUserId: userId,
|
||||
colonyId,
|
||||
inspectionDate: new Date('2026-10-16'),
|
||||
source: 'AUDIO',
|
||||
audioMime: 'audio/ogg',
|
||||
durationSec: 30,
|
||||
contentExpiresAt: new Date(Date.now() + 7 * 24 * HOUR_MS),
|
||||
...data,
|
||||
},
|
||||
});
|
||||
if (audio) {
|
||||
await prisma.voiceCaptureAudio.create({
|
||||
data: {
|
||||
captureId: created.id,
|
||||
dataEnc: Uint8Array.from(
|
||||
sealVoiceBytes(audio, voiceAad(created.id, 'audio')),
|
||||
),
|
||||
...(audioCreatedAt ? { createdAt: audioCreatedAt } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function whisperOut(id: string, over: Record<string, unknown> = {}) {
|
||||
await writeFile(
|
||||
path.join(workDir, 'out', `${id}.json`),
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
captureId: id,
|
||||
text: SECRET_TEXT,
|
||||
durationSec: 21.4,
|
||||
language: 'de',
|
||||
languageProbability: 0.97,
|
||||
segments: [{ start: 0, end: 4, text: SECRET_TEXT }],
|
||||
...over,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
workDir = await mkdtemp(path.join(tmpdir(), 'voice-tr-e2e-'));
|
||||
process.env.VOICE_WORK_DIR = workDir;
|
||||
prisma = new PrismaService();
|
||||
await prisma.$connect();
|
||||
job = new VoiceTranscribeJob(prisma);
|
||||
roleId = (
|
||||
await prisma.role.create({
|
||||
data: { key: `VOICE_TR_${suffix}`, name: `Voice tr ${suffix}` },
|
||||
})
|
||||
).id;
|
||||
userId = await newUser('main');
|
||||
apiaryId = (
|
||||
await prisma.apiary.create({ data: { name: `Voice tr ${suffix}` } })
|
||||
).id;
|
||||
colonyId = (
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `VTR-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
})
|
||||
).id;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const stale = await prisma.voiceCapture.findMany({
|
||||
where: { ownerUserId: { in: [userId, ...extraUserIds] } },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
aggregateType: 'VoiceCapture',
|
||||
aggregateId: { in: stale.map((c) => c.id) },
|
||||
},
|
||||
});
|
||||
await prisma.voiceCapture.deleteMany({
|
||||
where: { ownerUserId: { in: [userId, ...extraUserIds] } },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { email: { contains: suffix } },
|
||||
select: { id: true },
|
||||
});
|
||||
const captures = await prisma.voiceCapture.findMany({
|
||||
where: { ownerUserId: { in: users.map((u) => u.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
aggregateType: 'VoiceCapture',
|
||||
aggregateId: { in: captures.map((c) => c.id) },
|
||||
},
|
||||
});
|
||||
await prisma.voiceCapture.deleteMany({
|
||||
where: { ownerUserId: { in: users.map((u) => u.id) } },
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: users.map((u) => u.id) } },
|
||||
});
|
||||
await prisma.role.deleteMany({ where: { id: roleId } });
|
||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.jobRun.deleteMany({
|
||||
where: { job: 'voice-transcribe', startedAt: { gte: startedAt } },
|
||||
});
|
||||
delete process.env.VOICE_WORK_DIR;
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('fetch writes one file per claimed capture and a manifest', async () => {
|
||||
const a = await seed({}, Buffer.from('OggS-AAA'));
|
||||
const b = await seed({}, Buffer.from('OggS-BBB'));
|
||||
|
||||
const result = await fetch();
|
||||
|
||||
expect(result).toMatchObject({ claimed: 2, written: 2 });
|
||||
expect((await readdir(path.join(workDir, 'in'))).sort()).toEqual(
|
||||
[`${a}.ogg`, `${b}.ogg`, 'manifest.json'].sort(),
|
||||
);
|
||||
expect(await readFile(path.join(workDir, 'in', `${a}.ogg`))).toEqual(
|
||||
Buffer.from('OggS-AAA'),
|
||||
);
|
||||
expect((await row(a)).status).toBe('TRANSCRIBING');
|
||||
});
|
||||
|
||||
it('fetch ends quickly without work and writes no manifest', async () => {
|
||||
const start = Date.now();
|
||||
const result = await fetch();
|
||||
|
||||
expect(result).toMatchObject({ claimed: 0, written: 0 });
|
||||
expect(Date.now() - start).toBeLessThan(5000);
|
||||
expect(await readdir(path.join(workDir, 'in'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('fetch counts a missing audio row as a failed attempt', async () => {
|
||||
const id = await seed({}, null);
|
||||
|
||||
const result = await fetch();
|
||||
|
||||
expect(result).toMatchObject({ claimed: 1, written: 0 });
|
||||
const after = await row(id);
|
||||
expect(after.status).toBe('QUEUED');
|
||||
expect(after.attempts).toBe(1);
|
||||
expect(after.lastErrorCode).toBe('TRANSCRIBE_FAILED');
|
||||
});
|
||||
|
||||
it('ingest stores TRANSCRIBED, deletes the audio and wakes the worker in one transaction', async () => {
|
||||
const id = await seed();
|
||||
await fetch();
|
||||
await whisperOut(id);
|
||||
const listener = new Client(dbSessionConfig(process.env.DATABASE_URL));
|
||||
await listener.connect();
|
||||
const woken = new Promise<string>((resolve) =>
|
||||
listener.on('notification', (message) => resolve(message.channel)),
|
||||
);
|
||||
await listener.query(`LISTEN ${WAKE_CHANNELS.voiceExtraction}`);
|
||||
|
||||
try {
|
||||
const result = await job.ingest();
|
||||
|
||||
expect(result).toMatchObject({ ingested: 1, failed: 0, lost: 0 });
|
||||
expect(await woken).toBe(WAKE_CHANNELS.voiceExtraction);
|
||||
} finally {
|
||||
await listener.end();
|
||||
}
|
||||
const after = await row(id);
|
||||
expect(after.status).toBe('TRANSCRIBED');
|
||||
expect(after.attempts).toBe(0);
|
||||
expect(after.claimToken).toBeNull();
|
||||
expect(after.durationSec).toBe(21);
|
||||
expect(await audioCount(id)).toBe(0);
|
||||
expect(openTranscript(id, after.transcriptEnc as string).text).toBe(
|
||||
SECRET_TEXT,
|
||||
);
|
||||
expect(after.transcriptEnc).not.toContain('Geheimer');
|
||||
expect(await readdir(path.join(workDir, 'in'))).toEqual([]);
|
||||
expect(await readdir(path.join(workDir, 'out'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('ingest does nothing when the claim token no longer matches', async () => {
|
||||
const id = await seed();
|
||||
await fetch();
|
||||
await whisperOut(id);
|
||||
await prisma.voiceCapture.update({
|
||||
where: { id },
|
||||
data: { claimToken: 'another-token' },
|
||||
});
|
||||
|
||||
const result = await job.ingest();
|
||||
|
||||
expect(result).toMatchObject({ ingested: 0, lost: 1 });
|
||||
const after = await row(id);
|
||||
expect(after.status).toBe('TRANSCRIBING');
|
||||
expect(after.transcriptEnc).toBeNull();
|
||||
expect(await audioCount(id)).toBe(1);
|
||||
});
|
||||
|
||||
it('a confirmed capture is not overwritten by a late ingest', async () => {
|
||||
const id = await seed();
|
||||
await fetch();
|
||||
await whisperOut(id);
|
||||
await prisma.voiceCapture.update({
|
||||
where: { id },
|
||||
data: { status: 'DISCARDED', claimToken: null, claimedAt: null },
|
||||
});
|
||||
|
||||
const result = await job.ingest();
|
||||
|
||||
expect(result).toMatchObject({ ingested: 0, lost: 1 });
|
||||
const after = await row(id);
|
||||
expect(after.status).toBe('DISCARDED');
|
||||
expect(after.transcriptEnc).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['NO_SPEECH', 'TOO_LONG'] as const)(
|
||||
'%s ends FAILED with the code and deletes the audio',
|
||||
async (code) => {
|
||||
const id = await seed();
|
||||
await fetch();
|
||||
await whisperOut(id, { error: code, text: '', segments: [] });
|
||||
|
||||
const result = await job.ingest();
|
||||
|
||||
expect(result).toMatchObject({ failed: 1 });
|
||||
const after = await row(id);
|
||||
expect(after.status).toBe('FAILED');
|
||||
expect(after.lastErrorCode).toBe(code);
|
||||
expect(await audioCount(id)).toBe(0);
|
||||
expect(await failedEvents(id)).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('a missing result counts as a failed attempt', async () => {
|
||||
const id = await seed();
|
||||
await fetch();
|
||||
|
||||
const result = await job.ingest();
|
||||
|
||||
expect(result).toMatchObject({ retried: 1 });
|
||||
const after = await row(id);
|
||||
expect(after.status).toBe('QUEUED');
|
||||
expect(after.attempts).toBe(1);
|
||||
expect(after.lastErrorCode).toBe('TRANSCRIBE_FAILED');
|
||||
expect(await audioCount(id)).toBe(1);
|
||||
});
|
||||
|
||||
it('the third failed attempt ends FAILED with TRANSCRIBE_FAILED', async () => {
|
||||
const id = await seed({ attempts: 2 });
|
||||
await fetch();
|
||||
await whisperOut(id, { error: 'DECODE_FAILED', text: '', segments: [] });
|
||||
|
||||
const result = await job.ingest();
|
||||
|
||||
expect(result).toMatchObject({ failed: 1 });
|
||||
expect((await row(id)).status).toBe('FAILED');
|
||||
expect((await row(id)).lastErrorCode).toBe('TRANSCRIBE_FAILED');
|
||||
expect(await failedEvents(id)).toBe(1);
|
||||
});
|
||||
|
||||
it('writes no failure event for a retry or a lost claim', async () => {
|
||||
const retried = await seed();
|
||||
const lost = await seed();
|
||||
await fetch();
|
||||
await whisperOut(lost);
|
||||
await prisma.voiceCapture.update({
|
||||
where: { id: lost },
|
||||
data: { claimToken: 'another-token' },
|
||||
});
|
||||
|
||||
await job.ingest();
|
||||
|
||||
expect((await row(retried)).status).toBe('QUEUED');
|
||||
expect(await failedEvents(retried)).toBe(0);
|
||||
expect(await failedEvents(lost)).toBe(0);
|
||||
});
|
||||
|
||||
it('writes one failure event when a stale claim reaches its last attempt', async () => {
|
||||
const id = await seed({
|
||||
status: 'TRANSCRIBING',
|
||||
attempts: 2,
|
||||
claimToken: 'old',
|
||||
claimedAt: new Date(Date.now() - 40 * 60_000),
|
||||
});
|
||||
|
||||
await fetch();
|
||||
await fetch();
|
||||
|
||||
const after = await row(id);
|
||||
expect(after.status).toBe('FAILED');
|
||||
expect(after.lastErrorCode).toBe('TRANSCRIBE_FAILED');
|
||||
expect(await failedEvents(id)).toBe(1);
|
||||
});
|
||||
|
||||
it('expires audio after 24 h within one CronJob run', async () => {
|
||||
const old = new Date(Date.now() - 25 * HOUR_MS);
|
||||
const id = await seed({ createdAt: old }, undefined, old);
|
||||
|
||||
const result = await fetch();
|
||||
|
||||
expect(result).toMatchObject({ audioExpired: 1, claimed: 0 });
|
||||
const after = await row(id);
|
||||
expect(after.status).toBe('FAILED');
|
||||
expect(after.lastErrorCode).toBe('AUDIO_EXPIRED');
|
||||
expect(await audioCount(id)).toBe(0);
|
||||
expect(await failedEvents(id)).toBe(1);
|
||||
});
|
||||
|
||||
it('wipes content after 7 days and marks EXPIRED', async () => {
|
||||
const id = await seed(
|
||||
{
|
||||
status: 'READY',
|
||||
transcriptEnc: 'x',
|
||||
suggestionEnc: 'y',
|
||||
contentExpiresAt: new Date(Date.now() - HOUR_MS),
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await fetch();
|
||||
|
||||
expect(result).toMatchObject({ contentExpired: 1 });
|
||||
const after = await row(id);
|
||||
expect(after.status).toBe('EXPIRED');
|
||||
expect(after.transcriptEnc).toBeNull();
|
||||
expect(after.suggestionEnc).toBeNull();
|
||||
expect(after.contentDeletedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('wipes content of deactivated users', async () => {
|
||||
const inactive = await newUser('inactive', false);
|
||||
const id = await seed(
|
||||
{ ownerUserId: inactive, status: 'TRANSCRIBED', transcriptEnc: 'x' },
|
||||
null,
|
||||
);
|
||||
const keep = await seed(
|
||||
{ status: 'TRANSCRIBED', transcriptEnc: 'x' },
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await fetch();
|
||||
|
||||
expect(result).toMatchObject({ inactiveOwnersWiped: 1 });
|
||||
expect((await row(id)).transcriptEnc).toBeNull();
|
||||
expect((await row(id)).status).toBe('EXPIRED');
|
||||
expect((await row(keep)).transcriptEnc).toBe('x');
|
||||
});
|
||||
|
||||
it('releases a stale claim on the next fetch', async () => {
|
||||
const id = await seed({
|
||||
status: 'TRANSCRIBING',
|
||||
claimToken: 'old',
|
||||
claimedAt: new Date(Date.now() - 40 * 60_000),
|
||||
});
|
||||
|
||||
await fetch();
|
||||
|
||||
const after = await row(id);
|
||||
expect(after.attempts).toBe(1);
|
||||
expect(after.status).toBe('TRANSCRIBING');
|
||||
expect(after.claimToken).not.toBe('old');
|
||||
});
|
||||
|
||||
it('deletes rows after 90 days', async () => {
|
||||
const id = await seed(
|
||||
{ createdAt: new Date(Date.now() - 91 * 24 * HOUR_MS) },
|
||||
null,
|
||||
);
|
||||
|
||||
expect(await deleteOldVoiceRows(prisma)).toBeGreaterThanOrEqual(1);
|
||||
expect(await prisma.voiceCapture.count({ where: { id } })).toBe(0);
|
||||
});
|
||||
|
||||
it('prunes only old successful voice-transcribe runs without any work', async () => {
|
||||
const old = new Date(Date.now() - 48 * HOUR_MS);
|
||||
const make = (data: Omit<Prisma.JobRunUncheckedCreateInput, 'job'>) =>
|
||||
prisma.jobRun
|
||||
.create({ data: { job: 'voice-transcribe', startedAt: old, ...data } })
|
||||
.then((r) => r.id);
|
||||
const idle = await make({
|
||||
status: 'SUCCEEDED',
|
||||
result: { claimed: 0, written: 0 },
|
||||
});
|
||||
const busy = await make({
|
||||
status: 'SUCCEEDED',
|
||||
result: { claimed: 2, written: 0 },
|
||||
});
|
||||
const failed = await make({ status: 'FAILED', result: { claimed: 0 } });
|
||||
const recent = await prisma.jobRun
|
||||
.create({
|
||||
data: {
|
||||
job: 'voice-transcribe',
|
||||
status: 'SUCCEEDED',
|
||||
result: { claimed: 0 },
|
||||
},
|
||||
})
|
||||
.then((r) => r.id);
|
||||
const ids = [idle, busy, failed, recent];
|
||||
|
||||
const removed = await deleteIdleVoiceRuns(
|
||||
prisma,
|
||||
24,
|
||||
Prisma.sql`"id" IN (${Prisma.join(ids)})`,
|
||||
);
|
||||
|
||||
expect(removed).toBe(1);
|
||||
const left = await prisma.jobRun.findMany({ where: { id: { in: ids } } });
|
||||
await prisma.jobRun.deleteMany({ where: { id: { in: ids } } });
|
||||
expect(left.map((r) => r.id).sort()).toEqual([busy, failed, recent].sort());
|
||||
});
|
||||
|
||||
it('a measured duration over 240 s ends TOO_LONG instead of being clamped', async () => {
|
||||
const id = await seed();
|
||||
await fetch();
|
||||
await whisperOut(id, { durationSec: 240.6 });
|
||||
|
||||
await job.ingest();
|
||||
|
||||
expect((await row(id)).status).toBe('FAILED');
|
||||
expect((await row(id)).lastErrorCode).toBe('TOO_LONG');
|
||||
expect(await audioCount(id)).toBe(0);
|
||||
});
|
||||
|
||||
it('ingest never logs the transcript', async () => {
|
||||
const spies = (['log', 'warn', 'error', 'debug', 'verbose'] as const).map(
|
||||
(level) => jest.spyOn(Logger.prototype, level).mockImplementation(),
|
||||
);
|
||||
const id = await seed();
|
||||
await fetch();
|
||||
await whisperOut(id);
|
||||
|
||||
try {
|
||||
await job.ingest();
|
||||
const logged = JSON.stringify(spies.flatMap((s) => s.mock.calls));
|
||||
expect(logged).not.toContain('Geheimer');
|
||||
expect(logged).not.toContain('Sperrbrut');
|
||||
} finally {
|
||||
spies.forEach((s) => s.mockRestore());
|
||||
}
|
||||
});
|
||||
|
||||
it('runs through the CLI: ingest without a manifest succeeds and is recorded', async () => {
|
||||
expect(await runCli(['voice-transcribe', '--phase=ingest'])).toBe(0);
|
||||
expect(await runCli(['voice-transcribe'])).toBe(1);
|
||||
const runs = await prisma.jobRun.findMany({
|
||||
where: { job: 'voice-transcribe', startedAt: { gte: startedAt } },
|
||||
});
|
||||
expect(runs.map((r) => r.status).sort()).toEqual(['FAILED', 'SUCCEEDED']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user