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]>
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|