obsidian-voice-note/tests/openai-request.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

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