@lobehub/lobehub 2.0.0-next.141 → 2.0.0-next.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/Dockerfile +2 -0
  3. package/apps/desktop/package.json +1 -0
  4. package/apps/desktop/src/main/controllers/__tests__/McpInstallCtr.test.ts +286 -0
  5. package/apps/desktop/src/main/controllers/__tests__/NotificationCtr.test.ts +347 -0
  6. package/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts +645 -0
  7. package/apps/desktop/src/main/controllers/__tests__/RemoteServerSyncCtr.test.ts +372 -0
  8. package/apps/desktop/src/main/controllers/__tests__/SystemCtr.test.ts +276 -0
  9. package/apps/desktop/src/main/controllers/__tests__/UploadFileCtr.test.ts +171 -0
  10. package/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts +573 -0
  11. package/apps/desktop/src/main/core/browser/__tests__/BrowserManager.test.ts +415 -0
  12. package/apps/desktop/src/main/core/infrastructure/__tests__/I18nManager.test.ts +353 -0
  13. package/apps/desktop/src/main/core/infrastructure/__tests__/IoCContainer.test.ts +156 -0
  14. package/apps/desktop/src/main/core/infrastructure/__tests__/ProtocolManager.test.ts +348 -0
  15. package/apps/desktop/src/main/core/infrastructure/__tests__/StaticFileServerManager.test.ts +481 -0
  16. package/apps/desktop/src/main/core/infrastructure/__tests__/StoreManager.test.ts +164 -0
  17. package/apps/desktop/src/main/core/infrastructure/__tests__/UpdaterManager.test.ts +513 -0
  18. package/apps/desktop/src/main/core/ui/__tests__/MenuManager.test.ts +320 -0
  19. package/apps/desktop/src/main/core/ui/__tests__/Tray.test.ts +518 -0
  20. package/apps/desktop/src/main/core/ui/__tests__/TrayManager.test.ts +360 -0
  21. package/apps/desktop/src/main/menus/impls/BaseMenuPlatform.test.ts +49 -0
  22. package/apps/desktop/src/main/menus/impls/linux.test.ts +552 -0
  23. package/apps/desktop/src/main/menus/impls/macOS.test.ts +464 -0
  24. package/apps/desktop/src/main/menus/impls/windows.test.ts +429 -0
  25. package/apps/desktop/src/main/modules/fileSearch/__tests__/macOS.integration.test.ts +2 -2
  26. package/apps/desktop/src/main/services/__tests__/fileSearchSrv.test.ts +402 -0
  27. package/apps/desktop/src/main/utils/__tests__/file-system.test.ts +91 -0
  28. package/apps/desktop/src/main/utils/__tests__/logger.test.ts +229 -0
  29. package/apps/desktop/src/preload/electronApi.test.ts +142 -0
  30. package/apps/desktop/src/preload/invoke.test.ts +145 -0
  31. package/apps/desktop/src/preload/routeInterceptor.test.ts +374 -0
  32. package/apps/desktop/src/preload/streamer.test.ts +365 -0
  33. package/apps/desktop/vitest.config.mts +1 -0
  34. package/changelog/v1.json +18 -0
  35. package/docs/self-hosting/environment-variables/model-provider.mdx +31 -0
  36. package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +30 -0
  37. package/locales/ar/marketAuth.json +13 -0
  38. package/locales/bg-BG/marketAuth.json +13 -0
  39. package/locales/de-DE/marketAuth.json +13 -0
  40. package/locales/en-US/marketAuth.json +13 -0
  41. package/locales/es-ES/marketAuth.json +13 -0
  42. package/locales/fa-IR/marketAuth.json +13 -0
  43. package/locales/fr-FR/marketAuth.json +13 -0
  44. package/locales/it-IT/marketAuth.json +13 -0
  45. package/locales/ja-JP/marketAuth.json +13 -0
  46. package/locales/ko-KR/marketAuth.json +13 -0
  47. package/locales/nl-NL/marketAuth.json +13 -0
  48. package/locales/pl-PL/marketAuth.json +13 -0
  49. package/locales/pt-BR/marketAuth.json +13 -0
  50. package/locales/ru-RU/marketAuth.json +13 -0
  51. package/locales/tr-TR/marketAuth.json +13 -0
  52. package/locales/vi-VN/marketAuth.json +13 -0
  53. package/locales/zh-CN/marketAuth.json +13 -0
  54. package/locales/zh-TW/marketAuth.json +13 -0
  55. package/package.json +1 -1
  56. package/packages/database/src/models/user.ts +2 -0
  57. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +6 -3
  58. package/packages/types/src/discover/mcp.ts +2 -1
  59. package/packages/types/src/tool/plugin.ts +2 -1
  60. package/src/app/[variants]/(main)/chat/settings/features/SmartAgentActionButton/MarketPublishButton.tsx +0 -2
  61. package/src/app/[variants]/(main)/discover/(detail)/mcp/features/Sidebar/ActionButton/index.tsx +33 -7
  62. package/src/config/modelProviders/vertexai.ts +1 -1
  63. package/src/envs/llm.ts +4 -0
  64. package/src/features/PluginStore/McpList/List/Action.tsx +20 -1
  65. package/src/layout/AuthProvider/MarketAuth/MarketAuthConfirmModal.tsx +158 -0
  66. package/src/layout/AuthProvider/MarketAuth/MarketAuthProvider.tsx +130 -14
  67. package/src/libs/mcp/types.ts +8 -0
  68. package/src/locales/default/marketAuth.ts +13 -0
  69. package/src/server/modules/ModelRuntime/index.ts +4 -4
  70. package/src/server/routers/lambda/market/index.ts +85 -2
  71. package/src/server/services/discover/index.ts +45 -4
  72. package/src/services/discover.ts +1 -1
  73. package/src/services/mcp.ts +18 -3
  74. package/src/store/tool/slices/mcpStore/action.test.ts +141 -0
  75. package/src/store/tool/slices/mcpStore/action.ts +153 -11
@@ -0,0 +1,645 @@
1
+ import { DataSyncConfig } from '@lobechat/electron-client-ipc';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import type { App } from '@/core/App';
5
+
6
+ import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
7
+
8
+ // Mock logger
9
+ vi.mock('@/utils/logger', () => ({
10
+ createLogger: () => ({
11
+ debug: vi.fn(),
12
+ error: vi.fn(),
13
+ info: vi.fn(),
14
+ warn: vi.fn(),
15
+ }),
16
+ }));
17
+
18
+ // Mock electron
19
+ vi.mock('electron', () => ({
20
+ safeStorage: {
21
+ decryptString: vi.fn((buffer: Buffer) => buffer.toString()),
22
+ encryptString: vi.fn((str: string) => Buffer.from(str)),
23
+ isEncryptionAvailable: vi.fn(() => true),
24
+ },
25
+ }));
26
+
27
+ // Mock @/const/env
28
+ vi.mock('@/const/env', () => ({
29
+ OFFICIAL_CLOUD_SERVER: 'https://cloud.lobehub.com',
30
+ }));
31
+
32
+ // Mock storeManager
33
+ const mockStoreManager = {
34
+ delete: vi.fn(),
35
+ get: vi.fn(),
36
+ set: vi.fn(),
37
+ };
38
+
39
+ const mockApp = {
40
+ storeManager: mockStoreManager,
41
+ } as unknown as App;
42
+
43
+ describe('RemoteServerConfigCtr', () => {
44
+ let controller: RemoteServerConfigCtr;
45
+
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ mockStoreManager.get.mockReturnValue({
49
+ active: false,
50
+ storageMode: 'local',
51
+ });
52
+ controller = new RemoteServerConfigCtr(mockApp);
53
+ });
54
+
55
+ describe('getRemoteServerConfig', () => {
56
+ it('should return stored configuration', async () => {
57
+ const config: DataSyncConfig = {
58
+ active: true,
59
+ remoteServerUrl: 'https://my-server.com',
60
+ storageMode: 'selfHost',
61
+ };
62
+ mockStoreManager.get.mockReturnValue(config);
63
+
64
+ const result = await controller.getRemoteServerConfig();
65
+
66
+ expect(result).toEqual(config);
67
+ expect(mockStoreManager.get).toHaveBeenCalledWith('dataSyncConfig');
68
+ });
69
+ });
70
+
71
+ describe('setRemoteServerConfig', () => {
72
+ it('should update configuration', async () => {
73
+ const prevConfig: DataSyncConfig = {
74
+ active: false,
75
+ storageMode: 'local',
76
+ };
77
+ mockStoreManager.get.mockReturnValue(prevConfig);
78
+
79
+ const newConfig: Partial<DataSyncConfig> = {
80
+ active: true,
81
+ remoteServerUrl: 'https://my-server.com',
82
+ storageMode: 'selfHost',
83
+ };
84
+
85
+ const result = await controller.setRemoteServerConfig(newConfig);
86
+
87
+ expect(result).toBe(true);
88
+ expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', {
89
+ ...prevConfig,
90
+ ...newConfig,
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('clearRemoteServerConfig', () => {
96
+ it('should clear configuration and tokens', async () => {
97
+ const result = await controller.clearRemoteServerConfig();
98
+
99
+ expect(result).toBe(true);
100
+ expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', { storageMode: 'local' });
101
+ expect(mockStoreManager.delete).toHaveBeenCalledWith('encryptedTokens');
102
+ });
103
+ });
104
+
105
+ describe('saveTokens', () => {
106
+ it('should save encrypted tokens with expiration', async () => {
107
+ const { safeStorage } = await import('electron');
108
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
109
+
110
+ await controller.saveTokens('access-token', 'refresh-token', 3600);
111
+
112
+ expect(safeStorage.encryptString).toHaveBeenCalledWith('access-token');
113
+ expect(safeStorage.encryptString).toHaveBeenCalledWith('refresh-token');
114
+ expect(mockStoreManager.set).toHaveBeenCalledWith(
115
+ 'encryptedTokens',
116
+ expect.objectContaining({
117
+ accessToken: expect.any(String),
118
+ expiresAt: expect.any(Number),
119
+ refreshToken: expect.any(String),
120
+ }),
121
+ );
122
+ });
123
+
124
+ it('should save tokens without expiration', async () => {
125
+ const { safeStorage } = await import('electron');
126
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
127
+
128
+ await controller.saveTokens('access-token', 'refresh-token');
129
+
130
+ expect(mockStoreManager.set).toHaveBeenCalledWith(
131
+ 'encryptedTokens',
132
+ expect.objectContaining({
133
+ accessToken: expect.any(String),
134
+ expiresAt: undefined,
135
+ refreshToken: expect.any(String),
136
+ }),
137
+ );
138
+ });
139
+
140
+ it('should save unencrypted tokens when encryption is not available', async () => {
141
+ const { safeStorage } = await import('electron');
142
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false);
143
+
144
+ await controller.saveTokens('access-token', 'refresh-token', 3600);
145
+
146
+ expect(safeStorage.encryptString).not.toHaveBeenCalled();
147
+ expect(mockStoreManager.set).toHaveBeenCalledWith(
148
+ 'encryptedTokens',
149
+ expect.objectContaining({
150
+ accessToken: 'access-token',
151
+ refreshToken: 'refresh-token',
152
+ }),
153
+ );
154
+ });
155
+ });
156
+
157
+ describe('getAccessToken', () => {
158
+ it('should return decrypted access token', async () => {
159
+ const { safeStorage } = await import('electron');
160
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
161
+
162
+ // First save a token
163
+ await controller.saveTokens('test-access-token', 'test-refresh-token');
164
+
165
+ const result = await controller.getAccessToken();
166
+
167
+ expect(result).toBe('test-access-token');
168
+ });
169
+
170
+ it('should load token from store if not in memory', async () => {
171
+ const { safeStorage } = await import('electron');
172
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
173
+ vi.mocked(safeStorage.decryptString).mockReturnValue('stored-access-token');
174
+
175
+ mockStoreManager.get.mockImplementation((key) => {
176
+ if (key === 'encryptedTokens') {
177
+ return {
178
+ accessToken: Buffer.from('stored-access-token').toString('base64'),
179
+ refreshToken: Buffer.from('stored-refresh-token').toString('base64'),
180
+ };
181
+ }
182
+ return { active: false, storageMode: 'local' };
183
+ });
184
+
185
+ // Create new controller to test loading from store
186
+ const newController = new RemoteServerConfigCtr(mockApp);
187
+ const result = await newController.getAccessToken();
188
+
189
+ expect(result).toBe('stored-access-token');
190
+ });
191
+
192
+ it('should return null when no token exists', async () => {
193
+ mockStoreManager.get.mockImplementation((key) => {
194
+ if (key === 'encryptedTokens') {
195
+ return null;
196
+ }
197
+ return { active: false, storageMode: 'local' };
198
+ });
199
+
200
+ const newController = new RemoteServerConfigCtr(mockApp);
201
+ const result = await newController.getAccessToken();
202
+
203
+ expect(result).toBeNull();
204
+ });
205
+
206
+ it('should return raw token when encryption is not available', async () => {
207
+ const { safeStorage } = await import('electron');
208
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false);
209
+
210
+ await controller.saveTokens('raw-access-token', 'raw-refresh-token');
211
+ const result = await controller.getAccessToken();
212
+
213
+ expect(result).toBe('raw-access-token');
214
+ });
215
+
216
+ it('should return null on decryption error', async () => {
217
+ const { safeStorage } = await import('electron');
218
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
219
+ vi.mocked(safeStorage.decryptString).mockImplementation(() => {
220
+ throw new Error('Decryption failed');
221
+ });
222
+
223
+ mockStoreManager.get.mockImplementation((key) => {
224
+ if (key === 'encryptedTokens') {
225
+ return {
226
+ accessToken: 'invalid-encrypted-token',
227
+ refreshToken: 'invalid-encrypted-token',
228
+ };
229
+ }
230
+ return { active: false, storageMode: 'local' };
231
+ });
232
+
233
+ const newController = new RemoteServerConfigCtr(mockApp);
234
+ const result = await newController.getAccessToken();
235
+
236
+ expect(result).toBeNull();
237
+ });
238
+ });
239
+
240
+ describe('getRefreshToken', () => {
241
+ it('should return decrypted refresh token', async () => {
242
+ const { safeStorage } = await import('electron');
243
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
244
+ vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
245
+ buffer.toString(),
246
+ );
247
+
248
+ await controller.saveTokens('test-access-token', 'test-refresh-token');
249
+
250
+ const result = await controller.getRefreshToken();
251
+
252
+ expect(result).toBe('test-refresh-token');
253
+ });
254
+
255
+ it('should return null when no token exists', async () => {
256
+ mockStoreManager.get.mockImplementation((key) => {
257
+ if (key === 'encryptedTokens') {
258
+ return null;
259
+ }
260
+ return { active: false, storageMode: 'local' };
261
+ });
262
+
263
+ const newController = new RemoteServerConfigCtr(mockApp);
264
+ const result = await newController.getRefreshToken();
265
+
266
+ expect(result).toBeNull();
267
+ });
268
+ });
269
+
270
+ describe('clearTokens', () => {
271
+ it('should clear all tokens from memory and store', async () => {
272
+ await controller.saveTokens('access', 'refresh', 3600);
273
+ await controller.clearTokens();
274
+
275
+ expect(mockStoreManager.delete).toHaveBeenCalledWith('encryptedTokens');
276
+
277
+ // Verify tokens are cleared from memory
278
+ const accessToken = await controller.getAccessToken();
279
+ expect(accessToken).toBeNull();
280
+ });
281
+ });
282
+
283
+ describe('getTokenExpiresAt', () => {
284
+ it('should return expiration time after saving tokens with expiration', async () => {
285
+ const { safeStorage } = await import('electron');
286
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
287
+
288
+ const beforeSave = Date.now();
289
+ await controller.saveTokens('access', 'refresh', 3600);
290
+ const afterSave = Date.now();
291
+
292
+ const expiresAt = controller.getTokenExpiresAt();
293
+
294
+ expect(expiresAt).toBeDefined();
295
+ expect(expiresAt).toBeGreaterThanOrEqual(beforeSave + 3600 * 1000);
296
+ expect(expiresAt).toBeLessThanOrEqual(afterSave + 3600 * 1000);
297
+ });
298
+
299
+ it('should return undefined when no expiration is set', async () => {
300
+ const { safeStorage } = await import('electron');
301
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
302
+
303
+ await controller.saveTokens('access', 'refresh');
304
+
305
+ const expiresAt = controller.getTokenExpiresAt();
306
+
307
+ expect(expiresAt).toBeUndefined();
308
+ });
309
+ });
310
+
311
+ describe('isTokenExpiringSoon', () => {
312
+ it('should return false when no expiration is set', () => {
313
+ const result = controller.isTokenExpiringSoon();
314
+
315
+ expect(result).toBe(false);
316
+ });
317
+
318
+ it('should return false when token is not expiring soon', async () => {
319
+ const { safeStorage } = await import('electron');
320
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
321
+
322
+ // Token expires in 1 hour
323
+ await controller.saveTokens('access', 'refresh', 3600);
324
+
325
+ // Default buffer is 5 minutes
326
+ const result = controller.isTokenExpiringSoon();
327
+
328
+ expect(result).toBe(false);
329
+ });
330
+
331
+ it('should return true when token is within buffer time', async () => {
332
+ const { safeStorage } = await import('electron');
333
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
334
+
335
+ // Token expires in 2 minutes
336
+ await controller.saveTokens('access', 'refresh', 120);
337
+
338
+ // Default buffer is 5 minutes, so token is expiring soon
339
+ const result = controller.isTokenExpiringSoon();
340
+
341
+ expect(result).toBe(true);
342
+ });
343
+
344
+ it('should respect custom buffer time', async () => {
345
+ const { safeStorage } = await import('electron');
346
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
347
+
348
+ // Token expires in 10 minutes
349
+ await controller.saveTokens('access', 'refresh', 600);
350
+
351
+ // With 15 minute buffer, should be expiring soon
352
+ const result = controller.isTokenExpiringSoon(15 * 60 * 1000);
353
+
354
+ expect(result).toBe(true);
355
+ });
356
+ });
357
+
358
+ describe('refreshAccessToken', () => {
359
+ let mockFetch: ReturnType<typeof vi.fn>;
360
+
361
+ beforeEach(() => {
362
+ mockFetch = vi.fn();
363
+ global.fetch = mockFetch;
364
+ });
365
+
366
+ it('should return error when remote server is not active', async () => {
367
+ mockStoreManager.get.mockImplementation((key) => {
368
+ if (key === 'dataSyncConfig') {
369
+ return { active: false, storageMode: 'local' };
370
+ }
371
+ return null;
372
+ });
373
+
374
+ const result = await controller.refreshAccessToken();
375
+
376
+ expect(result.success).toBe(false);
377
+ expect(result.error).toContain('not active');
378
+ });
379
+
380
+ it('should return error when no refresh token available', async () => {
381
+ mockStoreManager.get.mockImplementation((key) => {
382
+ if (key === 'dataSyncConfig') {
383
+ return {
384
+ active: true,
385
+ remoteServerUrl: 'https://server.com',
386
+ storageMode: 'selfHost',
387
+ };
388
+ }
389
+ if (key === 'encryptedTokens') {
390
+ return null;
391
+ }
392
+ return null;
393
+ });
394
+
395
+ const newController = new RemoteServerConfigCtr(mockApp);
396
+ const result = await newController.refreshAccessToken();
397
+
398
+ expect(result.success).toBe(false);
399
+ expect(result.error).toContain('No refresh token');
400
+ });
401
+
402
+ it('should refresh token successfully', async () => {
403
+ const { safeStorage } = await import('electron');
404
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
405
+ vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
406
+ buffer.toString(),
407
+ );
408
+
409
+ mockStoreManager.get.mockImplementation((key) => {
410
+ if (key === 'dataSyncConfig') {
411
+ return {
412
+ active: true,
413
+ remoteServerUrl: 'https://server.com',
414
+ storageMode: 'selfHost',
415
+ };
416
+ }
417
+ return null;
418
+ });
419
+
420
+ // Save initial tokens
421
+ await controller.saveTokens('old-access', 'old-refresh');
422
+
423
+ mockFetch.mockResolvedValue({
424
+ json: () =>
425
+ Promise.resolve({
426
+ access_token: 'new-access-token',
427
+ expires_in: 3600,
428
+ refresh_token: 'new-refresh-token',
429
+ }),
430
+ ok: true,
431
+ });
432
+
433
+ const result = await controller.refreshAccessToken();
434
+
435
+ expect(result.success).toBe(true);
436
+ expect(mockFetch).toHaveBeenCalledWith(
437
+ 'https://server.com/oidc/token',
438
+ expect.objectContaining({
439
+ body: expect.stringContaining('grant_type=refresh_token'),
440
+ method: 'POST',
441
+ }),
442
+ );
443
+ });
444
+
445
+ it('should handle refresh failure', async () => {
446
+ const { safeStorage } = await import('electron');
447
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
448
+ vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
449
+ buffer.toString(),
450
+ );
451
+
452
+ mockStoreManager.get.mockImplementation((key) => {
453
+ if (key === 'dataSyncConfig') {
454
+ return {
455
+ active: true,
456
+ remoteServerUrl: 'https://server.com',
457
+ storageMode: 'selfHost',
458
+ };
459
+ }
460
+ return null;
461
+ });
462
+
463
+ await controller.saveTokens('old-access', 'old-refresh');
464
+
465
+ mockFetch.mockResolvedValue({
466
+ json: () => Promise.resolve({ error: 'invalid_grant' }),
467
+ ok: false,
468
+ status: 400,
469
+ statusText: 'Bad Request',
470
+ });
471
+
472
+ const result = await controller.refreshAccessToken();
473
+
474
+ expect(result.success).toBe(false);
475
+ expect(result.error).toContain('Token refresh failed');
476
+ });
477
+
478
+ it('should handle missing tokens in response', async () => {
479
+ const { safeStorage } = await import('electron');
480
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
481
+ vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
482
+ buffer.toString(),
483
+ );
484
+
485
+ mockStoreManager.get.mockImplementation((key) => {
486
+ if (key === 'dataSyncConfig') {
487
+ return {
488
+ active: true,
489
+ remoteServerUrl: 'https://server.com',
490
+ storageMode: 'selfHost',
491
+ };
492
+ }
493
+ return null;
494
+ });
495
+
496
+ await controller.saveTokens('old-access', 'old-refresh');
497
+
498
+ mockFetch.mockResolvedValue({
499
+ json: () => Promise.resolve({}), // Missing tokens
500
+ ok: true,
501
+ });
502
+
503
+ const result = await controller.refreshAccessToken();
504
+
505
+ expect(result.success).toBe(false);
506
+ expect(result.error).toContain('Missing tokens');
507
+ });
508
+
509
+ it('should handle concurrent refresh requests by returning same result', async () => {
510
+ const { safeStorage } = await import('electron');
511
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
512
+ vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
513
+ buffer.toString(),
514
+ );
515
+
516
+ mockStoreManager.get.mockImplementation((key) => {
517
+ if (key === 'dataSyncConfig') {
518
+ return {
519
+ active: true,
520
+ remoteServerUrl: 'https://server.com',
521
+ storageMode: 'selfHost',
522
+ };
523
+ }
524
+ return null;
525
+ });
526
+
527
+ await controller.saveTokens('old-access', 'old-refresh');
528
+
529
+ let resolvePromise: (value: any) => void;
530
+ const delayedResponse = new Promise((resolve) => {
531
+ resolvePromise = resolve;
532
+ });
533
+
534
+ mockFetch.mockReturnValue(delayedResponse);
535
+
536
+ // Start two concurrent refresh requests
537
+ const promise1 = controller.refreshAccessToken();
538
+ const promise2 = controller.refreshAccessToken();
539
+
540
+ // Resolve the fetch
541
+ resolvePromise!({
542
+ json: () =>
543
+ Promise.resolve({
544
+ access_token: 'new-access',
545
+ expires_in: 3600,
546
+ refresh_token: 'new-refresh',
547
+ }),
548
+ ok: true,
549
+ });
550
+
551
+ const [result1, result2] = await Promise.all([promise1, promise2]);
552
+
553
+ // Both results should be equal (same success)
554
+ expect(result1.success).toBe(true);
555
+ expect(result2.success).toBe(true);
556
+ expect(mockFetch).toHaveBeenCalledTimes(1);
557
+ });
558
+
559
+ it('should handle network errors', async () => {
560
+ const { safeStorage } = await import('electron');
561
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
562
+ vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
563
+ buffer.toString(),
564
+ );
565
+
566
+ mockStoreManager.get.mockImplementation((key) => {
567
+ if (key === 'dataSyncConfig') {
568
+ return {
569
+ active: true,
570
+ remoteServerUrl: 'https://server.com',
571
+ storageMode: 'selfHost',
572
+ };
573
+ }
574
+ return null;
575
+ });
576
+
577
+ await controller.saveTokens('old-access', 'old-refresh');
578
+
579
+ mockFetch.mockRejectedValue(new Error('Network error'));
580
+
581
+ const result = await controller.refreshAccessToken();
582
+
583
+ expect(result.success).toBe(false);
584
+ expect(result.error).toContain('Network error');
585
+ });
586
+ });
587
+
588
+ describe('afterAppReady', () => {
589
+ it('should load tokens from store', () => {
590
+ mockStoreManager.get.mockImplementation((key) => {
591
+ if (key === 'encryptedTokens') {
592
+ return {
593
+ accessToken: 'stored-access',
594
+ expiresAt: Date.now() + 3600000,
595
+ refreshToken: 'stored-refresh',
596
+ };
597
+ }
598
+ return { active: false, storageMode: 'local' };
599
+ });
600
+
601
+ const newController = new RemoteServerConfigCtr(mockApp);
602
+ newController.afterAppReady();
603
+
604
+ // Verify tokens were loaded by checking getTokenExpiresAt
605
+ expect(newController.getTokenExpiresAt()).toBeDefined();
606
+ });
607
+ });
608
+
609
+ describe('getRemoteServerUrl', () => {
610
+ it('should return official cloud server for cloud mode', async () => {
611
+ mockStoreManager.get.mockReturnValue({
612
+ active: true,
613
+ storageMode: 'cloud',
614
+ });
615
+
616
+ const result = await controller.getRemoteServerUrl();
617
+
618
+ expect(result).toBe('https://cloud.lobehub.com');
619
+ });
620
+
621
+ it('should return custom URL for selfHost mode', async () => {
622
+ mockStoreManager.get.mockReturnValue({
623
+ active: true,
624
+ remoteServerUrl: 'https://my-server.com',
625
+ storageMode: 'selfHost',
626
+ });
627
+
628
+ const result = await controller.getRemoteServerUrl();
629
+
630
+ expect(result).toBe('https://my-server.com');
631
+ });
632
+
633
+ it('should use provided config instead of stored config', async () => {
634
+ const customConfig: DataSyncConfig = {
635
+ active: true,
636
+ remoteServerUrl: 'https://custom-server.com',
637
+ storageMode: 'selfHost',
638
+ };
639
+
640
+ const result = await controller.getRemoteServerUrl(customConfig);
641
+
642
+ expect(result).toBe('https://custom-server.com');
643
+ });
644
+ });
645
+ });