import { describe, expect, it } from 'vitest'; import { BACKOFF_MS, InvalidTransition, MAX_ATTEMPTS, transition, type RecordEvent, type Status, type VoiceRecord, } from '../src/core/record'; const NOW = new Date('2026-09-27T11:15:03.000Z'); const base: VoiceRecord = Object.freeze({ id: '20260927-111503-0000', status: 'saved', device: 'd1', audio: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/mp4', attempts: 0, }); const sent = (over: Partial = {}): VoiceRecord => Object.freeze({ ...base, status: 'sent' as const, sentAt: NOW.toISOString(), ...over }); describe('transition', () => { it('send: saved → sent with sentAt', () => { expect(transition(base, { type: 'send' }, NOW)).toEqual({ ...base, status: 'sent', sentAt: '2026-09-27T11:15:03.000Z', }); }); it('send: retry → sent, drops nextAttempt, keeps attempts and error', () => { const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 1, nextAttempt: 'x', error: 'timeout' }); expect(transition(r, { type: 'send' }, NOW)).toEqual({ ...base, status: 'sent', attempts: 1, error: 'timeout', sentAt: NOW.toISOString(), }); }); it('success: sent → inserted, clears transient keys, keeps attempts', () => { expect(transition(sent({ error: 'old', attempts: 2 }), { type: 'success' }, NOW)).toEqual({ ...base, status: 'inserted', attempts: 2, }); }); it('temporary: backoff 30s, 2m, 10m, 30m after failures 1..4', () => { const got = [0, 1, 2, 3].map((a) => transition(sent({ attempts: a }), { type: 'temporary', message: 'timeout' }, NOW), ); expect(got.map((r) => r.status)).toEqual(['retry', 'retry', 'retry', 'retry']); expect(got.map((r) => r.attempts)).toEqual([1, 2, 3, 4]); expect(got.map((r) => Date.parse(r.nextAttempt!) - NOW.getTime())).toEqual([...BACKOFF_MS]); expect(got.every((r) => r.sentAt === undefined && r.error === 'timeout')).toBe(true); }); it('temporary: fifth failure → failed with last message', () => { expect( transition(sent({ attempts: MAX_ATTEMPTS - 1 }), { type: 'temporary', message: 'e5' }, NOW), ).toEqual({ ...base, status: 'failed', attempts: 5, error: 'e5' }); }); it('permanent: sent → failed immediately', () => { expect(transition(sent(), { type: 'permanent', message: '401 Unauthorized' }, NOW)).toEqual({ ...base, status: 'failed', attempts: 1, error: '401 Unauthorized', }); }); it('retry-now: failed → saved, attempts 0, device reassigned', () => { const r = Object.freeze({ ...base, status: 'failed' as const, attempts: 5, error: '401' }); expect(transition(r, { type: 'retry-now', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); }); it('retry-now: retry → saved, drops nextAttempt', () => { const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); expect(transition(r, { type: 'retry-now', device: 'd1' }, NOW)).toEqual(base); }); it('claim: retry → saved, new device, attempts kept', () => { const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); expect(transition(r, { type: 'claim', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2', attempts: 2, error: 't', }); }); it('claim: sent → saved, drops sentAt', () => { expect(transition(sent(), { type: 'claim', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); }); const invalid: [Status, RecordEvent][] = [ ['inserted', { type: 'send' }], ['failed', { type: 'send' }], ['sent', { type: 'send' }], ['saved', { type: 'success' }], ['retry', { type: 'success' }], ['saved', { type: 'temporary', message: 'x' }], ['inserted', { type: 'permanent', message: 'x' }], ['saved', { type: 'retry-now', device: 'd1' }], ['inserted', { type: 'retry-now', device: 'd1' }], ['failed', { type: 'claim', device: 'd2' }], ['inserted', { type: 'claim', device: 'd2' }], ]; it.each(invalid)('rejects %s + %o', (status, event) => { const r = Object.freeze({ ...base, status }); expect(() => transition(r, event, NOW)).toThrow(InvalidTransition); }); });