feat: Wiederholregel und Voice-Claims (T7 L2 Task 10)

RetryPolicy mit retryDelayMsFor (bestehende Aufrufer unverändert),
Phasen transcribe/extract mit eigenen Wartezeiten (5/15 min bzw.
30 s/2 min, 3 Versuche) und Stale-Fenstern (30 bzw. 5 min). Claims per
FOR UPDATE SKIP LOCKED mit claimToken als Compare-and-Set, nur
Notizen mit gültigem Inhalt; Erfolg setzt den nächsten Status und nach
dem Abschreiben attempts = 0; Fehler meldet 'lost', wenn die Notiz
inzwischen bestätigt oder verworfen wurde; Aufschub mit Grund;
Stale-Freigabe transaktionsfähig mit den beendeten Notizen als
Rückgabe. Eine Quelle für die 14 Fehlercodes.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:32:16 +02:00
1 parent af9df0afe4
commit 2a4dc868d9
8 files changed
+959 -3

No files matched your search

@@ -0,0 +1,369 @@
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<Prisma.VoiceCaptureUncheckedCreateInput> = {},
): Promise<string[]> {
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);
});
});