import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CANCELLED, DEFAULT_OPENAI, OpenAiTranscriber, REQUEST_TIMEOUT_MS, TIMED_OUT } from '../src/core/openai'; import type { HttpResponse, TranscribeResult } from '../src/core/ports'; import { SENT_STALE_MS } from '../src/core/record'; import { FakeHttp } from './fakes'; const settings = () => ({ ...DEFAULT_OPENAI, apiKey: 'sk-test-123' }); const audio = () => new Uint8Array([1, 2, 3]).buffer; const never = () => new Promise(() => {}); describe('timeout invariant', () => { it('is 5 minutes and below the stale-sent threshold', () => { expect(REQUEST_TIMEOUT_MS).toBe(300_000); expect(REQUEST_TIMEOUT_MS).toBeLessThan(SENT_STALE_MS); }); }); describe('timeout and abort', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('gives up after exactly REQUEST_TIMEOUT_MS and leaves no timers', async () => { const t = new OpenAiTranscriber(new FakeHttp(never), settings, () => 0); let done: TranscribeResult | undefined; const p = t.transcribe(audio(), 'audio/mp4', new AbortController().signal).then((r) => (done = r)); await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS - 1); expect(done).toBeUndefined(); await vi.advanceTimersByTimeAsync(1); await p; expect(done).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT }); expect(vi.getTimerCount()).toBe(0); }); it('returns "cancelled" right away when aborted in flight', async () => { const c = new AbortController(); const remove = vi.spyOn(c.signal, 'removeEventListener'); const p = new OpenAiTranscriber(new FakeHttp(never), settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal); await vi.advanceTimersByTimeAsync(1000); c.abort(); expect(await p).toEqual({ ok: false, kind: 'temporary', message: CANCELLED }); expect(vi.getTimerCount()).toBe(0); expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); }); it('clears the timer and the abort listener after a normal answer', async () => { const c = new AbortController(); const remove = vi.spyOn(c.signal, 'removeEventListener'); const result = await new OpenAiTranscriber(new FakeHttp(), settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal); expect(result).toEqual({ ok: true, text: 'ok' }); expect(vi.getTimerCount()).toBe(0); expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); }); it('ignores a late answer after timeout', async () => { let resolve!: (r: HttpResponse) => void; const http = new FakeHttp(() => new Promise((r) => (resolve = r))); const p = new OpenAiTranscriber(http, settings, () => 0).transcribe(audio(), 'audio/mp4', new AbortController().signal); await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS); expect(await p).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT }); resolve({ status: 200, text: '{"text":"late"}' }); await vi.advanceTimersByTimeAsync(0); expect(await p).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT }); }); it('does not leak an unhandled rejection from a late failure', async () => { const unhandled: unknown[] = []; const onUnhandled = (e: unknown) => unhandled.push(e); process.on('unhandledRejection', onUnhandled); try { let reject!: (e: unknown) => void; const http = new FakeHttp(() => new Promise((_, r) => (reject = r))); const c = new AbortController(); const p = new OpenAiTranscriber(http, settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal); c.abort(); expect(await p).toEqual({ ok: false, kind: 'temporary', message: CANCELLED }); reject(new Error('late failure')); vi.useRealTimers(); await new Promise((r) => setTimeout(r, 10)); expect(unhandled).toEqual([]); } finally { process.off('unhandledRejection', onUnhandled); } }); });