返回 oh-my-ppt
settings-handlers.test.ts
根目录 / tests / unit / ipc / settings-handlers.test.ts
1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
3 const settingsHandlersState = vi.hoisted(() => {
4 const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>()
5
6 return {
7 appMock: {
8 getVersion: vi.fn(() => '1.0.0')
9 },
10 applyProxyMock: vi.fn(),
11 dialogMock: {
12 showOpenDialog: vi.fn()
13 },
14 handlers,
15 ipcMainMock: {
16 handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
17 handlers.set(channel, handler)
18 })
19 },
20 localeMock: {
21 readAppLocale: vi.fn(async () => 'zh'),
22 uiText: vi.fn((locale: string, zh: string, en: string) => (locale === 'en' ? en : zh))
23 },
24 logMock: {
25 error: vi.fn(),
26 info: vi.fn(),
27 warn: vi.fn()
28 },
29 resolveModelMock: vi.fn()
30 }
31 })
32
33 vi.mock('electron', () => ({
34 BrowserWindow: {
35 fromWebContents: vi.fn(),
36 getFocusedWindow: vi.fn()
37 },
38 app: settingsHandlersState.appMock,
39 dialog: settingsHandlersState.dialogMock,
40 ipcMain: settingsHandlersState.ipcMainMock
41 }))
42
43 vi.mock('electron-log/main.js', () => ({
44 default: settingsHandlersState.logMock
45 }))
46
47 vi.mock('../../../src/main/agent-runtime/model', () => ({
48 resolveModel: settingsHandlersState.resolveModelMock,
49 runWithModelTemperatureControl: <T>(_config: unknown, task: () => T): T => task(),
50 OPENAI_RESPONSES_FORMAT_ERROR_EN: 'Invalid OpenAI Responses API payload.',
51 OPENAI_RESPONSES_FORMAT_ERROR_ZH: '当前 provider 返回的不是 OpenAI Responses API 格式。',
52 isOpenAIResponsesFormatError: (error: unknown) =>
53 /Cannot read propert(?:y|ies).*undefined.*map|Cannot read propert(?:y|ies).*map.*undefined/i.test(
54 error instanceof Error ? error.message : ''
55 )
56 }))
57
58 vi.mock('../../../src/main/utils/proxy', () => ({
59 applyProxy: settingsHandlersState.applyProxyMock
60 }))
61
62 vi.mock('../../../src/main/config/locale-utils', () => ({
63 readAppLocale: settingsHandlersState.localeMock.readAppLocale,
64 uiText: settingsHandlersState.localeMock.uiText
65 }))
66
67 vi.mock('@shared/model-timeout', () => ({
68 CONFIGURABLE_MODEL_TIMEOUT_PROFILES: ['planning', 'design', 'agent', 'document'],
69 resolveModelTimeoutMs: vi.fn((value: unknown, profile: string) => {
70 if (typeof value === 'number' && Number.isFinite(value)) return value
71 const defaults: Record<string, number> = {
72 planning: 300000,
73 design: 300000,
74 agent: 600000,
75 document: 600000
76 }
77 return defaults[profile] ?? 300000
78 })
79 }))
80
81 async function registerWithDb(overrides: Partial<Record<string, unknown>> = {}) {
82 vi.resetModules()
83 settingsHandlersState.handlers.clear()
84
85 const { registerSettingsHandlers } = await import('../../../src/main/config/settings-handlers')
86
87 const db = {
88 getAllSettings: vi.fn(async () => ({})),
89 listModelConfigs: vi.fn(async () => []),
90 setSetting: vi.fn(async () => undefined),
91 setStoragePath: vi.fn(async () => undefined),
92 ...overrides
93 }
94
95 const ctx = {
96 mainWindow: {} as never,
97 db,
98 encryptApiKey: vi.fn((value: string) => value),
99 decryptApiKey: vi.fn((value: unknown) => String(value ?? ''))
100 }
101
102 registerSettingsHandlers(ctx as never)
103
104 return {
105 db,
106 getHandler: (channel: string) => settingsHandlersState.handlers.get(channel)
107 }
108 }
109
110 describe('registerSettingsHandlers proxy settings', () => {
111 beforeEach(() => {
112 settingsHandlersState.applyProxyMock.mockReset()
113 settingsHandlersState.appMock.getVersion.mockClear()
114 settingsHandlersState.dialogMock.showOpenDialog.mockReset()
115 settingsHandlersState.handlers.clear()
116 settingsHandlersState.ipcMainMock.handle.mockClear()
117 settingsHandlersState.localeMock.readAppLocale.mockReset()
118 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
119 settingsHandlersState.localeMock.uiText.mockClear()
120 settingsHandlersState.logMock.error.mockClear()
121 settingsHandlersState.logMock.info.mockClear()
122 settingsHandlersState.resolveModelMock.mockReset()
123 })
124
125 it('returns trimmed proxyUrl from settings:get', async () => {
126 const { getHandler } = await registerWithDb({
127 getAllSettings: vi.fn(async () => ({
128 locale: 'en',
129 proxy_url: ' http://127.0.0.1:7890 ',
130 storage_path: ' /tmp/workspace '
131 }))
132 })
133
134 const getSettings = getHandler('settings:get')
135 const result = await getSettings?.()
136
137 expect(result).toMatchObject({
138 locale: 'en',
139 proxyUrl: 'http://127.0.0.1:7890',
140 storagePath: '/tmp/workspace'
141 })
142 })
143
144 it('applies proxy before persisting proxy_url in settings:save', async () => {
145 const callOrder: string[] = []
146 settingsHandlersState.applyProxyMock.mockImplementation(() => {
147 callOrder.push('apply')
148 })
149 const db = {
150 setSetting: vi.fn(async (key: string) => {
151 if (key === 'proxy_url') {
152 callOrder.push('persist')
153 }
154 })
155 }
156 const { getHandler } = await registerWithDb(db)
157
158 const saveSettings = getHandler('settings:save')
159 const result = await saveSettings?.(undefined, {
160 proxyUrl: ' http://127.0.0.1:7890 '
161 })
162
163 expect(result).toEqual({ success: true })
164 expect(settingsHandlersState.applyProxyMock).toHaveBeenCalledWith({
165 url: 'http://127.0.0.1:7890',
166 username: '',
167 password: '',
168 noProxy: ''
169 })
170 expect(db.setSetting).toHaveBeenCalledWith('proxy_url', 'http://127.0.0.1:7890')
171 expect(callOrder).toEqual(['apply', 'persist'])
172 })
173
174 it('does not persist proxy_url when applyProxy fails', async () => {
175 settingsHandlersState.applyProxyMock.mockImplementation(() => {
176 throw new Error('bad proxy')
177 })
178 const db = {
179 setSetting: vi.fn(async () => undefined)
180 }
181 const { getHandler } = await registerWithDb(db)
182
183 const saveSettings = getHandler('settings:save')
184
185 await expect(
186 saveSettings?.(undefined, {
187 proxyUrl: 'http://broken-proxy'
188 })
189 ).rejects.toThrow('代理设置无效:bad proxy')
190
191 expect(settingsHandlersState.localeMock.readAppLocale).toHaveBeenCalled()
192 expect(db.setSetting).not.toHaveBeenCalledWith('proxy_url', expect.anything())
193 })
194
195 it('clears persisted proxy when saving an empty proxyUrl', async () => {
196 const db = {
197 setSetting: vi.fn(async () => undefined)
198 }
199 const { getHandler } = await registerWithDb(db)
200
201 const saveSettings = getHandler('settings:save')
202 await saveSettings?.(undefined, { proxyUrl: ' ' })
203
204 expect(settingsHandlersState.applyProxyMock).toHaveBeenCalledWith({
205 url: '',
206 username: '',
207 password: '',
208 noProxy: ''
209 })
210 expect(db.setSetting).toHaveBeenCalledWith('proxy_url', '')
211 })
212
213 it('saves proxy credentials and no_proxy list with the proxy address', async () => {
214 const db = {
215 setSetting: vi.fn(async () => undefined)
216 }
217 const { getHandler } = await registerWithDb(db)
218
219 const saveSettings = getHandler('settings:save')
220 await saveSettings?.(undefined, {
221 proxyUrl: 'http://127.0.0.1:7890',
222 proxyUsername: ' corp\\user ',
223 proxyPassword: 'secret',
224 proxyNoProxy: ' localhost,10.0.0.0/8 '
225 })
226
227 expect(settingsHandlersState.applyProxyMock).toHaveBeenCalledWith({
228 url: 'http://127.0.0.1:7890',
229 username: 'corp\\user',
230 password: 'secret',
231 noProxy: 'localhost,10.0.0.0/8'
232 })
233 expect(db.setSetting).toHaveBeenCalledWith('proxy_username', 'corp\\user')
234 expect(db.setSetting).toHaveBeenCalledWith('proxy_password', 'secret')
235 expect(db.setSetting).toHaveBeenCalledWith('proxy_no_proxy', 'localhost,10.0.0.0/8')
236 })
237
238 it('keeps stored proxy credentials when saving only the proxy address', async () => {
239 const db = {
240 getAllSettings: vi.fn(async () => ({
241 proxy_username: 'corp\\user',
242 proxy_password: 'enc:v1:cipher',
243 proxy_no_proxy: '10.0.0.0/8'
244 })),
245 setSetting: vi.fn(async () => undefined)
246 }
247 const { getHandler, db: registeredDb } = await registerWithDb(db)
248
249 const saveSettings = getHandler('settings:save')
250 await saveSettings?.(undefined, { proxyUrl: 'http://127.0.0.1:7890' })
251
252 expect(settingsHandlersState.applyProxyMock).toHaveBeenCalledWith({
253 url: 'http://127.0.0.1:7890',
254 username: 'corp\\user',
255 password: 'enc:v1:cipher',
256 noProxy: '10.0.0.0/8'
257 })
258 expect(registeredDb.setSetting).not.toHaveBeenCalledWith('proxy_password', expect.anything())
259 })
260 })
261
262 describe('registerSettingsHandlers model list fetching', () => {
263 beforeEach(() => {
264 settingsHandlersState.handlers.clear()
265 settingsHandlersState.ipcMainMock.handle.mockClear()
266 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
267 settingsHandlersState.logMock.error.mockClear()
268 settingsHandlersState.logMock.info.mockClear()
269 })
270
271 afterEach(() => {
272 vi.unstubAllGlobals()
273 })
274
275 it('fetches the model list from the provider endpoint', async () => {
276 const fetchMock = vi.fn(
277 async () =>
278 new Response(
279 JSON.stringify({ data: [{ id: 'glm-5.3' }, { id: 'deepseek-v4-flash' }] }),
280 { status: 200 }
281 )
282 )
283 vi.stubGlobal('fetch', fetchMock)
284 const { getHandler } = await registerWithDb()
285
286 const fetchModelList = getHandler('settings:fetchModelList')
287 const result = await fetchModelList?.(undefined, {
288 provider: 'openai',
289 apiKey: 'secret',
290 baseUrl: 'https://llm.corp.internal/v1'
291 })
292
293 expect(result).toEqual({ models: ['deepseek-v4-flash', 'glm-5.3'], message: null })
294 expect(fetchMock).toHaveBeenCalledWith(
295 'https://llm.corp.internal/v1/models',
296 expect.objectContaining({
297 headers: { Authorization: 'Bearer secret' }
298 })
299 )
300 })
301
302 it('requires the api key before fetching', async () => {
303 const fetchMock = vi.fn()
304 vi.stubGlobal('fetch', fetchMock)
305 const { getHandler } = await registerWithDb()
306
307 const fetchModelList = getHandler('settings:fetchModelList')
308 const result = await fetchModelList?.(undefined, {
309 provider: 'openai',
310 apiKey: ' ',
311 baseUrl: ''
312 })
313
314 expect(result).toEqual({ models: [], message: '请先填写 api_key。' })
315 expect(fetchMock).not.toHaveBeenCalled()
316 })
317
318 it('reports http failures without throwing', async () => {
319 vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
320 const { getHandler } = await registerWithDb()
321
322 const fetchModelList = getHandler('settings:fetchModelList')
323 const result = await fetchModelList?.(undefined, {
324 provider: 'anthropic',
325 apiKey: 'secret',
326 baseUrl: 'https://gw.corp.cn'
327 })
328
329 expect(result).toEqual({
330 models: [],
331 message: expect.stringContaining('HTTP 404')
332 })
333 })
334
335 it('reports empty model lists with a hint', async () => {
336 vi.stubGlobal(
337 'fetch',
338 vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }))
339 )
340 const { getHandler } = await registerWithDb()
341
342 const fetchModelList = getHandler('settings:fetchModelList')
343 const result = await fetchModelList?.(undefined, {
344 provider: 'openai',
345 apiKey: 'secret',
346 baseUrl: ''
347 })
348
349 expect(result).toEqual({ models: [], message: expect.stringContaining('未返回任何模型') })
350 })
351 })
352
353 describe('registerSettingsHandlers model temperature settings', () => {
354 beforeEach(() => {
355 settingsHandlersState.handlers.clear()
356 settingsHandlersState.ipcMainMock.handle.mockClear()
357 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
358 settingsHandlersState.resolveModelMock.mockReset()
359 })
360
361 it('returns disableTemperature in the model config list', async () => {
362 const { getHandler } = await registerWithDb({
363 listModelConfigs: vi.fn(async () => [
364 {
365 id: 'model-1',
366 name: 'Reasoning model',
367 provider: 'openai',
368 model: 'reasoner',
369 apiKey: 'secret',
370 baseUrl: '',
371 maxTokens: 4096,
372 disableTemperature: 1,
373 thinkingParameterMode: 'omit',
374 active: 1,
375 createdAt: 1,
376 updatedAt: 2
377 }
378 ])
379 })
380
381 const listModelConfigs = getHandler('settings:listModelConfigs')
382 await expect(listModelConfigs?.()).resolves.toEqual([
383 expect.objectContaining({
384 id: 'model-1',
385 disableTemperature: true,
386 thinkingParameterMode: 'omit'
387 })
388 ])
389 })
390
391 it('persists parameter controls when saving a model config', async () => {
392 const upsertModelConfig = vi.fn(async () => 'model-1')
393 const { getHandler } = await registerWithDb({ upsertModelConfig })
394
395 const saveModelConfig = getHandler('settings:upsertModelConfig')
396 await saveModelConfig?.(undefined, {
397 name: 'Reasoning model',
398 provider: 'openai',
399 model: 'reasoner',
400 apiKey: 'secret',
401 baseUrl: '',
402 maxTokens: 4096,
403 disableTemperature: true,
404 thinkingParameterMode: 'omit'
405 })
406
407 expect(upsertModelConfig).toHaveBeenCalledWith(
408 expect.objectContaining({
409 disableTemperature: true,
410 thinkingParameterMode: 'omit'
411 })
412 )
413 })
414
415 it('accepts the OpenAI Responses provider when saving a model config', async () => {
416 const upsertModelConfig = vi.fn(async () => 'model-1')
417 const { getHandler } = await registerWithDb({ upsertModelConfig })
418
419 const saveModelConfig = getHandler('settings:upsertModelConfig')
420 await saveModelConfig?.(undefined, {
421 name: 'Responses model',
422 provider: 'openai-responses',
423 model: 'gpt-5.1',
424 apiKey: 'secret',
425 baseUrl: 'https://api.openai.com/v1',
426 maxTokens: 4096,
427 disableTemperature: false,
428 thinkingParameterMode: 'not-valid'
429 })
430
431 expect(upsertModelConfig).toHaveBeenCalledWith(
432 expect.objectContaining({
433 provider: 'openai-responses',
434 thinkingParameterMode: 'auto'
435 })
436 )
437 })
438
439 it('explains invalid Responses API payloads during model verification', async () => {
440 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
441 settingsHandlersState.resolveModelMock.mockReturnValue({
442 invoke: vi.fn(async () => {
443 throw new TypeError("Cannot read properties of undefined (reading 'map')")
444 })
445 })
446 const { getHandler } = await registerWithDb()
447
448 const verifyApiKey = getHandler('settings:verifyApiKey')
449 const result = await verifyApiKey?.(undefined, {
450 provider: 'openai-responses',
451 model: 'gpt-5.5',
452 apiKey: 'secret',
453 baseUrl: 'https://www.toumingren.xyz/v1',
454 maxTokens: 4096,
455 timeoutMs: 60000
456 })
457
458 expect(result).toEqual({
459 valid: false,
460 message: expect.stringContaining('不是 OpenAI Responses API 格式')
461 })
462 })
463
464 it('explains unsupported thinking parameter errors during model verification', async () => {
465 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
466 settingsHandlersState.resolveModelMock.mockReturnValue({
467 invoke: vi.fn(async () => {
468 throw new Error('Unsupported parameter: thinking')
469 })
470 })
471 const { getHandler } = await registerWithDb()
472
473 const verifyApiKey = getHandler('settings:verifyApiKey')
474 const result = await verifyApiKey?.(undefined, {
475 provider: 'openai',
476 model: 'compatible-model',
477 apiKey: 'secret',
478 baseUrl: 'https://api.example-compatible.com/v1',
479 maxTokens: 4096,
480 thinkingParameterMode: 'auto',
481 timeoutMs: 60000
482 })
483
484 expect(result).toEqual({
485 valid: false,
486 message: expect.stringContaining('不发送 thinking 参数')
487 })
488 })
489
490 it('explains unrecognized thinking argument errors during model verification', async () => {
491 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
492 settingsHandlersState.resolveModelMock.mockReturnValue({
493 invoke: vi.fn(async () => {
494 throw new Error('Unrecognized request argument supplied: thinking')
495 })
496 })
497 const { getHandler } = await registerWithDb()
498
499 const verifyApiKey = getHandler('settings:verifyApiKey')
500 const result = await verifyApiKey?.(undefined, {
501 provider: 'openai',
502 model: 'compatible-model',
503 apiKey: 'secret',
504 baseUrl: 'https://api.example-compatible.com/v1',
505 maxTokens: 4096,
506 thinkingParameterMode: 'auto',
507 timeoutMs: 60000
508 })
509
510 expect(result).toEqual({
511 valid: false,
512 message: expect.stringContaining('不发送 thinking 参数')
513 })
514 })
515
516 it('explains older undefined map errors during Responses API verification', async () => {
517 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
518 settingsHandlersState.resolveModelMock.mockReturnValue({
519 invoke: vi.fn(async () => {
520 throw new TypeError("Cannot read property 'map' of undefined")
521 })
522 })
523 const { getHandler } = await registerWithDb()
524
525 const verifyApiKey = getHandler('settings:verifyApiKey')
526 const result = await verifyApiKey?.(undefined, {
527 provider: 'openai-responses',
528 model: 'gpt-5.5',
529 apiKey: 'secret',
530 baseUrl: 'https://www.toumingren.xyz/v1',
531 maxTokens: 4096,
532 timeoutMs: 60000
533 })
534
535 expect(result).toEqual({
536 valid: false,
537 message: expect.stringContaining('OpenAI Responses API')
538 })
539 })
540 })
541
542 describe('registerSettingsHandlers model usage', () => {
543 beforeEach(() => {
544 settingsHandlersState.handlers.clear()
545 settingsHandlersState.ipcMainMock.handle.mockClear()
546 })
547
548 it('delegates the selected usage period to the database', async () => {
549 const stats = {
550 period: '7d',
551 startedAt: 1,
552 totals: {
553 callCount: 2,
554 exactCallCount: 1,
555 estimatedCallCount: 1,
556 inputTokens: 100,
557 outputTokens: 20,
558 totalTokens: 120
559 },
560 byModel: [],
561 byDay: []
562 }
563 const getModelUsageStats = vi.fn(async () => stats)
564 const { getHandler } = await registerWithDb({ getModelUsageStats })
565
566 await expect(getHandler('settings:getModelUsage')?.(undefined, '7d')).resolves.toBe(stats)
567 expect(getModelUsageStats).toHaveBeenCalledWith('7d')
568 })
569
570 it('falls back to 30 days for an invalid usage period', async () => {
571 const getModelUsageStats = vi.fn(async () => ({ period: '30d' }))
572 const { getHandler } = await registerWithDb({ getModelUsageStats })
573
574 await getHandler('settings:getModelUsage')?.(undefined, 'invalid')
575 expect(getModelUsageStats).toHaveBeenCalledWith('30d')
576 })
577 })
578
578 lines TYPESCRIPT