import { describe, expect, it } from 'vitest'; import { CANCELLED, DEFAULT_OPENAI, EMPTY_AUDIO, NO_API_KEY, NO_BASE_URL, OpenAiTranscriber, type OpenAiSettings, } from '../src/core/openai'; import { bytesIndexOf, FakeHttp, formParts } from './fakes'; const KEY = 'sk-test-123'; const settings = (over: Partial = {}) => () => ({ ...DEFAULT_OPENAI, apiKey: KEY, ...over }); const audio = () => new Uint8Array([0x00, 0xff, 0x80, 0x0d, 0x0a]).buffer; const live = () => new AbortController().signal; async function send(over: Partial = {}, mime = 'audio/mp4') { const http = new FakeHttp(); const result = await new OpenAiTranscriber(http, settings(over), () => 0).transcribe(audio(), mime, live()); return { http, result, req: http.requests[0] }; } describe('request', () => { it.each([ 'https://api.aitunnel.ru/v1', 'https://api.aitunnel.ru/v1/', 'https://api.aitunnel.ru/v1//', ' https://api.aitunnel.ru/v1/ ', ])('posts to /audio/transcriptions for %j', async (baseUrl) => { const { req } = await send({ baseUrl }); expect(req.url).toBe('https://api.aitunnel.ru/v1/audio/transcriptions'); }); it('trims key with newline and sends only Authorization in headers', async () => { const { req } = await send({ apiKey: ` ${KEY} \n` }); expect(req.headers).toEqual({ Authorization: `Bearer ${KEY}` }); }); it('passes multipart contentType separately, boundary from random() matches the body', async () => { const { req } = await send(); const boundary = `----voice-note-${'0'.repeat(24)}`; expect(req.contentType).toBe(`multipart/form-data; boundary=${boundary}`); expect(new TextDecoder().decode(req.body).startsWith(`--${boundary}\r\n`)).toBe(true); }); it('sends model, response_format=json and no language by default', async () => { const { req } = await send(); expect(formParts(req)).toEqual({ model: 'gpt-4o-transcribe', response_format: 'json', file: 'audio.m4a|audio/mp4' }); }); it('sends trimmed model and language when set', async () => { const { req } = await send({ model: ' whisper-1 ', language: ' ru ' }); expect(formParts(req)).toMatchObject({ model: 'whisper-1', language: 'ru' }); }); it.each([ ['audio/mp4;codecs=mp4a.40.2', 'audio.m4a|audio/mp4'], ['audio/webm;codecs=opus', 'audio.webm|audio/webm'], ['audio/ogg', 'audio.ogg|audio/ogg'], ['AUDIO/MP4', 'audio.m4a|audio/mp4'], ['audio/x-foo', 'audio.bin|audio/x-foo'], ['', 'audio.bin|application/octet-stream'], ])('names the file for mime %j', async (mime, expected) => { const { req } = await send({}, mime); expect(formParts(req).file).toBe(expected); }); it('sends audio bytes unchanged', async () => { const { req } = await send(); expect(bytesIndexOf(new Uint8Array(req.body), new Uint8Array(audio()))).toBeGreaterThan(0); }); it('reads settings on every call (fixed key applies without restart)', async () => { const http = new FakeHttp(); const current: OpenAiSettings = { ...DEFAULT_OPENAI, apiKey: 'sk-test-old' }; const t = new OpenAiTranscriber(http, () => current, () => 0); await t.transcribe(audio(), 'audio/mp4', live()); current.apiKey = 'sk-test-new'; await t.transcribe(audio(), 'audio/mp4', live()); expect(http.requests.map((r) => r.headers.Authorization)).toEqual(['Bearer sk-test-old', 'Bearer sk-test-new']); }); it('returns the service text on success', async () => { const { result } = await send(); expect(result).toEqual({ ok: true, text: 'ok' }); }); }); describe('prechecks (no network)', () => { const run = async (over: Partial, data: ArrayBuffer, signal: AbortSignal) => { const http = new FakeHttp(); const result = await new OpenAiTranscriber(http, settings(over), () => 0).transcribe(data, 'audio/mp4', signal); expect(http.requests).toHaveLength(0); return result; }; const aborted = () => { const c = new AbortController(); c.abort(); return c.signal; }; it.each(['', ' \n'])('empty key %j → permanent', async (apiKey) => { expect(await run({ apiKey }, audio(), live())).toEqual({ ok: false, kind: 'permanent', message: NO_API_KEY }); }); it('empty baseUrl → permanent', async () => { expect(await run({ baseUrl: ' ' }, audio(), live())).toEqual({ ok: false, kind: 'permanent', message: NO_BASE_URL }); }); it('0 bytes → permanent', async () => { expect(await run({}, new ArrayBuffer(0), live())).toEqual({ ok: false, kind: 'permanent', message: EMPTY_AUDIO }); }); it('already aborted → temporary', async () => { expect(await run({}, audio(), aborted())).toEqual({ ok: false, kind: 'temporary', message: CANCELLED }); }); it('checks in order: key, baseUrl, audio, signal', async () => { expect(await run({ apiKey: '', baseUrl: '' }, new ArrayBuffer(0), aborted())).toMatchObject({ message: NO_API_KEY }); expect(await run({ baseUrl: '' }, new ArrayBuffer(0), aborted())).toMatchObject({ message: NO_BASE_URL }); expect(await run({}, new ArrayBuffer(0), aborted())).toMatchObject({ message: EMPTY_AUDIO }); }); }); describe('never throws', () => { it('network rejection → temporary "сеть: …"', async () => { const http = new FakeHttp(() => Promise.reject(new Error('net::ERR_INTERNET_DISCONNECTED'))); const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live()); expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: net::ERR_INTERNET_DISCONNECTED' }); }); it('synchronous throw from the client → temporary "сеть: …"', async () => { const http = new FakeHttp(() => { throw new Error('boom'); }); const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live()); expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: boom' }); }); it('non-Error rejection is stringified', async () => { const http = new FakeHttp(() => Promise.reject('offline')); const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live()); expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: offline' }); }); it('unexpected exception → temporary "внутренняя ошибка: …"', async () => { const broken = () => { throw new Error('settings exploded'); }; const result = await new OpenAiTranscriber(new FakeHttp(), broken, () => 0).transcribe(audio(), 'audio/mp4', live()); expect(result).toEqual({ ok: false, kind: 'temporary', message: 'внутренняя ошибка: settings exploded' }); }); });