| |
| |
| |
| |
| |
| |
| |
|
|
| import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; |
| import { isProviderUsable } from '@/lib/store/settings-validation'; |
|
|
| |
| |
| |
|
|
| |
| vi.mock('@/lib/ai/providers', () => ({ |
| PROVIDERS: { |
| openai: { |
| id: 'openai', |
| name: 'OpenAI', |
| type: 'openai', |
| defaultBaseUrl: 'https://api.openai.com/v1', |
| requiresApiKey: true, |
| icon: '/logos/openai.svg', |
| models: [ |
| { id: 'gpt-4o', name: 'GPT-4o' }, |
| { id: 'gpt-4o-mini', name: 'GPT-4o Mini' }, |
| { id: 'gpt-4-turbo', name: 'GPT-4 Turbo' }, |
| ], |
| }, |
| anthropic: { |
| id: 'anthropic', |
| name: 'Anthropic', |
| type: 'anthropic', |
| defaultBaseUrl: 'https://api.anthropic.com', |
| requiresApiKey: true, |
| icon: '/logos/anthropic.svg', |
| models: [ |
| { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' }, |
| { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' }, |
| ], |
| }, |
| }, |
| })); |
|
|
| vi.mock('@/lib/audio/constants', () => ({ |
| TTS_PROVIDERS: { |
| 'openai-tts': { |
| id: 'openai-tts', |
| name: 'OpenAI TTS', |
| requiresApiKey: true, |
| defaultModelId: 'gpt-4o-mini-tts', |
| models: [{ id: 'gpt-4o-mini-tts', name: 'GPT-4o Mini TTS' }], |
| voices: [{ id: 'alloy', name: 'Alloy', language: 'en', gender: 'neutral' }], |
| supportedFormats: ['mp3'], |
| }, |
| 'azure-tts': { |
| id: 'azure-tts', |
| name: 'Azure TTS', |
| requiresApiKey: true, |
| defaultModelId: '', |
| models: [], |
| voices: [{ id: 'zh-CN-XiaoxiaoNeural', name: 'Xiaoxiao', language: 'zh-CN' }], |
| supportedFormats: ['mp3'], |
| }, |
| 'browser-native-tts': { |
| id: 'browser-native-tts', |
| name: 'Browser Native TTS', |
| requiresApiKey: false, |
| defaultModelId: '', |
| models: [], |
| voices: [{ id: 'default', name: 'Default', language: 'en', gender: 'neutral' }], |
| supportedFormats: ['browser'], |
| speedRange: { min: 0.1, max: 10, default: 1 }, |
| }, |
| }, |
| ASR_PROVIDERS: { |
| 'openai-whisper': { |
| id: 'openai-whisper', |
| name: 'OpenAI Whisper', |
| requiresApiKey: true, |
| defaultModelId: 'gpt-4o-mini-transcribe', |
| models: [{ id: 'gpt-4o-mini-transcribe', name: 'GPT-4o Mini Transcribe' }], |
| supportedLanguages: ['auto', 'zh'], |
| supportedFormats: ['webm'], |
| }, |
| 'browser-native': { |
| id: 'browser-native', |
| name: 'Browser Native ASR', |
| requiresApiKey: false, |
| defaultModelId: '', |
| models: [], |
| supportedLanguages: ['zh'], |
| supportedFormats: ['browser'], |
| }, |
| }, |
| DEFAULT_TTS_VOICES: { |
| 'openai-tts': 'alloy', |
| 'browser-native-tts': 'default', |
| }, |
| })); |
|
|
| vi.mock('@/lib/audio/types', () => ({ |
| isCustomTTSProvider: (id: string) => id.startsWith('custom-tts-'), |
| isCustomASRProvider: (id: string) => id.startsWith('custom-asr-'), |
| })); |
|
|
| vi.mock('@/lib/pdf/constants', () => ({ |
| PDF_PROVIDERS: { |
| unpdf: { id: 'unpdf', requiresApiKey: false }, |
| mineru: { id: 'mineru', requiresApiKey: false }, |
| }, |
| })); |
|
|
| vi.mock('@/lib/media/image-providers', () => ({ |
| IMAGE_PROVIDERS: { |
| seedream: { |
| id: 'seedream', |
| requiresApiKey: true, |
| models: [{ id: 'doubao-seedream-5-0-260128', name: 'Seedream 5.0' }], |
| }, |
| 'qwen-image': { |
| id: 'qwen-image', |
| requiresApiKey: true, |
| models: [{ id: 'qwen-image-max', name: 'Qwen Image Max' }], |
| }, |
| }, |
| })); |
|
|
| vi.mock('@/lib/media/video-providers', () => ({ |
| VIDEO_PROVIDERS: { |
| seedance: { |
| id: 'seedance', |
| requiresApiKey: true, |
| models: [{ id: 'doubao-seedance-1-5-pro-251215', name: 'Seedance 1.5 Pro' }], |
| }, |
| kling: { |
| id: 'kling', |
| requiresApiKey: true, |
| models: [{ id: 'kling-v2-6', name: 'Kling V2' }], |
| }, |
| }, |
| })); |
|
|
| vi.mock('@/lib/logger', () => ({ |
| createLogger: () => ({ |
| info: vi.fn(), |
| warn: vi.fn(), |
| error: vi.fn(), |
| debug: vi.fn(), |
| }), |
| })); |
|
|
| |
| const mockFetch = vi.fn() as Mock; |
| vi.stubGlobal('fetch', mockFetch); |
|
|
| |
| const storage = new Map<string, string>(); |
| const localStorageStub = { |
| getItem: (key: string) => storage.get(key) ?? null, |
| setItem: (key: string, value: string) => storage.set(key, value), |
| removeItem: (key: string) => storage.delete(key), |
| }; |
| vi.stubGlobal('localStorage', localStorageStub); |
| vi.stubGlobal('window', { localStorage: localStorageStub }); |
|
|
| |
| |
| |
|
|
| |
| interface MockServerResponse { |
| providers?: Record<string, { models?: string[]; baseUrl?: string }>; |
| tts?: Record<string, { baseUrl?: string }>; |
| asr?: Record<string, { baseUrl?: string }>; |
| pdf?: Record<string, { baseUrl?: string }>; |
| image?: Record<string, { baseUrl?: string }>; |
| video?: Record<string, { baseUrl?: string }>; |
| webSearch?: Record<string, { baseUrl?: string }>; |
| } |
|
|
| function mockServerResponse(overrides: MockServerResponse = {}) { |
| mockFetch.mockResolvedValueOnce({ |
| ok: true, |
| json: async () => ({ |
| providers: {}, |
| tts: {}, |
| asr: {}, |
| pdf: {}, |
| image: {}, |
| video: {}, |
| webSearch: {}, |
| ...overrides, |
| }), |
| }); |
| } |
|
|
| |
| |
| |
|
|
| describe('settings rehydrate — built-in provider models', () => { |
| beforeEach(() => { |
| vi.resetModules(); |
| storage.clear(); |
| mockFetch.mockReset(); |
| }); |
|
|
| async function getStore() { |
| const { useSettingsStore } = await import('@/lib/store/settings'); |
| return useSettingsStore; |
| } |
|
|
| it('reorders persisted built-in models to registry order while preserving custom models', async () => { |
| storage.set( |
| 'settings-storage', |
| JSON.stringify({ |
| state: { |
| providerId: 'openai', |
| modelId: 'gpt-4o-mini', |
| providersConfig: { |
| openai: { |
| apiKey: '', |
| baseUrl: '', |
| models: [ |
| { id: 'custom-earlier', name: 'Custom Earlier' }, |
| { id: 'gpt-4-turbo', name: 'Old GPT-4 Turbo' }, |
| { id: 'gpt-4o-mini', name: 'Old GPT-4o Mini' }, |
| { id: 'custom-later', name: 'Custom Later' }, |
| { id: 'gpt-4o', name: 'Old GPT-4o' }, |
| ], |
| name: 'OpenAI', |
| type: 'openai', |
| defaultBaseUrl: 'https://api.openai.com/v1', |
| icon: '/logos/openai.svg', |
| requiresApiKey: true, |
| isBuiltIn: true, |
| }, |
| }, |
| }, |
| version: 2, |
| }), |
| ); |
|
|
| const store = await getStore(); |
| const models = store.getState().providersConfig.openai.models; |
|
|
| expect(models.map((m) => m.id)).toEqual([ |
| 'gpt-4o', |
| 'gpt-4o-mini', |
| 'gpt-4-turbo', |
| 'custom-earlier', |
| 'custom-later', |
| ]); |
| expect(models[0].name).toBe('GPT-4o'); |
| expect(models[3].name).toBe('Custom Earlier'); |
| }); |
| }); |
|
|
| describe('fetchServerProviders — provider availability sync', () => { |
| beforeEach(() => { |
| vi.resetModules(); |
| storage.clear(); |
| mockFetch.mockReset(); |
| }); |
|
|
| async function getStore() { |
| const { useSettingsStore } = await import('@/lib/store/settings'); |
| return useSettingsStore; |
| } |
|
|
| |
|
|
| it('filters models to only those the server allows', async () => { |
| const store = await getStore(); |
| mockServerResponse({ |
| providers: { |
| openai: { models: ['gpt-4o'] }, |
| }, |
| }); |
|
|
| await store.getState().fetchServerProviders(); |
|
|
| const config = store.getState().providersConfig.openai; |
| const modelIds = config.models.map((m) => m.id); |
| expect(modelIds).toEqual(['gpt-4o']); |
| expect(modelIds).not.toContain('gpt-4o-mini'); |
| expect(modelIds).not.toContain('gpt-4-turbo'); |
| }); |
|
|
| it('preserves custom server model IDs in server order', async () => { |
| const store = await getStore(); |
| mockServerResponse({ |
| providers: { |
| openai: { models: ['gpt-5.5', 'gpt-4o'] }, |
| }, |
| }); |
|
|
| await store.getState().fetchServerProviders(); |
|
|
| const models = store.getState().providersConfig.openai.models; |
| expect(models.map((m) => m.id)).toEqual(['gpt-5.5', 'gpt-4o']); |
| expect(models[0].name).toBe('gpt-5.5'); |
| expect(models[1].name).toBe('GPT-4o'); |
| }); |
|
|
| it('keeps all models when server provides no model restriction', async () => { |
| const store = await getStore(); |
| mockServerResponse({ |
| providers: { |
| openai: {}, |
| }, |
| }); |
|
|
| await store.getState().fetchServerProviders(); |
|
|
| const modelIds = store.getState().providersConfig.openai.models.map((m) => m.id); |
| expect(modelIds).toContain('gpt-4o'); |
| expect(modelIds).toContain('gpt-4o-mini'); |
| expect(modelIds).toContain('gpt-4-turbo'); |
| }); |
|
|
| it('removes a model when server drops it from the allowed list', async () => { |
| const store = await getStore(); |
|
|
| |
| mockServerResponse({ |
| providers: { |
| openai: { models: ['gpt-4o', 'gpt-4o-mini'] }, |
| }, |
| }); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().providersConfig.openai.models.map((m) => m.id)).toEqual([ |
| 'gpt-4o', |
| 'gpt-4o-mini', |
| ]); |
|
|
| |
| mockServerResponse({ |
| providers: { |
| openai: { models: ['gpt-4o'] }, |
| }, |
| }); |
| await store.getState().fetchServerProviders(); |
| const modelIds = store.getState().providersConfig.openai.models.map((m) => m.id); |
| expect(modelIds).toEqual(['gpt-4o']); |
| expect(modelIds).not.toContain('gpt-4o-mini'); |
| }); |
|
|
| |
|
|
| it('marks provider as server-configured when present in response', async () => { |
| const store = await getStore(); |
| mockServerResponse({ |
| providers: { |
| openai: { models: ['gpt-4o'] }, |
| }, |
| }); |
|
|
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true); |
| }); |
|
|
| it('resets isServerConfigured when provider disappears from response', async () => { |
| const store = await getStore(); |
|
|
| |
| mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true); |
|
|
| |
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().providersConfig.openai.isServerConfigured).toBe(false); |
| }); |
|
|
| it('provider without client key and not server-configured has no usable path', async () => { |
| const store = await getStore(); |
| mockServerResponse({}); |
|
|
| await store.getState().fetchServerProviders(); |
|
|
| const config = store.getState().providersConfig.openai; |
| |
| expect(config.apiKey).toBe(''); |
| expect(config.isServerConfigured).toBe(false); |
| |
| const isUsable = isProviderUsable(config); |
| expect(isUsable).toBe(false); |
| }); |
|
|
| |
|
|
| it('handles mixed provider state: one configured, one not', async () => { |
| const store = await getStore(); |
| mockServerResponse({ |
| providers: { |
| openai: { models: ['gpt-4o'] }, |
| |
| }, |
| }); |
|
|
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true); |
| expect(store.getState().providersConfig.anthropic.isServerConfigured).toBe(false); |
| }); |
|
|
| |
|
|
| it('stores serverModels metadata for downstream filtering', async () => { |
| const store = await getStore(); |
| mockServerResponse({ |
| providers: { |
| openai: { models: ['gpt-4o', 'gpt-4o-mini'] }, |
| }, |
| }); |
|
|
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().providersConfig.openai.serverModels).toEqual(['gpt-4o', 'gpt-4o-mini']); |
| }); |
|
|
| it('clears serverModels when provider removed from server', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().providersConfig.openai.serverModels).toEqual(['gpt-4o']); |
|
|
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().providersConfig.openai.serverModels).toBeUndefined(); |
| }); |
|
|
| |
|
|
| |
| |
| |
|
|
| it('clears modelId when server removes the selected model', async () => { |
| const store = await getStore(); |
|
|
| |
| store.getState().setModel('openai', 'gpt-4o-mini'); |
| expect(store.getState().modelId).toBe('gpt-4o-mini'); |
|
|
| |
| mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); |
| await store.getState().fetchServerProviders(); |
|
|
| |
| expect(store.getState().modelId).toBe('gpt-4o'); |
| }); |
|
|
| it('clears providerId when entire provider loses server config and has no client key', async () => { |
| const store = await getStore(); |
|
|
| |
| store.getState().setModel('openai', 'gpt-4o'); |
| mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true); |
|
|
| |
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| |
| expect(store.getState().providerId).toBe(''); |
| expect(store.getState().modelId).toBe(''); |
| }); |
|
|
| it('clears modelId when server narrows model list and selected model is excluded', async () => { |
| const store = await getStore(); |
|
|
| |
| mockServerResponse({ |
| providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'] } }, |
| }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setModel('openai', 'gpt-4-turbo'); |
|
|
| |
| mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); |
| await store.getState().fetchServerProviders(); |
|
|
| |
| expect(store.getState().modelId).toBe('gpt-4o'); |
| }); |
|
|
| it('keeps modelId when selected model is still available after server sync', async () => { |
| const store = await getStore(); |
|
|
| store.getState().setModel('openai', 'gpt-4o'); |
| mockServerResponse({ providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini'] } } }); |
| await store.getState().fetchServerProviders(); |
|
|
| |
| expect(store.getState().providerId).toBe('openai'); |
| expect(store.getState().modelId).toBe('gpt-4o'); |
| }); |
|
|
| |
|
|
| it('does not modify state when fetch returns non-ok response', async () => { |
| const store = await getStore(); |
|
|
| |
| mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true); |
|
|
| |
| mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); |
| await store.getState().fetchServerProviders(); |
|
|
| |
| expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true); |
| }); |
|
|
| it('does not throw when fetch rejects (network error)', async () => { |
| const store = await getStore(); |
|
|
| mockFetch.mockRejectedValueOnce(new Error('Network error')); |
|
|
| |
| await expect(store.getState().fetchServerProviders()).resolves.not.toThrow(); |
| }); |
| }); |
|
|
| describe('fetchServerProviders — TTS stale selection', () => { |
| beforeEach(() => { |
| vi.resetModules(); |
| storage.clear(); |
| mockFetch.mockReset(); |
| }); |
|
|
| async function getStore() { |
| const { useSettingsStore } = await import('@/lib/store/settings'); |
| return useSettingsStore; |
| } |
|
|
| it('falls back to browser-native-tts when selected TTS provider loses server config', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ tts: { 'openai-tts': {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setTTSProvider('openai-tts'); |
| expect(store.getState().ttsProviderId).toBe('openai-tts'); |
|
|
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().ttsProviderId).toBe('browser-native-tts'); |
| }); |
|
|
| it('falls back to remaining server TTS provider when selected one is removed', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ tts: { 'openai-tts': {}, 'azure-tts': {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setTTSProvider('openai-tts'); |
|
|
| mockServerResponse({ tts: { 'azure-tts': {} } }); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().ttsProviderId).toBe('azure-tts'); |
| }); |
|
|
| it('keeps TTS provider when it is still server-configured', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ tts: { 'openai-tts': {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setTTSProvider('openai-tts'); |
|
|
| mockServerResponse({ tts: { 'openai-tts': {} } }); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().ttsProviderId).toBe('openai-tts'); |
| }); |
| }); |
|
|
| describe('fetchServerProviders — ASR stale selection', () => { |
| beforeEach(() => { |
| vi.resetModules(); |
| storage.clear(); |
| mockFetch.mockReset(); |
| }); |
|
|
| async function getStore() { |
| const { useSettingsStore } = await import('@/lib/store/settings'); |
| return useSettingsStore; |
| } |
|
|
| it('falls back to browser-native when selected ASR provider loses server config', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ asr: { 'openai-whisper': {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setASRProvider('openai-whisper'); |
| expect(store.getState().asrProviderId).toBe('openai-whisper'); |
|
|
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().asrProviderId).toBe('browser-native'); |
| }); |
|
|
| it('keeps ASR provider when it is still server-configured', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ asr: { 'openai-whisper': {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setASRProvider('openai-whisper'); |
|
|
| mockServerResponse({ asr: { 'openai-whisper': {} } }); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().asrProviderId).toBe('openai-whisper'); |
| }); |
| }); |
|
|
| describe('fetchServerProviders — PDF stale selection', () => { |
| beforeEach(() => { |
| vi.resetModules(); |
| storage.clear(); |
| mockFetch.mockReset(); |
| }); |
|
|
| async function getStore() { |
| const { useSettingsStore } = await import('@/lib/store/settings'); |
| return useSettingsStore; |
| } |
|
|
| it('falls back to unpdf when mineru loses server config', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ pdf: { mineru: {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setPDFProvider('mineru'); |
|
|
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().pdfProviderId).toBe('unpdf'); |
| }); |
| }); |
|
|
| describe('fetchServerProviders — Image stale selection', () => { |
| beforeEach(() => { |
| vi.resetModules(); |
| storage.clear(); |
| mockFetch.mockReset(); |
| }); |
|
|
| async function getStore() { |
| const { useSettingsStore } = await import('@/lib/store/settings'); |
| return useSettingsStore; |
| } |
|
|
| it('clears imageProviderId and imageModelId when provider loses server config', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ image: { seedream: {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setImageProvider('seedream'); |
| store.getState().setImageModelId('doubao-seedream-5-0-260128'); |
|
|
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().imageProviderId).toBe(''); |
| expect(store.getState().imageModelId).toBe(''); |
| }); |
|
|
| it('disables imageGenerationEnabled when no image provider is usable', async () => { |
| const store = await getStore(); |
|
|
| |
| mockServerResponse({ image: { seedream: {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setImageProvider('seedream'); |
| store.getState().setImageGenerationEnabled(true); |
| expect(store.getState().imageGenerationEnabled).toBe(true); |
|
|
| |
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().imageGenerationEnabled).toBe(false); |
| }); |
|
|
| it('prevents enabling image generation when no image provider is usable', async () => { |
| const store = await getStore(); |
|
|
| |
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| |
| store.getState().setImageGenerationEnabled(true); |
| expect(store.getState().imageGenerationEnabled).toBe(false); |
| }); |
|
|
| it('preserves user-disabled image generation across server syncs', async () => { |
| const store = await getStore(); |
|
|
| |
| mockServerResponse({ image: { seedream: {} } }); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().imageGenerationEnabled).toBe(true); |
|
|
| |
| store.getState().setImageGenerationEnabled(false); |
| expect(store.getState().imageGenerationEnabled).toBe(false); |
|
|
| |
| mockServerResponse({ image: { seedream: {} } }); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().imageGenerationEnabled).toBe(false); |
| }); |
|
|
| it('falls back to another server-configured image provider', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ image: { seedream: {}, 'qwen-image': {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setImageProvider('seedream'); |
| store.getState().setImageModelId('doubao-seedream-5-0-260128'); |
|
|
| mockServerResponse({ image: { 'qwen-image': {} } }); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().imageProviderId).toBe('qwen-image'); |
| expect(store.getState().imageModelId).toBe('qwen-image-max'); |
| }); |
|
|
| it('auto-selects provider and model when server adds image provider after empty state', async () => { |
| const store = await getStore(); |
|
|
| |
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().imageProviderId).toBe(''); |
| expect(store.getState().imageModelId).toBe(''); |
| expect(store.getState().imageGenerationEnabled).toBe(false); |
|
|
| |
| mockServerResponse({ image: { seedream: {} } }); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().imageProviderId).toBe('seedream'); |
| expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128'); |
| |
| expect(store.getState().imageGenerationEnabled).toBe(false); |
| }); |
|
|
| it('auto-enables image generation on first load when server has image provider', async () => { |
| const store = await getStore(); |
|
|
| |
| |
| mockServerResponse({ image: { seedream: {} } }); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().imageGenerationEnabled).toBe(true); |
| expect(store.getState().imageProviderId).toBe('seedream'); |
| expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128'); |
| }); |
|
|
| it('does not force-enable when provider is already set but generation was disabled', async () => { |
| const store = await getStore(); |
|
|
| |
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| store.setState({ |
| imageProviderId: 'seedream', |
| imageModelId: '', |
| imageGenerationEnabled: false, |
| }); |
|
|
| |
| mockServerResponse({ image: { seedream: {} } }); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().imageGenerationEnabled).toBe(false); |
| |
| expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128'); |
| }); |
| }); |
|
|
| describe('fetchServerProviders — Video stale selection', () => { |
| beforeEach(() => { |
| vi.resetModules(); |
| storage.clear(); |
| mockFetch.mockReset(); |
| }); |
|
|
| async function getStore() { |
| const { useSettingsStore } = await import('@/lib/store/settings'); |
| return useSettingsStore; |
| } |
|
|
| it('clears videoProviderId and videoModelId when provider loses server config', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ video: { seedance: {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setVideoProvider('seedance'); |
| store.getState().setVideoModelId('doubao-seedance-1-5-pro-251215'); |
|
|
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().videoProviderId).toBe(''); |
| expect(store.getState().videoModelId).toBe(''); |
| }); |
|
|
| it('disables videoGenerationEnabled when no video provider is usable', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ video: { seedance: {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setVideoProvider('seedance'); |
| store.getState().setVideoGenerationEnabled(true); |
| expect(store.getState().videoGenerationEnabled).toBe(true); |
|
|
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().videoGenerationEnabled).toBe(false); |
| }); |
|
|
| it('prevents enabling video generation when no video provider is usable', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
|
|
| store.getState().setVideoGenerationEnabled(true); |
| expect(store.getState().videoGenerationEnabled).toBe(false); |
| }); |
|
|
| it('falls back to another server-configured video provider', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ video: { seedance: {}, kling: {} } }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setVideoProvider('seedance'); |
| store.getState().setVideoModelId('doubao-seedance-1-5-pro-251215'); |
|
|
| mockServerResponse({ video: { kling: {} } }); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().videoProviderId).toBe('kling'); |
| expect(store.getState().videoModelId).toBe('kling-v2-6'); |
| }); |
|
|
| it('auto-selects provider and model when server adds video provider after empty state', async () => { |
| const store = await getStore(); |
|
|
| |
| mockServerResponse({}); |
| await store.getState().fetchServerProviders(); |
| expect(store.getState().videoProviderId).toBe(''); |
| expect(store.getState().videoModelId).toBe(''); |
| expect(store.getState().videoGenerationEnabled).toBe(false); |
|
|
| |
| mockServerResponse({ video: { seedance: {} } }); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().videoProviderId).toBe('seedance'); |
| expect(store.getState().videoModelId).toBe('doubao-seedance-1-5-pro-251215'); |
| |
| expect(store.getState().videoGenerationEnabled).toBe(false); |
| }); |
| }); |
|
|
| describe('fetchServerProviders — LLM cross-provider fallback', () => { |
| beforeEach(() => { |
| vi.resetModules(); |
| storage.clear(); |
| mockFetch.mockReset(); |
| }); |
|
|
| async function getStore() { |
| const { useSettingsStore } = await import('@/lib/store/settings'); |
| return useSettingsStore; |
| } |
|
|
| it('falls back to another server-configured LLM provider when current becomes unusable', async () => { |
| const store = await getStore(); |
|
|
| mockServerResponse({ |
| providers: { |
| openai: { models: ['gpt-4o'] }, |
| anthropic: { models: ['claude-sonnet-4-6'] }, |
| }, |
| }); |
| await store.getState().fetchServerProviders(); |
| store.getState().setModel('openai', 'gpt-4o'); |
|
|
| mockServerResponse({ |
| providers: { |
| anthropic: { models: ['claude-sonnet-4-6'] }, |
| }, |
| }); |
| await store.getState().fetchServerProviders(); |
|
|
| expect(store.getState().providerId).toBe('anthropic'); |
| expect(store.getState().modelId).toBe('claude-sonnet-4-6'); |
| }); |
| }); |
|
|
| describe('settings merge migration — custom provider baseUrl', () => { |
| beforeEach(() => { |
| vi.resetModules(); |
| storage.clear(); |
| mockFetch.mockReset(); |
| }); |
|
|
| it('promotes defaultBaseUrl into baseUrl for legacy custom providers', async () => { |
| const { promoteLegacyCustomProviderBaseUrls } = await import('@/lib/store/settings'); |
| const state = { |
| providersConfig: { |
| 'custom-123': { |
| apiKey: '', |
| baseUrl: '', |
| models: [{ id: 'test-model', name: 'Test Model' }], |
| name: 'Legacy Custom', |
| type: 'openai', |
| defaultBaseUrl: 'https://example.com/v1', |
| requiresApiKey: true, |
| isBuiltIn: false, |
| }, |
| }, |
| }; |
|
|
| |
| promoteLegacyCustomProviderBaseUrls(state as any); |
|
|
| expect(state.providersConfig['custom-123'].baseUrl).toBe('https://example.com/v1'); |
| expect(state.providersConfig['custom-123'].defaultBaseUrl).toBe('https://example.com/v1'); |
| }); |
|
|
| it('does not promote defaultBaseUrl for built-in providers', async () => { |
| const { promoteLegacyCustomProviderBaseUrls } = await import('@/lib/store/settings'); |
| const state = { |
| providersConfig: { |
| openai: { |
| apiKey: '', |
| baseUrl: '', |
| models: [{ id: 'gpt-4o', name: 'GPT-4o' }], |
| name: 'OpenAI', |
| type: 'openai', |
| defaultBaseUrl: 'https://persisted-openai.example/v1', |
| requiresApiKey: true, |
| isBuiltIn: true, |
| }, |
| }, |
| }; |
|
|
| |
| promoteLegacyCustomProviderBaseUrls(state as any); |
|
|
| expect(state.providersConfig.openai.baseUrl).toBe(''); |
| expect(state.providersConfig.openai.defaultBaseUrl).toBe('https://persisted-openai.example/v1'); |
| }); |
| }); |
|
|