Files
ApiCore/ApiCore-Backend/src/voice/claims/voice-claims.spec.ts
T
Nick SzaunigandClaude Opus 5.5 2a4dc868d9 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]>
2026-10-16 09:32:16 +02:00

140 lines
4.0 KiB
TypeScript

import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service';
import {
claimVoiceCaptures,
deferVoiceClaim,
releaseStaleVoiceClaims,
settleVoiceFailure,
settleVoiceSuccess,
VoiceClaim,
} from './voice-claims';
function fakeDb() {
const queryRaw = jest.fn().mockResolvedValue([]);
const executeRaw = jest.fn().mockResolvedValue(1);
return {
db: {
$queryRaw: queryRaw,
$executeRaw: executeRaw,
} as unknown as PrismaService,
queryRaw,
executeRaw,
};
}
const claim: VoiceClaim = {
id: 'c1',
claimToken: 't1',
attempts: 0,
ownerUserId: 'u1',
colonyId: 'k1',
audioMime: 'audio/webm',
durationSec: 30,
};
function sqlOf(call: unknown): Prisma.Sql {
return (call as unknown[])[0] as Prisma.Sql;
}
describe('voice claims (SQL shape)', () => {
it('claims with SKIP LOCKED against the database clock', async () => {
const { db, queryRaw } = fakeDb();
await claimVoiceCaptures(db, 'transcribe', { limit: 5, maxTotalSec: 600 });
const text = sqlOf(queryRaw.mock.calls[0]).sql;
expect(text).toContain('FOR UPDATE SKIP LOCKED');
expect(text).toContain('now()');
expect(text).toContain('gen_random_uuid()');
});
it.each([0, -1, 1.5])('refuses the claim limit %p', async (limit) => {
const { db } = fakeDb();
await expect(claimVoiceCaptures(db, 'extract', { limit })).rejects.toThrow(
RangeError,
);
});
it('refuses an unknown phase', async () => {
const { db } = fakeDb();
await expect(
claimVoiceCaptures(db, 'drop' as never, { limit: 1 }),
).rejects.toThrow(/Unknown voice phase/);
});
it('settles by compare-and-set on status and claim token', async () => {
const { db, executeRaw } = fakeDb();
await expect(settleVoiceSuccess(db, 'transcribe', claim)).resolves.toBe(
true,
);
const { sql, values } = sqlOf(executeRaw.mock.calls[0]);
expect(sql).toContain('"claimToken"');
expect(values).toEqual(
expect.arrayContaining(['c1', 't1', 'TRANSCRIBING']),
);
});
it('reports a lost claim when no row changed', async () => {
const { db, executeRaw } = fakeDb();
executeRaw.mockResolvedValue(0);
await expect(settleVoiceSuccess(db, 'extract', claim)).resolves.toBe(false);
await expect(deferVoiceClaim(db, 'extract', claim, 1000)).resolves.toBe(
false,
);
});
it('decides retry or failed from the attempts before', async () => {
const { db } = fakeDb();
await expect(
settleVoiceFailure(
db,
'transcribe',
{ ...claim, attempts: 1 },
'TRANSCRIBE_FAILED',
),
).resolves.toBe('retry');
await expect(
settleVoiceFailure(
db,
'transcribe',
{ ...claim, attempts: 2 },
'TRANSCRIBE_FAILED',
),
).resolves.toBe('failed');
});
it('reports a lost failure settle', async () => {
const { db, executeRaw } = fakeDb();
executeRaw.mockResolvedValue(0);
await expect(
settleVoiceFailure(db, 'extract', claim, 'EXTRACT_RETRY'),
).resolves.toBe('lost');
});
it('turns the last EXTRACT_RETRY into EXTRACT_FAILED', async () => {
const { db, executeRaw } = fakeDb();
await settleVoiceFailure(
db,
'extract',
{ ...claim, attempts: 2 },
'EXTRACT_RETRY',
);
const { values } = sqlOf(executeRaw.mock.calls[0]);
expect(values).toContain('EXTRACT_FAILED');
expect(values).not.toContain('EXTRACT_RETRY');
});
it.each([-1, 1.5])('refuses the defer delay %p', async (delay) => {
const { db } = fakeDb();
await expect(deferVoiceClaim(db, 'extract', claim, delay)).rejects.toThrow(
RangeError,
);
});
it('releases stale claims with the phase stale window and attempt limit', async () => {
const { db, queryRaw } = fakeDb();
await releaseStaleVoiceClaims(db, 'extract');
const { values } = sqlOf(queryRaw.mock.calls[0]);
expect(values).toContain(300_000);
expect(values).toContain(3);
});
});