import { describe, expect, it, vi } from 'vitest'; import { createLearningSpeechClient } from '@electron/services/learning-speech-client'; describe('Learning speech client', () => { it('keeps authentication in Main and forwards bounded multipart audio', async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response( JSON.stringify({ text: '什么是变量', model: 'stt' }), { status: 200, headers: { 'Content-Type': 'application/json' } }, )); const client = createLearningSpeechClient({ fetchImpl, getAccessToken: vi.fn().mockResolvedValue('main-owned-token'), apiBaseUrl: 'https://square.example', }); await expect(client.transcribe({ audio: new Uint8Array([1, 2, 3]), fileName: 'voice.webm', mimeType: 'audio/webm', language: 'zh-CN', })).resolves.toEqual({ text: '什么是变量' }); const [url, init] = fetchImpl.mock.calls[0]; expect(url).toBe('https://square.example/api/speech/transcriptions'); expect(init?.headers).toEqual({ Authorization: 'Bearer main-owned-token' }); const form = init?.body as FormData; expect(form.get('language')).toBe('zh-CN'); const file = form.get('audio') as File; expect(file.name).toBe('voice.webm'); expect(new Uint8Array(await file.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); }); it('projects only transcript text and never forwards upstream error detail', async () => { const fetchImpl = vi.fn() .mockResolvedValueOnce(new Response(JSON.stringify({ text: ' 安全答案 ', model: 'secret-model', token: 'secret' }), { status: 200 })) .mockResolvedValueOnce(new Response(JSON.stringify({ detail: 'provider key invalid at internal-host' }), { status: 502 })); const client = createLearningSpeechClient({ fetchImpl, getAccessToken: vi.fn().mockResolvedValue('token'), apiBaseUrl: 'https://square.example', }); await expect(client.transcribe({ audio: new Uint8Array([1]), fileName: 'a.webm', mimeType: 'audio/webm' })) .resolves.toEqual({ text: '安全答案' }); await expect(client.transcribe({ audio: new Uint8Array([1]), fileName: 'a.webm', mimeType: 'audio/webm' })) .rejects.toThrow('语音识别失败'); }); it('does not upload audio after its captured account changes during token lookup', async () => { let resolveToken!: (token: string) => void; const fetchImpl = vi.fn(); const client = createLearningSpeechClient({ fetchImpl, getAccessToken: vi.fn(() => new Promise((resolve) => { resolveToken = resolve; })), apiBaseUrl: 'https://square.example', }); let current = true; const result = client.transcribe( { audio: new Uint8Array([1]), fileName: 'a.webm', mimeType: 'audio/webm' }, () => { if (!current) throw new Error('account changed'); }, ); current = false; resolveToken('new-account-token'); await expect(result).rejects.toThrow('account changed'); expect(fetchImpl).not.toHaveBeenCalled(); }); it('does not retry a 401 after its captured account changes during refresh', async () => { let resolveRefresh!: (token: string) => void; const getAccessToken = vi.fn() .mockResolvedValueOnce('old-token') .mockReturnValueOnce(new Promise((resolve) => { resolveRefresh = resolve; })); const fetchImpl = vi.fn().mockResolvedValueOnce(new Response(null, { status: 401 })); const client = createLearningSpeechClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); let current = true; const result = client.transcribe( { audio: new Uint8Array([1]), fileName: 'a.webm', mimeType: 'audio/webm' }, () => { if (!current) throw new Error('account changed'); }, ); await vi.waitFor(() => expect(getAccessToken).toHaveBeenCalledTimes(2)); current = false; resolveRefresh('new-account-token'); await expect(result).rejects.toThrow('account changed'); expect(fetchImpl).toHaveBeenCalledOnce(); }); });