Files
openmaic/OpenMAIC/tests/audio/doubao-tts.test.ts
2026-08-16 14:58:47 +08:00

117 lines
4.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { generateTTS } from '@/lib/audio/tts-providers';
const mockFetch = vi.fn() as Mock;
vi.stubGlobal('fetch', mockFetch);
// Build the chunked NDJSON body the Doubao /unidirectional endpoint streams:
// one {code,data} JSON object per line, ending with the 20000000 sentinel.
function ndjsonBody(audioChunksB64: string[]): string {
const lines = audioChunksB64.map((d) => JSON.stringify({ code: 0, data: d }));
lines.push(JSON.stringify({ code: 20000000, message: 'done' }));
return lines.join('\n');
}
function okResponse(body: string) {
return {
ok: true,
status: 200,
text: async () => body,
headers: { get: () => 'application/json' },
};
}
const helloB64 = Buffer.from([1, 2, 3, 4]).toString('base64');
describe('Doubao TTS dual auth', () => {
beforeEach(() => {
mockFetch.mockReset();
});
it('uses X-Api-Key + plan endpoint for an Agent Plan single key (no colon)', async () => {
mockFetch.mockResolvedValueOnce(okResponse(ndjsonBody([helloB64])));
const result = await generateTTS(
{
providerId: 'doubao-tts',
apiKey: 'ark-plan-key-123',
baseUrl: 'https://openspeech.bytedance.com/api/plan/tts',
voice: 'zh_female_vv_uranus_bigtts',
},
'你好',
);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('https://openspeech.bytedance.com/api/plan/tts/unidirectional');
expect(init.headers['X-Api-Key']).toBe('ark-plan-key-123');
expect(init.headers['X-Api-Resource-Id']).toBe('seed-tts-2.0');
// Plan mode must NOT send the appId/accessKey pair headers.
expect(init.headers['X-Api-App-Id']).toBeUndefined();
expect(init.headers['X-Api-Access-Key']).toBeUndefined();
expect(result.audio).toBeInstanceOf(Uint8Array);
expect(result.audio.byteLength).toBe(4);
});
it('uses X-Api-App-Id + X-Api-Access-Key for a classic appId:accessKey key', async () => {
mockFetch.mockResolvedValueOnce(okResponse(ndjsonBody([helloB64])));
await generateTTS(
{
providerId: 'doubao-tts',
apiKey: 'app123:secretAccessKey',
voice: 'zh_female_vv_uranus_bigtts',
},
'你好',
);
const init = mockFetch.mock.calls[0][1];
expect(init.headers['X-Api-App-Id']).toBe('app123');
expect(init.headers['X-Api-Access-Key']).toBe('secretAccessKey');
expect(init.headers['X-Api-Key']).toBeUndefined();
});
it('rejects an empty key before making a request', async () => {
// The generic generateTTS guard (requiresApiKey && !apiKey) fires first.
await expect(
generateTTS({ providerId: 'doubao-tts', apiKey: '', voice: 'x' }, 'hi'),
).rejects.toThrow(/API key required for TTS provider: doubao-tts/);
expect(mockFetch).not.toHaveBeenCalled();
});
it('rejects a malformed appId:accessKey (empty half) without a request', async () => {
await expect(
generateTTS({ providerId: 'doubao-tts', apiKey: 'appId:', voice: 'x' }, 'hi'),
).rejects.toThrow(/appId:accessKey is malformed/);
await expect(
generateTTS({ providerId: 'doubao-tts', apiKey: ':secret', voice: 'x' }, 'hi'),
).rejects.toThrow(/appId:accessKey is malformed/);
expect(mockFetch).not.toHaveBeenCalled();
});
it('aggregates base64 audio chunks across the NDJSON stream', async () => {
const a = Buffer.from([1, 2]).toString('base64');
const b = Buffer.from([3, 4, 5]).toString('base64');
mockFetch.mockResolvedValueOnce(okResponse(ndjsonBody([a, b])));
const result = await generateTTS(
{ providerId: 'doubao-tts', apiKey: 'ark-k', voice: 'v' },
'hi',
);
expect(Array.from(result.audio)).toEqual([1, 2, 3, 4, 5]);
});
it('parses concatenated JSON errors whose message contains braces', async () => {
mockFetch.mockResolvedValueOnce(
okResponse(
JSON.stringify({ code: 0, data: helloB64 }) +
JSON.stringify({ code: 40000001, message: 'bad payload: {"field":"text"}' }),
),
);
await expect(
generateTTS({ providerId: 'doubao-tts', apiKey: 'ark-k', voice: 'v' }, 'hi'),
).rejects.toThrow(/bad payload: \{"field":"text"\}/);
});
});