chat-page-settings-modal.test.ts
1 import { describe, expect, it, vi } from 'vitest' 2 3 import type { 4 ProviderCatalogItem, 5 SettingsResponsePayload, 6 SettingsUpdatePayload, 7 } from '@/lib/shared/chat' 8 import type { ProviderWithSettings } from '@/lib/shared/providers' 9 import type { SettingsFormState } from '@/features/chat/components/chat-page/types' 10 import { saveSettingsFromModal } from '@/features/chat/chat-page/settings-modal' 11 import { updateChatSettings } from '@/features/chat/api' 12 13 vi.mock('@/features/chat/api', () => ({ 14 updateChatSettings: vi.fn(), 15 })) 16 17 function makeForm(overrides: Partial<SettingsFormState> = {}): SettingsFormState { 18 const base: SettingsFormState = { 19 sessionChatModelOverride: '', 20 sessionThinkingLevelOverride: '', 21 sessionReasoningLevelOverride: '', 22 sessionProviderPreset: 'inherit', 23 sessionInheritedProviderPreset: 'openai', 24 sessionProviderBaseUrl: '', 25 sessionProviderEndpointMode: 'auto', 26 sessionProviderApiKey: '', 27 defaultsChatModel: '', 28 defaultsProviderModelById: {}, 29 defaultsProviderModelOpenAi: '', 30 defaultsProviderModelRouter: '', 31 defaultsProviderModelCustom: '', 32 defaultsEmbeddingModel: '', 33 defaultsTranscriptionModel: '', 34 defaultsEmbeddingProviderId: '', 35 defaultsTranscriptionProviderId: '', 36 defaultsThinkingLevel: '', 37 defaultsReasoningLevel: '', 38 defaultsProviderPreset: 'openai', 39 defaultsOpenAiBaseUrl: 'https://api.openai.com/v1', 40 defaultsOpenAiApiKey: '', 41 defaultsRouterBaseUrl: '', 42 defaultsRouterApiKey: '', 43 defaultsProviderBaseUrl: '', 44 defaultsProviderEndpointMode: '', 45 defaultsProviderApiKey: '', 46 defaultsToolCallLogRetentionDays: '30', 47 } 48 49 return { 50 ...base, 51 ...overrides, 52 defaultsProviderModelById: { 53 ...base.defaultsProviderModelById, 54 ...(overrides.defaultsProviderModelById ?? {}), 55 }, 56 } 57 } 58 59 function makeCatalog(): ProviderCatalogItem[] { 60 return [ 61 { 62 id: 'openai', 63 label: 'OpenAI', 64 available: true, 65 baseUrl: 'https://api.openai.com/v1', 66 description: null, 67 defaultChatModel: null, 68 models: [], 69 modelCapabilities: {}, 70 }, 71 { 72 id: 'example', 73 label: 'Example', 74 available: true, 75 baseUrl: 'https://example.com/v1', 76 description: null, 77 defaultChatModel: null, 78 models: ['minimax-m2'], 79 modelCapabilities: { 80 'minimax-m2': ['reasoning'], 81 }, 82 }, 83 ] 84 } 85 86 function makeProviders(): ProviderWithSettings[] { 87 return [ 88 { 89 id: 'openai', 90 name: 'OpenAI', 91 type: 'openai', 92 isCustom: false, 93 settings: { 94 apiHost: 'https://api.openai.com/v1', 95 apiPath: '/responses', 96 }, 97 }, 98 { 99 id: 'example', 100 name: 'Example', 101 type: 'openai', 102 isCustom: true, 103 settings: { 104 apiHost: 'https://example.com/v1', 105 apiPath: '/chat/completions', 106 models: [{ modelId: 'minimax-m2', type: 'chat', capabilities: ['reasoning'] }], 107 }, 108 }, 109 ] as ProviderWithSettings[] 110 } 111 112 function makeSnapshot(): SettingsResponsePayload { 113 return { 114 ok: true, 115 session: { 116 sessionId: 'session-1', 117 chatModelOverride: null, 118 activeChatModel: 'minimax-m2', 119 activeProviderModelDefault: null, 120 thinkingLevelOverride: null, 121 activeThinkingLevel: null, 122 reasoningLevelOverride: null, 123 activeReasoningLevel: 'off', 124 providerOverrideActive: false, 125 providerProfileId: 'openai', 126 providerEndpointMode: 'auto', 127 providerApiKeyConfigured: true, 128 providerLabel: 'OpenAI', 129 providerSourceLabel: 'global default (OpenAI)', 130 }, 131 defaults: { 132 chatModel: null, 133 embeddingModel: null, 134 transcriptionModel: null, 135 embeddingProviderId: null, 136 transcriptionProviderId: null, 137 providerModelDefaults: {}, 138 thinkingLevel: null, 139 reasoningLevel: null, 140 defaultProviderId: 'openai', 141 providerEndpointMode: 'auto', 142 webSearchProvider: null, 143 webSearchMaxResults: null, 144 webFetchMaxBytes: null, 145 toolCallLogRetentionDays: 30, 146 updatedAt: null, 147 }, 148 providerCatalog: makeCatalog(), 149 providers: makeProviders(), 150 } 151 } 152 153 describe('saveSettingsFromModal', () => { 154 it('persists session/default provider selection by provider id', async () => { 155 const updateMock = vi.mocked(updateChatSettings) 156 updateMock.mockResolvedValue(makeSnapshot()) 157 158 let payloadSent: SettingsUpdatePayload | undefined 159 updateMock.mockImplementation(async (payload) => { 160 payloadSent = payload 161 return makeSnapshot() 162 }) 163 164 await saveSettingsFromModal({ 165 sessionId: 'session-1', 166 settingsForm: makeForm({ 167 sessionProviderPreset: 'example', 168 defaultsProviderPreset: 'example', 169 defaultsProviderModelById: { 170 example: 'minimax-m2', 171 }, 172 }), 173 providerCatalog: makeCatalog(), 174 providers: makeProviders(), 175 settingsSnapshot: null, 176 setIsSavingSettings: () => undefined, 177 setSettingsMutationError: () => undefined, 178 setSettingsSnapshot: () => undefined, 179 setSettingsForm: () => undefined, 180 setIsSettingsModalOpen: () => undefined, 181 setStatusLine: () => undefined, 182 }) 183 184 expect(updateMock).toHaveBeenCalledTimes(1) 185 const sentPayload = payloadSent as SettingsUpdatePayload 186 expect(sentPayload.session?.provider).toMatchObject({ 187 providerId: 'example', 188 }) 189 expect(sentPayload.defaults?.defaultProviderId).toBe('example') 190 expect(sentPayload.defaults?.providerModelDefaults?.example).toBe('minimax-m2') 191 expect(sentPayload.defaults?.providers?.find((provider) => provider.id === 'example')) 192 .toBeTruthy() 193 }) 194 })