import * as bcrypt from 'bcryptjs'; import { Prisma } from '../generated/prisma'; import { PrismaService } from '../src/prisma/prisma.service'; import { sealTranscript } from '../src/voice/core/transcript'; import { openVoiceText, voiceAad } from '../src/voice/core/voice-crypto'; import { FakeStructuredModel } from '../src/voice/extraction/fake-structured-model'; import { VoiceExtractionService } from '../src/voice/extraction/voice-extraction.service'; const SECRET_TEXT = 'Geheimer Befund Königin gesehen vier Brutwaben'; const ANSWER = { findings: [ { field: 'queenSeen', value: true, quote: 'Königin gesehen', corrected: false, }, ], unclear: [], }; /** * The extraction loop against the real database with a FakeStructuredModel * (no network, no key). Runs are scoped to this file's own users; every row * is created and removed here because other suites share the database. */ describe('voice extraction loop (e2e)', () => { const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const originalLimit = process.env.VOICE_DAILY_EXTRACTIONS; let prisma: PrismaService; let roleId: string; let userId: string; let apiaryId: string; let colonyId: string; const mine = Prisma.sql`"ownerUserId" IN (SELECT "id" FROM "User" WHERE "email" LIKE ${`%${suffix}%`})`; const run = (model: FakeStructuredModel, signal?: AbortSignal) => new VoiceExtractionService(prisma, model).runOnce(signal, { filter: mine, }); const row = (id: string) => prisma.voiceCapture.findUniqueOrThrow({ where: { id } }); const events = (id: string, type: string) => prisma.domainEvent.findMany({ where: { aggregateType: 'VoiceCapture', aggregateId: id, type }, }); async function seed( data: Partial = {}, ): Promise { const created = await prisma.voiceCapture.create({ data: { ownerUserId: userId, colonyId, inspectionDate: new Date('2026-10-16'), source: 'TEXT', status: 'TRANSCRIBED', contentExpiresAt: new Date(Date.now() + 7 * 24 * 3_600_000), ...data, }, }); await prisma.voiceCapture.update({ where: { id: created.id }, data: { transcriptEnc: sealTranscript(created.id, { v: 1, text: SECRET_TEXT, }), }, }); return created.id; } async function ledgerCleanup(): Promise { const own = await prisma.voiceCapture.findMany({ where: { ownerUserId: userId }, select: { id: true }, }); const ledgerIds = own.map((each) => each.id); await prisma.voiceExtractionCall.deleteMany({ where: { OR: [ { captureId: { in: ledgerIds } }, { captureId: { endsWith: suffix } }, { id: { endsWith: suffix } }, ], }, }); } beforeAll(async () => { prisma = new PrismaService(); await prisma.$connect(); roleId = ( await prisma.role.create({ data: { key: `VOICE_EX_${suffix}`, name: `Voice ex ${suffix}` }, }) ).id; userId = ( await prisma.user.create({ data: { email: `voice-ex-${suffix}@example.com`, passwordHash: await bcrypt.hash('SuperSecret123!', 4), name: 'Voice extraction', roleId, }, }) ).id; apiaryId = ( await prisma.apiary.create({ data: { name: `Voice ex ${suffix}` } }) ).id; colonyId = ( await prisma.colony.create({ data: { code: `VEX-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }) ).id; }); beforeEach(async () => { process.env.VOICE_DAILY_EXTRACTIONS = '10000'; const own = await prisma.voiceCapture.findMany({ where: { ownerUserId: userId }, select: { id: true }, }); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'VoiceCapture', aggregateId: { in: own.map((c) => c.id) }, }, }); await ledgerCleanup(); await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } }); }); afterAll(async () => { if (originalLimit === undefined) { delete process.env.VOICE_DAILY_EXTRACTIONS; } else { process.env.VOICE_DAILY_EXTRACTIONS = originalLimit; } const own = await prisma.voiceCapture.findMany({ where: { ownerUserId: userId }, select: { id: true }, }); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'VoiceCapture', aggregateId: { in: own.map((c) => c.id) }, }, }); await ledgerCleanup(); await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } }); await prisma.user.deleteMany({ where: { id: userId } }); await prisma.role.deleteMany({ where: { id: roleId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.$disconnect(); }); it('turns a transcribed note into READY with a sealed suggestion, tokens and one event', async () => { const id = await seed(); const model = new FakeStructuredModel([FakeStructuredModel.ok(ANSWER)]); await run(model); const after = await row(id); expect(after).toMatchObject({ status: 'READY', claimToken: null, claimedAt: null, model: 'claude-sonnet-5-5', inputTokens: 1500, outputTokens: 400, }); expect(after.readyAt).not.toBeNull(); expect(after.suggestionEnc).not.toContain('queenSeen'); const stored = JSON.parse( openVoiceText(after.suggestionEnc ?? '', voiceAad(id, 'suggestion')), ) as { suggestions: { field: string }[] }; expect(stored.suggestions.map((each) => each.field)).toEqual(['queenSeen']); const ready = await events(id, 'VoiceCaptureReady'); expect(ready).toHaveLength(1); expect(JSON.stringify(ready[0].payload)).not.toContain('Königin'); }); it('parks the note with EXTRACT_DISABLED, burns no attempt and writes one operator event', async () => { const first = await seed(); const second = await seed(); await run(new FakeStructuredModel([], false)); for (const id of [first, second]) { const after = await row(id); expect(after).toMatchObject({ status: 'TRANSCRIBED', attempts: 0, lastErrorCode: 'EXTRACT_DISABLED', }); expect(after.availableAt.getTime()).toBeGreaterThan( Date.now() + 3_000_000, ); } const disabled = [ ...(await events(first, 'VoiceCaptureFailed')), ...(await events(second, 'VoiceCaptureFailed')), ]; expect(disabled).toHaveLength(1); }); it('counts paid calls, not notes, against the daily limit', async () => { // Two paid calls today that never produced a READY note, plus calls that // never reached the model (they do not count). await prisma.$executeRaw` INSERT INTO "VoiceExtractionCall" ("id", "model", "outcome", "captureId") VALUES (${`ex-1-${suffix}`}, 'claude-sonnet-5-5', 'RETRY', ${`gone-${suffix}`}), (${`ex-2-${suffix}`}, 'claude-sonnet-5-5', 'REFUSED', ${`gone-${suffix}`}), (${`ex-3-${suffix}`}, 'claude-sonnet-5-5', 'AUTH', ${`gone-${suffix}`})`; const waiting = await seed(); const own = await prisma.voiceExtractionCall.count({ where: { captureId: `gone-${suffix}`, outcome: { notIn: ['AUTH', 'ABORTED'] }, }, }); expect(own).toBe(2); const todayCounted = await prisma.$queryRaw<{ n: number }[]>` SELECT count(*)::int AS "n" FROM "VoiceExtractionCall" WHERE "outcome" NOT IN ('AUTH', 'ABORTED') AND "at" >= date_trunc('day', now() AT TIME ZONE 'UTC')`; process.env.VOICE_DAILY_EXTRACTIONS = String(todayCounted[0].n); const model = new FakeStructuredModel([FakeStructuredModel.ok(ANSWER)]); await run(model); expect(model.requests).toHaveLength(0); const after = await row(waiting); expect(after).toMatchObject({ status: 'TRANSCRIBED', attempts: 0, lastErrorCode: 'DAILY_LIMIT', }); expect(after.availableAt.getTime()).toBeGreaterThan(Date.now()); // One call below the limit the note goes through and is booked. process.env.VOICE_DAILY_EXTRACTIONS = String(todayCounted[0].n + 1); await prisma.voiceCapture.update({ where: { id: waiting }, data: { availableAt: new Date(Date.now() - 1000) }, }); await run(model); expect(model.requests).toHaveLength(1); const booked = await prisma.voiceExtractionCall.findMany({ where: { captureId: waiting }, }); expect(booked).toEqual([ expect.objectContaining({ outcome: 'OK', model: 'claude-sonnet-5-5', inputTokens: 1500, outputTokens: 400, }), ]); }); it('retries three times with the backoff and then ends FAILED with one event', async () => { const id = await seed(); const model = new FakeStructuredModel([ { kind: 'error', code: 'RETRYABLE' }, ]); await run(model); expect(await row(id)).toMatchObject({ status: 'TRANSCRIBED', attempts: 1, lastErrorCode: 'EXTRACT_RETRY', }); for (const attempts of [1, 2]) { await prisma.voiceCapture.update({ where: { id }, data: { availableAt: new Date(Date.now() - 1000) }, }); await run(model); expect((await row(id)).attempts).toBe(attempts + 1); } expect(await row(id)).toMatchObject({ status: 'FAILED', attempts: 3, lastErrorCode: 'EXTRACT_FAILED', }); expect(await events(id, 'VoiceCaptureFailed')).toHaveLength(1); }); it('ends FAILED/REFUSED at once when the model declines', async () => { const id = await seed(); await run( new FakeStructuredModel([ { kind: 'refused', usage: { input: 10, output: 1, cacheRead: 0, cacheWrite: 0 }, model: 'claude-sonnet-5-5', }, ]), ); expect(await row(id)).toMatchObject({ status: 'FAILED', lastErrorCode: 'REFUSED', }); expect(await events(id, 'VoiceCaptureFailed')).toHaveLength(1); }); it('does not burn an attempt when shutdown cuts the call off', async () => { const id = await seed(); const stop = new AbortController(); const model = new FakeStructuredModel([]); jest.spyOn(model, 'extract').mockImplementation(() => { stop.abort(); return Promise.resolve({ kind: 'error', code: 'RETRYABLE' }); }); await run(model, stop.signal); expect(await row(id)).toMatchObject({ status: 'TRANSCRIBED', attempts: 0, }); }); it('a confirmed capture is not overwritten by a late extraction', async () => { const id = await seed(); const model = new FakeStructuredModel([]); jest.spyOn(model, 'extract').mockImplementation(async () => { // The user saves the inspection while the model is still working. await prisma.voiceCapture.update({ where: { id }, data: { status: 'DISCARDED', transcriptEnc: null }, }); return FakeStructuredModel.ok(ANSWER); }); await run(model); const after = await row(id); expect(after.status).toBe('DISCARDED'); expect(after.suggestionEnc).toBeNull(); expect(await events(id, 'VoiceCaptureReady')).toHaveLength(0); }); it('puts a note whose worker died back, and fails it after the third stale release', async () => { const id = await seed({ status: 'EXTRACTING', attempts: 2, claimToken: 'dead-worker', }); await prisma.$executeRaw` UPDATE "VoiceCapture" SET "claimedAt" = now() - interval '10 minutes' WHERE "id" = ${id}`; await run(new FakeStructuredModel([], false)); expect(await row(id)).toMatchObject({ status: 'FAILED', lastErrorCode: 'EXTRACT_FAILED', }); expect(await events(id, 'VoiceCaptureFailed')).toHaveLength(1); }); });