Refs #5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
89 lines
3.9 KiB
TypeScript
89 lines
3.9 KiB
TypeScript
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<HttpResponse>(() => {});
|
|
|
|
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<HttpResponse>((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<HttpResponse>((_, 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);
|
|
}
|
|
});
|
|
});
|