import { Test, TestingModule } from '@nestjs/testing'; import * as bcrypt from 'bcryptjs'; import { Prisma } from '../generated/prisma'; import { PrismaModule } from '../src/prisma/prisma.module'; import { PrismaService } from '../src/prisma/prisma.service'; import { claimVoiceCaptures, deferVoiceClaim, releaseStaleVoiceClaims, settleVoiceFailure, settleVoiceSuccess, } from '../src/voice/claims/voice-claims'; /** * Every query is scoped to this file's own owner (ownerUserId filter) and * every row is created and removed here: other suites share the database. */ describe('voice claims (e2e)', () => { const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; let moduleRef: TestingModule; let prisma: PrismaService; let roleId: string; let userId: string; let apiaryId: string; let colonyId: string; const mine = () => Prisma.sql`"ownerUserId" = ${userId}`; const stale = (phase: 'transcribe' | 'extract') => releaseStaleVoiceClaims(prisma, phase, { filter: mine() }); const claim = ( phase: 'transcribe' | 'extract', limit: number, maxTotalSec?: number, ) => claimVoiceCaptures(prisma, phase, { limit, maxTotalSec, filter: mine() }); const TEXT_WAITING = { status: 'TRANSCRIBED', source: 'TEXT', audioMime: null, durationSec: null, } as const; async function seed( count: number, data: Partial = {}, ): Promise { const ids: string[] = []; for (let i = 0; i < count; i++) { const row = await prisma.voiceCapture.create({ data: { ownerUserId: userId, colonyId, inspectionDate: new Date('2026-10-16'), source: 'AUDIO', audioMime: 'audio/webm', durationSec: 30, contentExpiresAt: new Date(Date.now() + 7 * 86_400_000), ...data, }, }); ids.push(row.id); } return ids; } const rowOf = (id: string) => prisma.voiceCapture.findUniqueOrThrow({ where: { id } }); beforeAll(async () => { moduleRef = await Test.createTestingModule({ imports: [PrismaModule], }).compile(); await moduleRef.init(); prisma = moduleRef.get(PrismaService); const role = await prisma.role.create({ data: { key: `VOICE_CLAIMS_${suffix}`, name: `Voice claims ${suffix}` }, }); roleId = role.id; const user = await prisma.user.create({ data: { email: `voice-claims-${suffix}@example.com`, passwordHash: await bcrypt.hash('SuperSecret123!', 4), name: 'Voice claims', roleId, }, }); userId = user.id; const apiary = await prisma.apiary.create({ data: { name: `Voice claims ${suffix}` }, }); apiaryId = apiary.id; const colony = await prisma.colony.create({ data: { code: `VC-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyId = colony.id; }); beforeEach(async () => { await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } }); }); afterAll(async () => { await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.user.deleteMany({ where: { id: userId } }); await prisma.role.deleteMany({ where: { id: roleId } }); await moduleRef.close(); }); it('fetch claims disjoint sets under SKIP LOCKED', async () => { const ids = await seed(8); const claimedBy: string[][] = [[], [], []]; const worker = async (n: number) => { for (let round = 0; round < 8; round++) { const got = await claim('transcribe', 3); if (got.length === 0) { return; } claimedBy[n].push(...got.map((c) => c.id)); } }; await Promise.all([worker(0), worker(1), worker(2)]); const all = claimedBy.flat(); expect(new Set(all).size).toBe(all.length); expect([...all].sort()).toEqual([...ids].sort()); const rows = await prisma.voiceCapture.findMany({ where: { ownerUserId: userId }, }); expect(rows.every((r) => r.status === 'TRANSCRIBING')).toBe(true); expect(rows.every((r) => r.claimToken && r.claimedAt)).toBe(true); }); it('hands out a distinct claim token per capture and the data a phase needs', async () => { await seed(2, { durationSec: 42 }); const got = await claim('transcribe', 5); expect(new Set(got.map((c) => c.claimToken)).size).toBe(2); expect(got[0]).toMatchObject({ attempts: 0, ownerUserId: userId, colonyId, audioMime: 'audio/webm', durationSec: 42, }); }); it('claims only captures that are due in the phase', async () => { const [due] = await seed(1); await seed(1, { availableAt: new Date(Date.now() + 3_600_000) }); await seed(1, { status: 'TRANSCRIBED' }); const got = await claim('transcribe', 10); expect(got.map((c) => c.id)).toEqual([due]); }); it('respects the total seconds of audio per run', async () => { const ids = await seed(3, { durationSec: 200 }); const got = await claim('transcribe', 5, 450); expect(got.map((c) => c.id)).toEqual(ids.slice(0, 2)); expect((await rowOf(ids[2])).status).toBe('QUEUED'); }); it('still claims the first capture when it alone exceeds the seconds budget', async () => { await seed(1, { durationSec: 240 }); expect(await claim('transcribe', 5, 100)).toHaveLength(1); }); it('does not claim a capture whose content has expired', async () => { await seed(1, { contentExpiresAt: new Date(Date.now() - 1000) }); expect(await claim('transcribe', 5)).toHaveLength(0); }); it('defers with a code that stays visible and counts no attempt', async () => { const [id] = await seed(1, TEXT_WAITING); const [claimed] = await claim('extract', 1); expect( await deferVoiceClaim(prisma, 'extract', claimed, 60_000, 'DAILY_LIMIT'), ).toBe(true); expect(await rowOf(id)).toMatchObject({ status: 'TRANSCRIBED', attempts: 0, lastErrorCode: 'DAILY_LIMIT', }); }); it('settles a success, resets attempts after transcribing and clears the claim', async () => { const [id] = await seed(1, { attempts: 1 }); const [claimed] = await claim('transcribe', 1); const ok = await settleVoiceSuccess( prisma, 'transcribe', claimed, Prisma.sql`"transcriptEnc" = ${'ct'}`, ); expect(ok).toBe(true); expect(await rowOf(id)).toMatchObject({ status: 'TRANSCRIBED', claimToken: null, claimedAt: null, attempts: 0, }); }); it('settle does nothing when the claim token no longer matches', async () => { const [id] = await seed(1); const [claimed] = await claim('transcribe', 1); const wrong = { ...claimed, claimToken: 'someone-else' }; expect(await settleVoiceSuccess(prisma, 'transcribe', wrong)).toBe(false); expect( await settleVoiceFailure( prisma, 'transcribe', wrong, 'TRANSCRIBE_FAILED', ), ).toBe('lost'); expect(await deferVoiceClaim(prisma, 'transcribe', wrong, 1000)).toBe( false, ); expect(await rowOf(id)).toMatchObject({ status: 'TRANSCRIBING', claimToken: claimed.claimToken, attempts: 0, lastErrorCode: null, }); }); it('settle does nothing on a capture confirmed meanwhile', async () => { const [id] = await seed(1); const [claimed] = await claim('transcribe', 1); await prisma.voiceCapture.update({ where: { id }, data: { status: 'CONFIRMED', inspectionId: `insp-${suffix}`, claimedAt: null, claimToken: null, }, }); expect(await settleVoiceSuccess(prisma, 'transcribe', claimed)).toBe(false); expect( await settleVoiceFailure( prisma, 'transcribe', claimed, 'TRANSCRIBE_FAILED', ), ).toBe('lost'); expect( await deferVoiceClaim(prisma, 'transcribe', claimed, 1000, 'DAILY_LIMIT'), ).toBe(false); expect(await rowOf(id)).toMatchObject({ status: 'CONFIRMED', attempts: 0, lastErrorCode: null, }); }); it('retries after the policy delay and the third failure ends FAILED with the code', async () => { const [id] = await seed(1); const delays: number[] = []; for (let round = 1; round <= 3; round++) { await prisma.voiceCapture.update({ where: { id }, data: { availableAt: new Date(Date.now() - 1000) }, }); const [claimed] = await claim('transcribe', 1); expect(claimed.attempts).toBe(round - 1); const outcome = await settleVoiceFailure( prisma, 'transcribe', claimed, 'TRANSCRIBE_FAILED', ); expect(outcome).toBe(round < 3 ? 'retry' : 'failed'); const row = await rowOf(id); if (round < 3) { delays.push(row.availableAt.getTime() - Date.now()); expect(row.status).toBe('QUEUED'); } else { expect(row).toMatchObject({ status: 'FAILED', attempts: 3, lastErrorCode: 'TRANSCRIBE_FAILED', claimToken: null, claimedAt: null, }); } } expect(delays[0]).toBeGreaterThan(290_000); expect(delays[0]).toBeLessThanOrEqual(300_500); expect(delays[1]).toBeGreaterThan(890_000); expect(delays[1]).toBeLessThanOrEqual(900_500); }); it('ends an extraction that kept being retryable as EXTRACT_FAILED', async () => { const [id] = await seed(1, { ...TEXT_WAITING, attempts: 2 }); const [claimed] = await claim('extract', 1); expect( await settleVoiceFailure(prisma, 'extract', claimed, 'EXTRACT_RETRY'), ).toBe('failed'); expect(await rowOf(id)).toMatchObject({ status: 'FAILED', lastErrorCode: 'EXTRACT_FAILED', }); }); it('defers a claim without counting an attempt', async () => { const [id] = await seed(1, TEXT_WAITING); const [claimed] = await claim('extract', 1); expect(await deferVoiceClaim(prisma, 'extract', claimed, 600_000)).toBe( true, ); const row = await rowOf(id); expect(row).toMatchObject({ status: 'TRANSCRIBED', attempts: 0, claimToken: null, }); expect(row.availableAt.getTime() - Date.now()).toBeGreaterThan(590_000); expect(await claim('extract', 1)).toHaveLength(0); }); it('stale TRANSCRIBING claims are released with attempts + 1', async () => { const [id] = await seed(1); await claim('transcribe', 1); await prisma.$executeRaw`UPDATE "VoiceCapture" SET "claimedAt" = now() - interval '31 minutes' WHERE "id" = ${id}`; expect(await stale('transcribe')).toEqual([ { id, status: 'QUEUED', code: null }, ]); expect(await rowOf(id)).toMatchObject({ status: 'QUEUED', attempts: 1, claimToken: null, claimedAt: null, }); }); it('keeps a fresh claim and ends a worn-out stale one as FAILED', async () => { const [fresh] = await seed(1); const [worn] = await seed(1, { attempts: 2 }); await claim('transcribe', 2); await prisma.$executeRaw`UPDATE "VoiceCapture" SET "claimedAt" = now() - interval '29 minutes' WHERE "id" = ${worn}`; expect(await stale('transcribe')).toEqual([]); await prisma.$executeRaw`UPDATE "VoiceCapture" SET "claimedAt" = now() - interval '31 minutes' WHERE "id" = ${worn}`; expect(await stale('transcribe')).toEqual([ { id: worn, status: 'FAILED', code: 'TRANSCRIBE_FAILED' }, ]); expect(await rowOf(worn)).toMatchObject({ attempts: 3, lastErrorCode: 'TRANSCRIBE_FAILED', }); expect((await rowOf(fresh)).status).toBe('TRANSCRIBING'); }); it('uses the shorter stale window of the extract phase', async () => { const [id] = await seed(1, TEXT_WAITING); await claim('extract', 1); await prisma.$executeRaw`UPDATE "VoiceCapture" SET "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${id}`; expect(await stale('transcribe')).toEqual([]); expect(await stale('extract')).toEqual([ { id, status: 'TRANSCRIBED', code: null }, ]); expect((await rowOf(id)).attempts).toBe(1); }); });