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