@lobehub/lobehub 2.0.0-next.137 → 2.0.0-next.139

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 (34) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/CLAUDE.md +38 -0
  3. package/changelog/v1.json +14 -0
  4. package/locales/ar/modelProvider.json +2 -0
  5. package/locales/bg-BG/modelProvider.json +2 -0
  6. package/locales/de-DE/modelProvider.json +2 -0
  7. package/locales/en-US/modelProvider.json +2 -0
  8. package/locales/es-ES/modelProvider.json +2 -0
  9. package/locales/fa-IR/modelProvider.json +2 -0
  10. package/locales/fr-FR/modelProvider.json +2 -0
  11. package/locales/it-IT/modelProvider.json +2 -0
  12. package/locales/ja-JP/modelProvider.json +2 -0
  13. package/locales/ko-KR/modelProvider.json +2 -0
  14. package/locales/nl-NL/modelProvider.json +2 -0
  15. package/locales/pl-PL/modelProvider.json +2 -0
  16. package/locales/pt-BR/modelProvider.json +2 -0
  17. package/locales/ru-RU/modelProvider.json +2 -0
  18. package/locales/tr-TR/modelProvider.json +2 -0
  19. package/locales/vi-VN/modelProvider.json +2 -0
  20. package/locales/zh-CN/modelProvider.json +2 -0
  21. package/locales/zh-TW/modelProvider.json +2 -0
  22. package/package.json +1 -1
  23. package/packages/conversation-flow/src/transformation/BranchResolver.ts +24 -14
  24. package/packages/conversation-flow/src/transformation/ContextTreeBuilder.ts +6 -1
  25. package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +15 -0
  26. package/packages/conversation-flow/src/transformation/__tests__/BranchResolver.test.ts +66 -3
  27. package/packages/conversation-flow/src/transformation/__tests__/ContextTreeBuilder.test.ts +64 -0
  28. package/packages/conversation-flow/src/transformation/__tests__/FlatListBuilder.test.ts +47 -0
  29. package/packages/database/src/models/__tests__/agent.test.ts +102 -3
  30. package/packages/database/src/models/__tests__/document.test.ts +163 -0
  31. package/packages/database/src/models/__tests__/embedding.test.ts +294 -0
  32. package/packages/database/src/models/__tests__/oauthHandoff.test.ts +261 -0
  33. package/packages/database/src/models/__tests__/thread.test.ts +327 -0
  34. package/packages/database/src/models/__tests__/user.test.ts +372 -0
@@ -0,0 +1,327 @@
1
+ import { ThreadStatus, ThreadType } from '@lobechat/types';
2
+ import { eq } from 'drizzle-orm';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+
5
+ import { sessions, threads, topics, users } from '../../schemas';
6
+ import { LobeChatDatabase } from '../../type';
7
+ import { ThreadModel } from '../thread';
8
+ import { getTestDB } from './_util';
9
+
10
+ const userId = 'thread-user-test';
11
+ const otherUserId = 'other-user-test';
12
+ const sessionId = 'thread-session';
13
+ const topicId = 'thread-topic';
14
+
15
+ const serverDB: LobeChatDatabase = await getTestDB();
16
+ const threadModel = new ThreadModel(serverDB, userId);
17
+
18
+ describe('ThreadModel', () => {
19
+ beforeEach(async () => {
20
+ await serverDB.delete(users);
21
+
22
+ // Create test users, session and topic
23
+ await serverDB.transaction(async (tx) => {
24
+ await tx.insert(users).values([{ id: userId }, { id: otherUserId }]);
25
+ await tx.insert(sessions).values({ id: sessionId, userId });
26
+ await tx.insert(topics).values({ id: topicId, userId, sessionId });
27
+ });
28
+ });
29
+
30
+ afterEach(async () => {
31
+ await serverDB.delete(users);
32
+ });
33
+
34
+ describe('create', () => {
35
+ it('should create a new thread', async () => {
36
+ const result = await threadModel.create({
37
+ topicId,
38
+ type: ThreadType.Standalone,
39
+ sourceMessageId: 'msg-1',
40
+ });
41
+
42
+ expect(result).toBeDefined();
43
+ expect(result.topicId).toBe(topicId);
44
+ expect(result.type).toBe(ThreadType.Standalone);
45
+ expect(result.status).toBe(ThreadStatus.Active);
46
+ expect(result.sourceMessageId).toBe('msg-1');
47
+ });
48
+
49
+ it('should create a thread with title', async () => {
50
+ const result = await threadModel.create({
51
+ topicId,
52
+ type: ThreadType.Continuation,
53
+ title: 'Test Thread',
54
+ });
55
+
56
+ expect(result.title).toBe('Test Thread');
57
+ expect(result.type).toBe(ThreadType.Continuation);
58
+ });
59
+ });
60
+
61
+ describe('query', () => {
62
+ it('should return all threads for the user', async () => {
63
+ // Create test threads
64
+ await serverDB.insert(threads).values([
65
+ {
66
+ id: 'thread-1',
67
+ topicId,
68
+ type: ThreadType.Standalone,
69
+ status: ThreadStatus.Active,
70
+ userId,
71
+ updatedAt: new Date('2024-01-01'),
72
+ },
73
+ {
74
+ id: 'thread-2',
75
+ topicId,
76
+ type: ThreadType.Continuation,
77
+ status: ThreadStatus.Active,
78
+ userId,
79
+ updatedAt: new Date('2024-01-02'),
80
+ },
81
+ ]);
82
+
83
+ const result = await threadModel.query();
84
+
85
+ expect(result).toHaveLength(2);
86
+ // Should be ordered by updatedAt desc
87
+ expect(result[0].id).toBe('thread-2');
88
+ expect(result[1].id).toBe('thread-1');
89
+ });
90
+
91
+ it('should only return threads for the current user', async () => {
92
+ await serverDB.transaction(async (tx) => {
93
+ await tx.insert(topics).values({ id: 'other-topic', userId: otherUserId });
94
+ await tx.insert(threads).values([
95
+ {
96
+ id: 'thread-1',
97
+ topicId,
98
+ type: ThreadType.Standalone,
99
+ status: ThreadStatus.Active,
100
+ userId,
101
+ },
102
+ {
103
+ id: 'thread-2',
104
+ topicId: 'other-topic',
105
+ type: ThreadType.Standalone,
106
+ status: ThreadStatus.Active,
107
+ userId: otherUserId,
108
+ },
109
+ ]);
110
+ });
111
+
112
+ const result = await threadModel.query();
113
+
114
+ expect(result).toHaveLength(1);
115
+ expect(result[0].id).toBe('thread-1');
116
+ });
117
+ });
118
+
119
+ describe('queryByTopicId', () => {
120
+ it('should return threads for a specific topic', async () => {
121
+ await serverDB.transaction(async (tx) => {
122
+ await tx.insert(topics).values({ id: 'another-topic', userId, sessionId });
123
+ await tx.insert(threads).values([
124
+ {
125
+ id: 'thread-1',
126
+ topicId,
127
+ type: ThreadType.Standalone,
128
+ status: ThreadStatus.Active,
129
+ userId,
130
+ updatedAt: new Date('2024-01-01'),
131
+ },
132
+ {
133
+ id: 'thread-2',
134
+ topicId: 'another-topic',
135
+ type: ThreadType.Standalone,
136
+ status: ThreadStatus.Active,
137
+ userId,
138
+ updatedAt: new Date('2024-01-02'),
139
+ },
140
+ ]);
141
+ });
142
+
143
+ const result = await threadModel.queryByTopicId(topicId);
144
+
145
+ expect(result).toHaveLength(1);
146
+ expect(result[0].id).toBe('thread-1');
147
+ });
148
+
149
+ it('should return empty array when no threads exist for the topic', async () => {
150
+ const result = await threadModel.queryByTopicId('non-existent-topic');
151
+
152
+ expect(result).toHaveLength(0);
153
+ });
154
+ });
155
+
156
+ describe('findById', () => {
157
+ it('should return a thread by id', async () => {
158
+ await serverDB.insert(threads).values({
159
+ id: 'thread-1',
160
+ topicId,
161
+ type: ThreadType.Standalone,
162
+ status: ThreadStatus.Active,
163
+ userId,
164
+ title: 'Test Thread',
165
+ });
166
+
167
+ const result = await threadModel.findById('thread-1');
168
+
169
+ expect(result).toBeDefined();
170
+ expect(result?.id).toBe('thread-1');
171
+ expect(result?.title).toBe('Test Thread');
172
+ });
173
+
174
+ it('should return undefined for non-existent thread', async () => {
175
+ const result = await threadModel.findById('non-existent');
176
+
177
+ expect(result).toBeUndefined();
178
+ });
179
+
180
+ it('should not return thread belonging to another user', async () => {
181
+ await serverDB.transaction(async (tx) => {
182
+ await tx.insert(topics).values({ id: 'other-topic', userId: otherUserId });
183
+ await tx.insert(threads).values({
184
+ id: 'thread-other',
185
+ topicId: 'other-topic',
186
+ type: ThreadType.Standalone,
187
+ status: ThreadStatus.Active,
188
+ userId: otherUserId,
189
+ });
190
+ });
191
+
192
+ const result = await threadModel.findById('thread-other');
193
+
194
+ expect(result).toBeUndefined();
195
+ });
196
+ });
197
+
198
+ describe('update', () => {
199
+ it('should update a thread', async () => {
200
+ await serverDB.insert(threads).values({
201
+ id: 'thread-1',
202
+ topicId,
203
+ type: ThreadType.Standalone,
204
+ status: ThreadStatus.Active,
205
+ userId,
206
+ title: 'Original Title',
207
+ });
208
+
209
+ await threadModel.update('thread-1', {
210
+ title: 'Updated Title',
211
+ status: ThreadStatus.Archived,
212
+ });
213
+
214
+ const updated = await serverDB.query.threads.findFirst({
215
+ where: eq(threads.id, 'thread-1'),
216
+ });
217
+
218
+ expect(updated?.title).toBe('Updated Title');
219
+ expect(updated?.status).toBe(ThreadStatus.Archived);
220
+ });
221
+
222
+ it('should not update thread belonging to another user', async () => {
223
+ await serverDB.transaction(async (tx) => {
224
+ await tx.insert(topics).values({ id: 'other-topic', userId: otherUserId });
225
+ await tx.insert(threads).values({
226
+ id: 'thread-other',
227
+ topicId: 'other-topic',
228
+ type: ThreadType.Standalone,
229
+ status: ThreadStatus.Active,
230
+ userId: otherUserId,
231
+ title: 'Original Title',
232
+ });
233
+ });
234
+
235
+ await threadModel.update('thread-other', { title: 'Hacked Title' });
236
+
237
+ const unchanged = await serverDB.query.threads.findFirst({
238
+ where: eq(threads.id, 'thread-other'),
239
+ });
240
+
241
+ expect(unchanged?.title).toBe('Original Title');
242
+ });
243
+ });
244
+
245
+ describe('delete', () => {
246
+ it('should delete a thread', async () => {
247
+ await serverDB.insert(threads).values({
248
+ id: 'thread-1',
249
+ topicId,
250
+ type: ThreadType.Standalone,
251
+ status: ThreadStatus.Active,
252
+ userId,
253
+ });
254
+
255
+ await threadModel.delete('thread-1');
256
+
257
+ const deleted = await serverDB.query.threads.findFirst({
258
+ where: eq(threads.id, 'thread-1'),
259
+ });
260
+
261
+ expect(deleted).toBeUndefined();
262
+ });
263
+
264
+ it('should not delete thread belonging to another user', async () => {
265
+ await serverDB.transaction(async (tx) => {
266
+ await tx.insert(topics).values({ id: 'other-topic', userId: otherUserId });
267
+ await tx.insert(threads).values({
268
+ id: 'thread-other',
269
+ topicId: 'other-topic',
270
+ type: ThreadType.Standalone,
271
+ status: ThreadStatus.Active,
272
+ userId: otherUserId,
273
+ });
274
+ });
275
+
276
+ await threadModel.delete('thread-other');
277
+
278
+ const stillExists = await serverDB.query.threads.findFirst({
279
+ where: eq(threads.id, 'thread-other'),
280
+ });
281
+
282
+ expect(stillExists).toBeDefined();
283
+ });
284
+ });
285
+
286
+ describe('deleteAll', () => {
287
+ it('should delete all threads for the current user', async () => {
288
+ await serverDB.transaction(async (tx) => {
289
+ await tx.insert(topics).values({ id: 'other-topic', userId: otherUserId });
290
+ await tx.insert(threads).values([
291
+ {
292
+ id: 'thread-1',
293
+ topicId,
294
+ type: ThreadType.Standalone,
295
+ status: ThreadStatus.Active,
296
+ userId,
297
+ },
298
+ {
299
+ id: 'thread-2',
300
+ topicId,
301
+ type: ThreadType.Continuation,
302
+ status: ThreadStatus.Active,
303
+ userId,
304
+ },
305
+ {
306
+ id: 'thread-3',
307
+ topicId: 'other-topic',
308
+ type: ThreadType.Standalone,
309
+ status: ThreadStatus.Active,
310
+ userId: otherUserId,
311
+ },
312
+ ]);
313
+ });
314
+
315
+ await threadModel.deleteAll();
316
+
317
+ const userThreads = await serverDB.select().from(threads).where(eq(threads.userId, userId));
318
+ const otherUserThreads = await serverDB
319
+ .select()
320
+ .from(threads)
321
+ .where(eq(threads.userId, otherUserId));
322
+
323
+ expect(userThreads).toHaveLength(0);
324
+ expect(otherUserThreads).toHaveLength(1);
325
+ });
326
+ });
327
+ });
@@ -0,0 +1,372 @@
1
+ import { UserPreference } from '@lobechat/types';
2
+ import { eq } from 'drizzle-orm';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { nextauthAccounts, userSettings, users } from '../../schemas';
6
+ import { LobeChatDatabase } from '../../type';
7
+ import { UserModel, UserNotFoundError } from '../user';
8
+ import { getTestDB } from './_util';
9
+
10
+ const userId = 'user-model-test';
11
+ const otherUserId = 'other-user-test';
12
+
13
+ const serverDB: LobeChatDatabase = await getTestDB();
14
+ const userModel = new UserModel(serverDB, userId);
15
+
16
+ // Mock decryptor function
17
+ const mockDecryptor = vi.fn().mockResolvedValue({});
18
+
19
+ describe('UserModel', () => {
20
+ beforeEach(async () => {
21
+ await serverDB.delete(users);
22
+ await serverDB.insert(users).values([
23
+ { id: userId, email: 'test@example.com', fullName: 'Test User' },
24
+ { id: otherUserId, email: 'other@example.com' },
25
+ ]);
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await serverDB.delete(users);
30
+ vi.clearAllMocks();
31
+ });
32
+
33
+ describe('getUserRegistrationDuration', () => {
34
+ it('should return registration duration for existing user', async () => {
35
+ const thirtyDaysAgo = new Date();
36
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
37
+
38
+ await serverDB.update(users).set({ createdAt: thirtyDaysAgo }).where(eq(users.id, userId));
39
+
40
+ const result = await userModel.getUserRegistrationDuration();
41
+
42
+ expect(result.duration).toBeGreaterThanOrEqual(30);
43
+ expect(result.createdAt).toBeDefined();
44
+ expect(result.updatedAt).toBeDefined();
45
+ });
46
+
47
+ it('should return default duration for non-existent user', async () => {
48
+ const nonExistentUserModel = new UserModel(serverDB, 'non-existent');
49
+
50
+ const result = await nonExistentUserModel.getUserRegistrationDuration();
51
+
52
+ expect(result.duration).toBe(1);
53
+ });
54
+ });
55
+
56
+ describe('getUserState', () => {
57
+ it('should return user state with settings', async () => {
58
+ // Create user settings
59
+ await serverDB.insert(userSettings).values({
60
+ id: userId,
61
+ general: { fontSize: 14 },
62
+ tts: { voice: 'default' },
63
+ });
64
+
65
+ const result = await userModel.getUserState(mockDecryptor);
66
+
67
+ expect(result.userId).toBe(userId);
68
+ expect(result.email).toBe('test@example.com');
69
+ expect(result.fullName).toBe('Test User');
70
+ expect(result.settings.general).toEqual({ fontSize: 14 });
71
+ expect(result.settings.tts).toEqual({ voice: 'default' });
72
+ });
73
+
74
+ it('should throw UserNotFoundError for non-existent user', async () => {
75
+ const nonExistentUserModel = new UserModel(serverDB, 'non-existent');
76
+
77
+ await expect(nonExistentUserModel.getUserState(mockDecryptor)).rejects.toThrow(
78
+ UserNotFoundError,
79
+ );
80
+ });
81
+
82
+ it('should handle decryptor errors gracefully', async () => {
83
+ await serverDB.insert(userSettings).values({
84
+ id: userId,
85
+ keyVaults: 'encrypted-data',
86
+ });
87
+
88
+ const failingDecryptor = vi.fn().mockRejectedValue(new Error('Decryption failed'));
89
+
90
+ const result = await userModel.getUserState(failingDecryptor);
91
+
92
+ expect(result.settings.keyVaults).toEqual({});
93
+ });
94
+ });
95
+
96
+ describe('getUserSSOProviders', () => {
97
+ it('should return SSO providers for user', async () => {
98
+ await serverDB.insert(nextauthAccounts).values({
99
+ userId,
100
+ provider: 'google',
101
+ providerAccountId: 'google-123',
102
+ type: 'oauth' as any,
103
+ });
104
+
105
+ const result = await userModel.getUserSSOProviders();
106
+
107
+ expect(result).toHaveLength(1);
108
+ expect(result[0].provider).toBe('google');
109
+ expect(result[0].providerAccountId).toBe('google-123');
110
+ });
111
+
112
+ it('should return empty array when no SSO providers', async () => {
113
+ const result = await userModel.getUserSSOProviders();
114
+
115
+ expect(result).toHaveLength(0);
116
+ });
117
+ });
118
+
119
+ describe('getUserSettings', () => {
120
+ it('should return user settings', async () => {
121
+ await serverDB.insert(userSettings).values({
122
+ id: userId,
123
+ general: { fontSize: 14 },
124
+ });
125
+
126
+ const result = await userModel.getUserSettings();
127
+
128
+ expect(result).toBeDefined();
129
+ expect(result?.general).toEqual({ fontSize: 14 });
130
+ });
131
+
132
+ it('should return undefined when no settings exist', async () => {
133
+ const result = await userModel.getUserSettings();
134
+
135
+ expect(result).toBeUndefined();
136
+ });
137
+ });
138
+
139
+ describe('updateUser', () => {
140
+ it('should update user properties', async () => {
141
+ await userModel.updateUser({
142
+ fullName: 'Updated Name',
143
+ avatar: 'https://example.com/avatar.jpg',
144
+ });
145
+
146
+ const updated = await serverDB.query.users.findFirst({
147
+ where: eq(users.id, userId),
148
+ });
149
+
150
+ expect(updated?.fullName).toBe('Updated Name');
151
+ expect(updated?.avatar).toBe('https://example.com/avatar.jpg');
152
+ });
153
+ });
154
+
155
+ describe('deleteSetting', () => {
156
+ it('should delete user settings', async () => {
157
+ await serverDB.insert(userSettings).values({
158
+ id: userId,
159
+ general: { fontSize: 14 },
160
+ });
161
+
162
+ await userModel.deleteSetting();
163
+
164
+ const settings = await serverDB.query.userSettings.findFirst({
165
+ where: eq(userSettings.id, userId),
166
+ });
167
+
168
+ expect(settings).toBeUndefined();
169
+ });
170
+ });
171
+
172
+ describe('updateSetting', () => {
173
+ it('should create settings if not exist', async () => {
174
+ await userModel.updateSetting({
175
+ general: { fontSize: 16 },
176
+ });
177
+
178
+ const settings = await serverDB.query.userSettings.findFirst({
179
+ where: eq(userSettings.id, userId),
180
+ });
181
+
182
+ expect(settings?.general).toEqual({ fontSize: 16 });
183
+ });
184
+
185
+ it('should update existing settings', async () => {
186
+ await serverDB.insert(userSettings).values({
187
+ id: userId,
188
+ general: { fontSize: 14 },
189
+ });
190
+
191
+ await userModel.updateSetting({
192
+ general: { fontSize: 18 },
193
+ });
194
+
195
+ const settings = await serverDB.query.userSettings.findFirst({
196
+ where: eq(userSettings.id, userId),
197
+ });
198
+
199
+ expect(settings?.general).toEqual({ fontSize: 18 });
200
+ });
201
+ });
202
+
203
+ describe('updatePreference', () => {
204
+ it('should update user preference', async () => {
205
+ await userModel.updatePreference({
206
+ telemetry: false,
207
+ });
208
+
209
+ const user = await serverDB.query.users.findFirst({
210
+ where: eq(users.id, userId),
211
+ });
212
+
213
+ expect((user?.preference as UserPreference)?.telemetry).toBe(false);
214
+ });
215
+
216
+ it('should merge with existing preference', async () => {
217
+ await serverDB
218
+ .update(users)
219
+ .set({ preference: { telemetry: true, useCmdEnterToSend: true } })
220
+ .where(eq(users.id, userId));
221
+
222
+ await userModel.updatePreference({
223
+ telemetry: false,
224
+ });
225
+
226
+ const user = await serverDB.query.users.findFirst({
227
+ where: eq(users.id, userId),
228
+ });
229
+
230
+ const preference = user?.preference as UserPreference;
231
+ expect(preference?.telemetry).toBe(false);
232
+ expect(preference?.useCmdEnterToSend).toBe(true);
233
+ });
234
+
235
+ it('should do nothing for non-existent user', async () => {
236
+ const nonExistentUserModel = new UserModel(serverDB, 'non-existent');
237
+
238
+ await expect(
239
+ nonExistentUserModel.updatePreference({ telemetry: false }),
240
+ ).resolves.toBeUndefined();
241
+ });
242
+ });
243
+
244
+ describe('updateGuide', () => {
245
+ it('should update user guide preference', async () => {
246
+ await userModel.updateGuide({
247
+ moveSettingsToAvatar: true,
248
+ });
249
+
250
+ const user = await serverDB.query.users.findFirst({
251
+ where: eq(users.id, userId),
252
+ });
253
+
254
+ const preference = user?.preference as UserPreference;
255
+ expect(preference?.guide?.moveSettingsToAvatar).toBe(true);
256
+ });
257
+
258
+ it('should do nothing for non-existent user', async () => {
259
+ const nonExistentUserModel = new UserModel(serverDB, 'non-existent');
260
+
261
+ await expect(
262
+ nonExistentUserModel.updateGuide({ moveSettingsToAvatar: true }),
263
+ ).resolves.toBeUndefined();
264
+ });
265
+ });
266
+
267
+ describe('static methods', () => {
268
+ describe('makeSureUserExist', () => {
269
+ it('should create user if not exists', async () => {
270
+ await UserModel.makeSureUserExist(serverDB, 'new-user-id');
271
+
272
+ const user = await serverDB.query.users.findFirst({
273
+ where: eq(users.id, 'new-user-id'),
274
+ });
275
+
276
+ expect(user).toBeDefined();
277
+ });
278
+
279
+ it('should not throw if user already exists', async () => {
280
+ await expect(UserModel.makeSureUserExist(serverDB, userId)).resolves.not.toThrow();
281
+ });
282
+ });
283
+
284
+ describe('createUser', () => {
285
+ it('should create a new user', async () => {
286
+ const result = await UserModel.createUser(serverDB, {
287
+ id: 'brand-new-user',
288
+ email: 'new@example.com',
289
+ });
290
+
291
+ expect(result.duplicate).toBe(false);
292
+ expect(result.user?.id).toBe('brand-new-user');
293
+ expect(result.user?.email).toBe('new@example.com');
294
+ });
295
+
296
+ it('should return duplicate flag for existing user', async () => {
297
+ const result = await UserModel.createUser(serverDB, {
298
+ id: userId,
299
+ email: 'duplicate@example.com',
300
+ });
301
+
302
+ expect(result.duplicate).toBe(true);
303
+ });
304
+ });
305
+
306
+ describe('deleteUser', () => {
307
+ it('should delete a user', async () => {
308
+ await UserModel.deleteUser(serverDB, userId);
309
+
310
+ const user = await serverDB.query.users.findFirst({
311
+ where: eq(users.id, userId),
312
+ });
313
+
314
+ expect(user).toBeUndefined();
315
+ });
316
+ });
317
+
318
+ describe('findById', () => {
319
+ it('should find user by id', async () => {
320
+ const user = await UserModel.findById(serverDB, userId);
321
+
322
+ expect(user).toBeDefined();
323
+ expect(user?.email).toBe('test@example.com');
324
+ });
325
+
326
+ it('should return undefined for non-existent user', async () => {
327
+ const user = await UserModel.findById(serverDB, 'non-existent');
328
+
329
+ expect(user).toBeUndefined();
330
+ });
331
+ });
332
+
333
+ describe('findByEmail', () => {
334
+ it('should find user by email', async () => {
335
+ const user = await UserModel.findByEmail(serverDB, 'test@example.com');
336
+
337
+ expect(user).toBeDefined();
338
+ expect(user?.id).toBe(userId);
339
+ });
340
+
341
+ it('should return undefined for non-existent email', async () => {
342
+ const user = await UserModel.findByEmail(serverDB, 'nonexistent@example.com');
343
+
344
+ expect(user).toBeUndefined();
345
+ });
346
+ });
347
+
348
+ describe('getUserApiKeys', () => {
349
+ it('should return decrypted API keys', async () => {
350
+ await serverDB.insert(userSettings).values({
351
+ id: userId,
352
+ keyVaults: 'encrypted-keys',
353
+ });
354
+
355
+ const decryptor = vi.fn().mockResolvedValue({
356
+ openai: 'sk-xxx',
357
+ });
358
+
359
+ const result = await UserModel.getUserApiKeys(serverDB, userId, decryptor);
360
+
361
+ expect(decryptor).toHaveBeenCalledWith('encrypted-keys', userId);
362
+ expect(result).toEqual({ openai: 'sk-xxx' });
363
+ });
364
+
365
+ it('should throw UserNotFoundError when settings not found', async () => {
366
+ await expect(
367
+ UserModel.getUserApiKeys(serverDB, 'non-existent', mockDecryptor),
368
+ ).rejects.toThrow(UserNotFoundError);
369
+ });
370
+ });
371
+ });
372
+ });