settings-server-sync.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843
  1. /**
  2. * Tests for fetchServerProviders() — verifying that the settings store
  3. * correctly reflects server-side provider availability changes.
  4. *
  5. * Core invariant: after server sync, the set of models/providers a user
  6. * can select in the UI must match what the server currently supports.
  7. */
  8. import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
  9. // ---------------------------------------------------------------------------
  10. // Mocks — must be defined before importing the store
  11. // ---------------------------------------------------------------------------
  12. // Minimal built-in provider registry used by the store
  13. vi.mock('@/lib/ai/providers', () => ({
  14. PROVIDERS: {
  15. openai: {
  16. id: 'openai',
  17. name: 'OpenAI',
  18. type: 'openai',
  19. defaultBaseUrl: 'https://api.openai.com/v1',
  20. requiresApiKey: true,
  21. icon: '/logos/openai.svg',
  22. models: [
  23. { id: 'gpt-4o', name: 'GPT-4o' },
  24. { id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
  25. { id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
  26. ],
  27. },
  28. anthropic: {
  29. id: 'anthropic',
  30. name: 'Anthropic',
  31. type: 'anthropic',
  32. defaultBaseUrl: 'https://api.anthropic.com',
  33. requiresApiKey: true,
  34. icon: '/logos/anthropic.svg',
  35. models: [
  36. { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
  37. { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },
  38. ],
  39. },
  40. },
  41. }));
  42. vi.mock('@/lib/audio/constants', () => ({
  43. TTS_PROVIDERS: {
  44. 'openai-tts': {
  45. id: 'openai-tts',
  46. name: 'OpenAI TTS',
  47. requiresApiKey: true,
  48. defaultModelId: 'gpt-4o-mini-tts',
  49. models: [{ id: 'gpt-4o-mini-tts', name: 'GPT-4o Mini TTS' }],
  50. voices: [{ id: 'alloy', name: 'Alloy', language: 'en', gender: 'neutral' }],
  51. supportedFormats: ['mp3'],
  52. },
  53. 'azure-tts': {
  54. id: 'azure-tts',
  55. name: 'Azure TTS',
  56. requiresApiKey: true,
  57. defaultModelId: '',
  58. models: [],
  59. voices: [{ id: 'zh-CN-XiaoxiaoNeural', name: 'Xiaoxiao', language: 'zh-CN' }],
  60. supportedFormats: ['mp3'],
  61. },
  62. 'browser-native-tts': {
  63. id: 'browser-native-tts',
  64. name: 'Browser Native TTS',
  65. requiresApiKey: false,
  66. defaultModelId: '',
  67. models: [],
  68. voices: [{ id: 'default', name: 'Default', language: 'en', gender: 'neutral' }],
  69. supportedFormats: ['browser'],
  70. speedRange: { min: 0.1, max: 10, default: 1 },
  71. },
  72. },
  73. ASR_PROVIDERS: {
  74. 'openai-whisper': {
  75. id: 'openai-whisper',
  76. name: 'OpenAI Whisper',
  77. requiresApiKey: true,
  78. defaultModelId: 'gpt-4o-mini-transcribe',
  79. models: [{ id: 'gpt-4o-mini-transcribe', name: 'GPT-4o Mini Transcribe' }],
  80. supportedLanguages: ['auto', 'zh'],
  81. supportedFormats: ['webm'],
  82. },
  83. 'browser-native': {
  84. id: 'browser-native',
  85. name: 'Browser Native ASR',
  86. requiresApiKey: false,
  87. defaultModelId: '',
  88. models: [],
  89. supportedLanguages: ['zh'],
  90. supportedFormats: ['browser'],
  91. },
  92. },
  93. DEFAULT_TTS_VOICES: {
  94. 'openai-tts': 'alloy',
  95. 'browser-native-tts': 'default',
  96. },
  97. }));
  98. vi.mock('@/lib/audio/types', () => ({}));
  99. vi.mock('@/lib/pdf/constants', () => ({
  100. PDF_PROVIDERS: {
  101. unpdf: { id: 'unpdf', requiresApiKey: false },
  102. mineru: { id: 'mineru', requiresApiKey: false },
  103. },
  104. }));
  105. vi.mock('@/lib/media/image-providers', () => ({
  106. IMAGE_PROVIDERS: {
  107. seedream: {
  108. id: 'seedream',
  109. requiresApiKey: true,
  110. models: [{ id: 'doubao-seedream-5-0-260128', name: 'Seedream 5.0' }],
  111. },
  112. 'qwen-image': {
  113. id: 'qwen-image',
  114. requiresApiKey: true,
  115. models: [{ id: 'qwen-image-max', name: 'Qwen Image Max' }],
  116. },
  117. },
  118. }));
  119. vi.mock('@/lib/media/video-providers', () => ({
  120. VIDEO_PROVIDERS: {
  121. seedance: {
  122. id: 'seedance',
  123. requiresApiKey: true,
  124. models: [{ id: 'doubao-seedance-1-5-pro-251215', name: 'Seedance 1.5 Pro' }],
  125. },
  126. kling: {
  127. id: 'kling',
  128. requiresApiKey: true,
  129. models: [{ id: 'kling-v2-6', name: 'Kling V2' }],
  130. },
  131. },
  132. }));
  133. vi.mock('@/lib/logger', () => ({
  134. createLogger: () => ({
  135. info: vi.fn(),
  136. warn: vi.fn(),
  137. error: vi.fn(),
  138. debug: vi.fn(),
  139. }),
  140. }));
  141. // Stub global fetch
  142. const mockFetch = vi.fn() as Mock;
  143. vi.stubGlobal('fetch', mockFetch);
  144. // Stub localStorage
  145. const storage = new Map<string, string>();
  146. vi.stubGlobal('localStorage', {
  147. getItem: (key: string) => storage.get(key) ?? null,
  148. setItem: (key: string, value: string) => storage.set(key, value),
  149. removeItem: (key: string) => storage.delete(key),
  150. });
  151. // ---------------------------------------------------------------------------
  152. // Helpers
  153. // ---------------------------------------------------------------------------
  154. /** Full server response shape */
  155. interface MockServerResponse {
  156. providers?: Record<string, { models?: string[]; baseUrl?: string }>;
  157. tts?: Record<string, { baseUrl?: string }>;
  158. asr?: Record<string, { baseUrl?: string }>;
  159. pdf?: Record<string, { baseUrl?: string }>;
  160. image?: Record<string, Record<string, never>>;
  161. video?: Record<string, Record<string, never>>;
  162. webSearch?: Record<string, { baseUrl?: string }>;
  163. }
  164. function mockServerResponse(overrides: MockServerResponse = {}) {
  165. mockFetch.mockResolvedValueOnce({
  166. ok: true,
  167. json: async () => ({
  168. providers: {},
  169. tts: {},
  170. asr: {},
  171. pdf: {},
  172. image: {},
  173. video: {},
  174. webSearch: {},
  175. ...overrides,
  176. }),
  177. });
  178. }
  179. // ---------------------------------------------------------------------------
  180. // Tests
  181. // ---------------------------------------------------------------------------
  182. describe('fetchServerProviders — provider availability sync', () => {
  183. beforeEach(() => {
  184. vi.resetModules();
  185. storage.clear();
  186. mockFetch.mockReset();
  187. });
  188. async function getStore() {
  189. const { useSettingsStore } = await import('@/lib/store/settings');
  190. return useSettingsStore;
  191. }
  192. // ---- Server model list filtering ----
  193. it('filters models to only those the server allows', async () => {
  194. const store = await getStore();
  195. mockServerResponse({
  196. providers: {
  197. openai: { models: ['gpt-4o'] },
  198. },
  199. });
  200. await store.getState().fetchServerProviders();
  201. const config = store.getState().providersConfig.openai;
  202. const modelIds = config.models.map((m) => m.id);
  203. expect(modelIds).toEqual(['gpt-4o']);
  204. expect(modelIds).not.toContain('gpt-4o-mini');
  205. expect(modelIds).not.toContain('gpt-4-turbo');
  206. });
  207. it('keeps all models when server provides no model restriction', async () => {
  208. const store = await getStore();
  209. mockServerResponse({
  210. providers: {
  211. openai: {}, // no models field = no restriction
  212. },
  213. });
  214. await store.getState().fetchServerProviders();
  215. const modelIds = store.getState().providersConfig.openai.models.map((m) => m.id);
  216. expect(modelIds).toContain('gpt-4o');
  217. expect(modelIds).toContain('gpt-4o-mini');
  218. expect(modelIds).toContain('gpt-4-turbo');
  219. });
  220. it('removes a model when server drops it from the allowed list', async () => {
  221. const store = await getStore();
  222. // Round 1: server allows two models
  223. mockServerResponse({
  224. providers: {
  225. openai: { models: ['gpt-4o', 'gpt-4o-mini'] },
  226. },
  227. });
  228. await store.getState().fetchServerProviders();
  229. expect(store.getState().providersConfig.openai.models.map((m) => m.id)).toEqual([
  230. 'gpt-4o',
  231. 'gpt-4o-mini',
  232. ]);
  233. // Round 2: server removes gpt-4o-mini
  234. mockServerResponse({
  235. providers: {
  236. openai: { models: ['gpt-4o'] },
  237. },
  238. });
  239. await store.getState().fetchServerProviders();
  240. const modelIds = store.getState().providersConfig.openai.models.map((m) => m.id);
  241. expect(modelIds).toEqual(['gpt-4o']);
  242. expect(modelIds).not.toContain('gpt-4o-mini');
  243. });
  244. // ---- Provider availability flags ----
  245. it('marks provider as server-configured when present in response', async () => {
  246. const store = await getStore();
  247. mockServerResponse({
  248. providers: {
  249. openai: { models: ['gpt-4o'] },
  250. },
  251. });
  252. await store.getState().fetchServerProviders();
  253. expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);
  254. });
  255. it('resets isServerConfigured when provider disappears from response', async () => {
  256. const store = await getStore();
  257. // Round 1: openai is server-configured
  258. mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });
  259. await store.getState().fetchServerProviders();
  260. expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);
  261. // Round 2: openai is no longer in server response
  262. mockServerResponse({});
  263. await store.getState().fetchServerProviders();
  264. expect(store.getState().providersConfig.openai.isServerConfigured).toBe(false);
  265. });
  266. it('provider without client key and not server-configured has no usable path', async () => {
  267. const store = await getStore();
  268. mockServerResponse({}); // no server providers
  269. await store.getState().fetchServerProviders();
  270. const config = store.getState().providersConfig.openai;
  271. // No client key, not server-configured → provider should not be "ready"
  272. expect(config.apiKey).toBe('');
  273. expect(config.isServerConfigured).toBe(false);
  274. // This is the condition model-selector uses to decide if a provider is usable:
  275. const isUsable = !config.requiresApiKey || !!config.apiKey || !!config.isServerConfigured;
  276. expect(isUsable).toBe(false);
  277. });
  278. // ---- Multiple providers ----
  279. it('handles mixed provider state: one configured, one not', async () => {
  280. const store = await getStore();
  281. mockServerResponse({
  282. providers: {
  283. openai: { models: ['gpt-4o'] },
  284. // anthropic not in response
  285. },
  286. });
  287. await store.getState().fetchServerProviders();
  288. expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);
  289. expect(store.getState().providersConfig.anthropic.isServerConfigured).toBe(false);
  290. });
  291. // ---- serverModels metadata ----
  292. it('stores serverModels metadata for downstream filtering', async () => {
  293. const store = await getStore();
  294. mockServerResponse({
  295. providers: {
  296. openai: { models: ['gpt-4o', 'gpt-4o-mini'] },
  297. },
  298. });
  299. await store.getState().fetchServerProviders();
  300. expect(store.getState().providersConfig.openai.serverModels).toEqual(['gpt-4o', 'gpt-4o-mini']);
  301. });
  302. it('clears serverModels when provider removed from server', async () => {
  303. const store = await getStore();
  304. mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });
  305. await store.getState().fetchServerProviders();
  306. expect(store.getState().providersConfig.openai.serverModels).toEqual(['gpt-4o']);
  307. mockServerResponse({});
  308. await store.getState().fetchServerProviders();
  309. expect(store.getState().providersConfig.openai.serverModels).toBeUndefined();
  310. });
  311. // ---- Stale selection consistency ----
  312. // BUG: fetchServerProviders() updates providersConfig.models but never
  313. // validates the current modelId/providerId selection against the new list.
  314. // These tests document the desired fix — remove .fails() once implemented.
  315. it('clears modelId when server removes the selected model', async () => {
  316. const store = await getStore();
  317. // User selects gpt-4o-mini while it's available
  318. store.getState().setModel('openai', 'gpt-4o-mini');
  319. expect(store.getState().modelId).toBe('gpt-4o-mini');
  320. // Server drops gpt-4o-mini
  321. mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });
  322. await store.getState().fetchServerProviders();
  323. // modelId should be cleared, not silently kept as a stale value
  324. expect(store.getState().modelId).toBe('gpt-4o');
  325. });
  326. it('clears providerId when entire provider loses server config and has no client key', async () => {
  327. const store = await getStore();
  328. // User on a server-only provider (no client key)
  329. store.getState().setModel('openai', 'gpt-4o');
  330. mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });
  331. await store.getState().fetchServerProviders();
  332. expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);
  333. // Server removes openai entirely — no client key either
  334. mockServerResponse({});
  335. await store.getState().fetchServerProviders();
  336. // Provider is unusable → selection should be cleared
  337. expect(store.getState().providerId).toBe('');
  338. expect(store.getState().modelId).toBe('');
  339. });
  340. it('clears modelId when server narrows model list and selected model is excluded', async () => {
  341. const store = await getStore();
  342. // Round 1: user picks gpt-4-turbo
  343. mockServerResponse({
  344. providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'] } },
  345. });
  346. await store.getState().fetchServerProviders();
  347. store.getState().setModel('openai', 'gpt-4-turbo');
  348. // Round 2: server narrows to gpt-4o only
  349. mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });
  350. await store.getState().fetchServerProviders();
  351. // Selection should be cleared, not left pointing to unavailable model
  352. expect(store.getState().modelId).toBe('gpt-4o');
  353. });
  354. it('keeps modelId when selected model is still available after server sync', async () => {
  355. const store = await getStore();
  356. store.getState().setModel('openai', 'gpt-4o');
  357. mockServerResponse({ providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini'] } } });
  358. await store.getState().fetchServerProviders();
  359. // gpt-4o is still available — selection should be preserved
  360. expect(store.getState().providerId).toBe('openai');
  361. expect(store.getState().modelId).toBe('gpt-4o');
  362. });
  363. // ---- Error handling ----
  364. it('does not modify state when fetch returns non-ok response', async () => {
  365. const store = await getStore();
  366. // First, set up a known state
  367. mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });
  368. await store.getState().fetchServerProviders();
  369. expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);
  370. // Now fetch returns an error
  371. mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
  372. await store.getState().fetchServerProviders();
  373. // State should be unchanged — the failed fetch should not wipe existing config
  374. expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);
  375. });
  376. it('does not throw when fetch rejects (network error)', async () => {
  377. const store = await getStore();
  378. mockFetch.mockRejectedValueOnce(new Error('Network error'));
  379. // Should not throw — server providers are optional
  380. await expect(store.getState().fetchServerProviders()).resolves.not.toThrow();
  381. });
  382. });
  383. describe('fetchServerProviders — TTS stale selection', () => {
  384. beforeEach(() => {
  385. vi.resetModules();
  386. storage.clear();
  387. mockFetch.mockReset();
  388. });
  389. async function getStore() {
  390. const { useSettingsStore } = await import('@/lib/store/settings');
  391. return useSettingsStore;
  392. }
  393. it('falls back to browser-native-tts when selected TTS provider loses server config', async () => {
  394. const store = await getStore();
  395. mockServerResponse({ tts: { 'openai-tts': {} } });
  396. await store.getState().fetchServerProviders();
  397. store.getState().setTTSProvider('openai-tts');
  398. expect(store.getState().ttsProviderId).toBe('openai-tts');
  399. mockServerResponse({});
  400. await store.getState().fetchServerProviders();
  401. expect(store.getState().ttsProviderId).toBe('browser-native-tts');
  402. });
  403. it('falls back to remaining server TTS provider when selected one is removed', async () => {
  404. const store = await getStore();
  405. mockServerResponse({ tts: { 'openai-tts': {}, 'azure-tts': {} } });
  406. await store.getState().fetchServerProviders();
  407. store.getState().setTTSProvider('openai-tts');
  408. mockServerResponse({ tts: { 'azure-tts': {} } });
  409. await store.getState().fetchServerProviders();
  410. expect(store.getState().ttsProviderId).toBe('azure-tts');
  411. });
  412. it('keeps TTS provider when it is still server-configured', async () => {
  413. const store = await getStore();
  414. mockServerResponse({ tts: { 'openai-tts': {} } });
  415. await store.getState().fetchServerProviders();
  416. store.getState().setTTSProvider('openai-tts');
  417. mockServerResponse({ tts: { 'openai-tts': {} } });
  418. await store.getState().fetchServerProviders();
  419. expect(store.getState().ttsProviderId).toBe('openai-tts');
  420. });
  421. });
  422. describe('fetchServerProviders — ASR stale selection', () => {
  423. beforeEach(() => {
  424. vi.resetModules();
  425. storage.clear();
  426. mockFetch.mockReset();
  427. });
  428. async function getStore() {
  429. const { useSettingsStore } = await import('@/lib/store/settings');
  430. return useSettingsStore;
  431. }
  432. it('falls back to browser-native when selected ASR provider loses server config', async () => {
  433. const store = await getStore();
  434. mockServerResponse({ asr: { 'openai-whisper': {} } });
  435. await store.getState().fetchServerProviders();
  436. store.getState().setASRProvider('openai-whisper');
  437. expect(store.getState().asrProviderId).toBe('openai-whisper');
  438. mockServerResponse({});
  439. await store.getState().fetchServerProviders();
  440. expect(store.getState().asrProviderId).toBe('browser-native');
  441. });
  442. it('keeps ASR provider when it is still server-configured', async () => {
  443. const store = await getStore();
  444. mockServerResponse({ asr: { 'openai-whisper': {} } });
  445. await store.getState().fetchServerProviders();
  446. store.getState().setASRProvider('openai-whisper');
  447. mockServerResponse({ asr: { 'openai-whisper': {} } });
  448. await store.getState().fetchServerProviders();
  449. expect(store.getState().asrProviderId).toBe('openai-whisper');
  450. });
  451. });
  452. describe('fetchServerProviders — PDF stale selection', () => {
  453. beforeEach(() => {
  454. vi.resetModules();
  455. storage.clear();
  456. mockFetch.mockReset();
  457. });
  458. async function getStore() {
  459. const { useSettingsStore } = await import('@/lib/store/settings');
  460. return useSettingsStore;
  461. }
  462. it('falls back to unpdf when mineru loses server config', async () => {
  463. const store = await getStore();
  464. mockServerResponse({ pdf: { mineru: {} } });
  465. await store.getState().fetchServerProviders();
  466. store.getState().setPDFProvider('mineru');
  467. mockServerResponse({});
  468. await store.getState().fetchServerProviders();
  469. expect(store.getState().pdfProviderId).toBe('unpdf');
  470. });
  471. });
  472. describe('fetchServerProviders — Image stale selection', () => {
  473. beforeEach(() => {
  474. vi.resetModules();
  475. storage.clear();
  476. mockFetch.mockReset();
  477. });
  478. async function getStore() {
  479. const { useSettingsStore } = await import('@/lib/store/settings');
  480. return useSettingsStore;
  481. }
  482. it('clears imageProviderId and imageModelId when provider loses server config', async () => {
  483. const store = await getStore();
  484. mockServerResponse({ image: { seedream: {} } });
  485. await store.getState().fetchServerProviders();
  486. store.getState().setImageProvider('seedream');
  487. store.getState().setImageModelId('doubao-seedream-5-0-260128');
  488. mockServerResponse({});
  489. await store.getState().fetchServerProviders();
  490. expect(store.getState().imageProviderId).toBe('');
  491. expect(store.getState().imageModelId).toBe('');
  492. });
  493. it('disables imageGenerationEnabled when no image provider is usable', async () => {
  494. const store = await getStore();
  495. // Server configures seedream, user enables image generation
  496. mockServerResponse({ image: { seedream: {} } });
  497. await store.getState().fetchServerProviders();
  498. store.getState().setImageProvider('seedream');
  499. store.getState().setImageGenerationEnabled(true);
  500. expect(store.getState().imageGenerationEnabled).toBe(true);
  501. // Server removes all image providers
  502. mockServerResponse({});
  503. await store.getState().fetchServerProviders();
  504. expect(store.getState().imageGenerationEnabled).toBe(false);
  505. });
  506. it('prevents enabling image generation when no image provider is usable', async () => {
  507. const store = await getStore();
  508. // No server image providers
  509. mockServerResponse({});
  510. await store.getState().fetchServerProviders();
  511. // User tries to enable image generation
  512. store.getState().setImageGenerationEnabled(true);
  513. expect(store.getState().imageGenerationEnabled).toBe(false);
  514. });
  515. it('preserves user-disabled image generation across server syncs', async () => {
  516. const store = await getStore();
  517. // Server has seedream, auto-enabled on first sync
  518. mockServerResponse({ image: { seedream: {} } });
  519. await store.getState().fetchServerProviders();
  520. expect(store.getState().imageGenerationEnabled).toBe(true);
  521. // User intentionally disables
  522. store.getState().setImageGenerationEnabled(false);
  523. expect(store.getState().imageGenerationEnabled).toBe(false);
  524. // Next server sync — same config, should NOT re-enable
  525. mockServerResponse({ image: { seedream: {} } });
  526. await store.getState().fetchServerProviders();
  527. expect(store.getState().imageGenerationEnabled).toBe(false);
  528. });
  529. it('falls back to another server-configured image provider', async () => {
  530. const store = await getStore();
  531. mockServerResponse({ image: { seedream: {}, 'qwen-image': {} } });
  532. await store.getState().fetchServerProviders();
  533. store.getState().setImageProvider('seedream');
  534. store.getState().setImageModelId('doubao-seedream-5-0-260128');
  535. mockServerResponse({ image: { 'qwen-image': {} } });
  536. await store.getState().fetchServerProviders();
  537. expect(store.getState().imageProviderId).toBe('qwen-image');
  538. expect(store.getState().imageModelId).toBe('qwen-image-max');
  539. });
  540. it('auto-selects provider and model when server adds image provider after empty state', async () => {
  541. const store = await getStore();
  542. // Start with no image providers — selection is empty, generation disabled
  543. mockServerResponse({});
  544. await store.getState().fetchServerProviders();
  545. expect(store.getState().imageProviderId).toBe('');
  546. expect(store.getState().imageModelId).toBe('');
  547. expect(store.getState().imageGenerationEnabled).toBe(false);
  548. // Server adds seedream
  549. mockServerResponse({ image: { seedream: {} } });
  550. await store.getState().fetchServerProviders();
  551. expect(store.getState().imageProviderId).toBe('seedream');
  552. expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128');
  553. // Provider recovered but generation stays off — user enables manually
  554. expect(store.getState().imageGenerationEnabled).toBe(false);
  555. });
  556. it('auto-enables image generation on first load when server has image provider', async () => {
  557. const store = await getStore();
  558. // First ever fetchServerProviders — server has seedream
  559. // Default state: imageProviderId='seedream', imageGenerationEnabled=false, autoConfigApplied=false
  560. mockServerResponse({ image: { seedream: {} } });
  561. await store.getState().fetchServerProviders();
  562. expect(store.getState().imageGenerationEnabled).toBe(true);
  563. expect(store.getState().imageProviderId).toBe('seedream');
  564. expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128');
  565. });
  566. it('does not force-enable when provider is already set but generation was disabled', async () => {
  567. const store = await getStore();
  568. // autoConfigApplied=true, provider already set, generation off (user choice)
  569. mockServerResponse({});
  570. await store.getState().fetchServerProviders(); // sets autoConfigApplied=true
  571. store.setState({
  572. imageProviderId: 'seedream',
  573. imageModelId: '',
  574. imageGenerationEnabled: false,
  575. });
  576. // Server has seedream — should NOT force-enable (provider was already set)
  577. mockServerResponse({ image: { seedream: {} } });
  578. await store.getState().fetchServerProviders();
  579. expect(store.getState().imageGenerationEnabled).toBe(false);
  580. // But model should be auto-filled
  581. expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128');
  582. });
  583. });
  584. describe('fetchServerProviders — Video stale selection', () => {
  585. beforeEach(() => {
  586. vi.resetModules();
  587. storage.clear();
  588. mockFetch.mockReset();
  589. });
  590. async function getStore() {
  591. const { useSettingsStore } = await import('@/lib/store/settings');
  592. return useSettingsStore;
  593. }
  594. it('clears videoProviderId and videoModelId when provider loses server config', async () => {
  595. const store = await getStore();
  596. mockServerResponse({ video: { seedance: {} } });
  597. await store.getState().fetchServerProviders();
  598. store.getState().setVideoProvider('seedance');
  599. store.getState().setVideoModelId('doubao-seedance-1-5-pro-251215');
  600. mockServerResponse({});
  601. await store.getState().fetchServerProviders();
  602. expect(store.getState().videoProviderId).toBe('');
  603. expect(store.getState().videoModelId).toBe('');
  604. });
  605. it('disables videoGenerationEnabled when no video provider is usable', async () => {
  606. const store = await getStore();
  607. mockServerResponse({ video: { seedance: {} } });
  608. await store.getState().fetchServerProviders();
  609. store.getState().setVideoProvider('seedance');
  610. store.getState().setVideoGenerationEnabled(true);
  611. expect(store.getState().videoGenerationEnabled).toBe(true);
  612. mockServerResponse({});
  613. await store.getState().fetchServerProviders();
  614. expect(store.getState().videoGenerationEnabled).toBe(false);
  615. });
  616. it('prevents enabling video generation when no video provider is usable', async () => {
  617. const store = await getStore();
  618. mockServerResponse({});
  619. await store.getState().fetchServerProviders();
  620. store.getState().setVideoGenerationEnabled(true);
  621. expect(store.getState().videoGenerationEnabled).toBe(false);
  622. });
  623. it('falls back to another server-configured video provider', async () => {
  624. const store = await getStore();
  625. mockServerResponse({ video: { seedance: {}, kling: {} } });
  626. await store.getState().fetchServerProviders();
  627. store.getState().setVideoProvider('seedance');
  628. store.getState().setVideoModelId('doubao-seedance-1-5-pro-251215');
  629. mockServerResponse({ video: { kling: {} } });
  630. await store.getState().fetchServerProviders();
  631. expect(store.getState().videoProviderId).toBe('kling');
  632. expect(store.getState().videoModelId).toBe('kling-v2-6');
  633. });
  634. it('auto-selects provider and model when server adds video provider after empty state', async () => {
  635. const store = await getStore();
  636. // Start with no video providers — generation disabled
  637. mockServerResponse({});
  638. await store.getState().fetchServerProviders();
  639. expect(store.getState().videoProviderId).toBe('');
  640. expect(store.getState().videoModelId).toBe('');
  641. expect(store.getState().videoGenerationEnabled).toBe(false);
  642. // Server adds seedance
  643. mockServerResponse({ video: { seedance: {} } });
  644. await store.getState().fetchServerProviders();
  645. expect(store.getState().videoProviderId).toBe('seedance');
  646. expect(store.getState().videoModelId).toBe('doubao-seedance-1-5-pro-251215');
  647. // Provider recovered but generation stays off — user enables manually
  648. expect(store.getState().videoGenerationEnabled).toBe(false);
  649. });
  650. });
  651. describe('fetchServerProviders — LLM cross-provider fallback', () => {
  652. beforeEach(() => {
  653. vi.resetModules();
  654. storage.clear();
  655. mockFetch.mockReset();
  656. });
  657. async function getStore() {
  658. const { useSettingsStore } = await import('@/lib/store/settings');
  659. return useSettingsStore;
  660. }
  661. it('falls back to another server-configured LLM provider when current becomes unusable', async () => {
  662. const store = await getStore();
  663. mockServerResponse({
  664. providers: {
  665. openai: { models: ['gpt-4o'] },
  666. anthropic: { models: ['claude-sonnet-4-6'] },
  667. },
  668. });
  669. await store.getState().fetchServerProviders();
  670. store.getState().setModel('openai', 'gpt-4o');
  671. mockServerResponse({
  672. providers: {
  673. anthropic: { models: ['claude-sonnet-4-6'] },
  674. },
  675. });
  676. await store.getState().fetchServerProviders();
  677. expect(store.getState().providerId).toBe('anthropic');
  678. expect(store.getState().modelId).toBe('claude-sonnet-4-6');
  679. });
  680. });