obsidian-voice-note/tests/openai-timeout.test.ts
Andrey Epifancev 06d3528efd feat(core): OpenAI-compatible transcriber with prechecks, timeout and abort
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 00:31:01 +04:00

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);
}
});
});