diff --git a/hub/src/web/routes/voice.test.ts b/hub/src/web/routes/voice.test.ts index 88fd344d..0969ff70 100644 --- a/hub/src/web/routes/voice.test.ts +++ b/hub/src/web/routes/voice.test.ts @@ -488,6 +488,21 @@ describe('GET /api/voice/backend', () => { } }) + test('returns no backend when no voice credentials are configured', async () => { + delete process.env.VOICE_BACKEND + delete process.env.ELEVENLABS_API_KEY + delete process.env.GEMINI_API_KEY + delete process.env.GOOGLE_API_KEY + delete process.env.DASHSCOPE_API_KEY + delete process.env.QWEN_API_KEY + const app = createApp() + const headers = await authHeaders() + const res = await app.request('/api/voice/backend', { headers }) + expect(res.status).toBe(200) + const body = await res.json() as { backend: string | null; backends: string[] } + expect(body).toEqual({ backend: null, backends: [] }) + }) + test('returns elevenlabs by default with backends list', async () => { delete process.env.VOICE_BACKEND delete process.env.GEMINI_API_KEY diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 2bfa8006..25926e50 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -127,4 +127,14 @@ describe('ApiClient error mapping', () => { expect(init?.body).toBeInstanceOf(FormData) expect(new Headers(init?.headers).has('content-type')).toBe(false) }) + + it('preserves an unavailable voice backend response', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ backend: null, backends: [] }), { status: 200 }) + ) + + const api = new ApiClient('test-token') + await expect(api.fetchVoiceBackend()).resolves.toEqual({ backend: null, backends: [] }) + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/voice/backend') + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e945c748..73aa6b6a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -951,7 +951,7 @@ export class ApiClient { return this.getToken ? this.getToken() : this.token } - async fetchVoiceBackend(): Promise<{ backend: string; backends: string[] }> { + async fetchVoiceBackend(): Promise<{ backend: string | null; backends: string[] }> { return await this.request('/api/voice/backend') } diff --git a/web/src/realtime/VoiceBackendSession.test.tsx b/web/src/realtime/VoiceBackendSession.test.tsx index 8c592da1..131c597b 100644 --- a/web/src/realtime/VoiceBackendSession.test.tsx +++ b/web/src/realtime/VoiceBackendSession.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, waitFor } from '@testing-library/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { VoiceBackendSession } from '@/realtime/VoiceBackendSession' import type { ApiClient } from '@/api/client' @@ -10,15 +10,15 @@ vi.mock('@/api/voice', () => ({ })) vi.mock('@/realtime/RealtimeVoiceSession', () => ({ - RealtimeVoiceSession: () => null, + RealtimeVoiceSession: () =>
, })) vi.mock('@/realtime/GeminiLiveVoiceSession', () => ({ - GeminiLiveVoiceSession: () => null, + GeminiLiveVoiceSession: () => , })) vi.mock('@/realtime/QwenVoiceSession', () => ({ - QwenVoiceSession: () => null, + QwenVoiceSession: () => , })) const api = {} as ApiClient @@ -48,6 +48,29 @@ describe('VoiceBackendSession', () => { consoleError.mockRestore() }) + it('does not mount a voice session when no backend is configured', async () => { + fetchVoiceBackendMock.mockResolvedValue({ backend: null, backends: [] }) + const onReadyChange = vi.fn() + + const view = render( +