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

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.
@@ -0,0 +1,261 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { oauthHandoffs } from '../../schemas';
5
+ import { LobeChatDatabase } from '../../type';
6
+ import { OAuthHandoffModel } from '../oauthHandoff';
7
+ import { getTestDB } from './_util';
8
+
9
+ const serverDB: LobeChatDatabase = await getTestDB();
10
+ const oauthHandoffModel = new OAuthHandoffModel(serverDB);
11
+
12
+ describe('OAuthHandoffModel', () => {
13
+ beforeEach(async () => {
14
+ await serverDB.delete(oauthHandoffs);
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await serverDB.delete(oauthHandoffs);
19
+ vi.useRealTimers();
20
+ });
21
+
22
+ describe('create', () => {
23
+ it('should create a new OAuth handoff record', async () => {
24
+ const result = await oauthHandoffModel.create({
25
+ id: 'handoff-1',
26
+ client: 'desktop',
27
+ payload: { code: 'auth-code', state: 'state-value' },
28
+ });
29
+
30
+ expect(result).toBeDefined();
31
+ expect(result.id).toBe('handoff-1');
32
+ expect(result.client).toBe('desktop');
33
+ expect(result.payload).toEqual({ code: 'auth-code', state: 'state-value' });
34
+ });
35
+
36
+ it('should handle conflict by doing nothing', async () => {
37
+ // Create first record
38
+ await oauthHandoffModel.create({
39
+ id: 'handoff-1',
40
+ client: 'desktop',
41
+ payload: { code: 'original-code' },
42
+ });
43
+
44
+ // Try to create duplicate - should not throw
45
+ const result = await oauthHandoffModel.create({
46
+ id: 'handoff-1',
47
+ client: 'desktop',
48
+ payload: { code: 'new-code' },
49
+ });
50
+
51
+ // Result is undefined when conflict occurs with onConflictDoNothing
52
+ expect(result).toBeUndefined();
53
+
54
+ // Original should remain unchanged
55
+ const record = await serverDB.query.oauthHandoffs.findFirst({
56
+ where: eq(oauthHandoffs.id, 'handoff-1'),
57
+ });
58
+
59
+ expect(record?.payload).toEqual({ code: 'original-code' });
60
+ });
61
+ });
62
+
63
+ describe('fetchAndConsume', () => {
64
+ it('should fetch and delete valid credentials', async () => {
65
+ await oauthHandoffModel.create({
66
+ id: 'handoff-1',
67
+ client: 'desktop',
68
+ payload: { code: 'auth-code', state: 'state-value' },
69
+ });
70
+
71
+ const result = await oauthHandoffModel.fetchAndConsume('handoff-1', 'desktop');
72
+
73
+ expect(result).toBeDefined();
74
+ expect(result?.id).toBe('handoff-1');
75
+ expect(result?.payload).toEqual({ code: 'auth-code', state: 'state-value' });
76
+
77
+ // Verify it was deleted
78
+ const deleted = await serverDB.query.oauthHandoffs.findFirst({
79
+ where: eq(oauthHandoffs.id, 'handoff-1'),
80
+ });
81
+ expect(deleted).toBeUndefined();
82
+ });
83
+
84
+ it('should return null for non-existent credentials', async () => {
85
+ const result = await oauthHandoffModel.fetchAndConsume('non-existent', 'desktop');
86
+
87
+ expect(result).toBeNull();
88
+ });
89
+
90
+ it('should return null when client type does not match', async () => {
91
+ await oauthHandoffModel.create({
92
+ id: 'handoff-1',
93
+ client: 'desktop',
94
+ payload: { code: 'auth-code' },
95
+ });
96
+
97
+ const result = await oauthHandoffModel.fetchAndConsume('handoff-1', 'browser-extension');
98
+
99
+ expect(result).toBeNull();
100
+
101
+ // Record should still exist
102
+ const record = await serverDB.query.oauthHandoffs.findFirst({
103
+ where: eq(oauthHandoffs.id, 'handoff-1'),
104
+ });
105
+ expect(record).toBeDefined();
106
+ });
107
+
108
+ it('should return null for expired credentials (older than 5 minutes)', async () => {
109
+ // Create a record with old timestamp
110
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
111
+
112
+ await serverDB.insert(oauthHandoffs).values({
113
+ id: 'handoff-expired',
114
+ client: 'desktop',
115
+ payload: { code: 'auth-code' },
116
+ createdAt: sixMinutesAgo,
117
+ updatedAt: sixMinutesAgo,
118
+ });
119
+
120
+ const result = await oauthHandoffModel.fetchAndConsume('handoff-expired', 'desktop');
121
+
122
+ expect(result).toBeNull();
123
+ });
124
+
125
+ it('should return valid credentials within 5 minutes', async () => {
126
+ // Create a record with timestamp 4 minutes ago
127
+ const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000);
128
+
129
+ await serverDB.insert(oauthHandoffs).values({
130
+ id: 'handoff-valid',
131
+ client: 'desktop',
132
+ payload: { code: 'auth-code' },
133
+ createdAt: fourMinutesAgo,
134
+ updatedAt: fourMinutesAgo,
135
+ });
136
+
137
+ const result = await oauthHandoffModel.fetchAndConsume('handoff-valid', 'desktop');
138
+
139
+ expect(result).toBeDefined();
140
+ expect(result?.id).toBe('handoff-valid');
141
+ });
142
+ });
143
+
144
+ describe('cleanupExpired', () => {
145
+ it('should delete expired records', async () => {
146
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
147
+ const now = new Date();
148
+
149
+ // Create expired record
150
+ await serverDB.insert(oauthHandoffs).values({
151
+ id: 'handoff-expired',
152
+ client: 'desktop',
153
+ payload: { code: 'expired-code' },
154
+ createdAt: sixMinutesAgo,
155
+ updatedAt: sixMinutesAgo,
156
+ });
157
+
158
+ // Create valid record
159
+ await serverDB.insert(oauthHandoffs).values({
160
+ id: 'handoff-valid',
161
+ client: 'desktop',
162
+ payload: { code: 'valid-code' },
163
+ createdAt: now,
164
+ updatedAt: now,
165
+ });
166
+
167
+ await oauthHandoffModel.cleanupExpired();
168
+
169
+ // Verify expired record is deleted
170
+ const expiredRecord = await serverDB.query.oauthHandoffs.findFirst({
171
+ where: eq(oauthHandoffs.id, 'handoff-expired'),
172
+ });
173
+ expect(expiredRecord).toBeUndefined();
174
+
175
+ // Verify valid record still exists
176
+ const validRecord = await serverDB.query.oauthHandoffs.findFirst({
177
+ where: eq(oauthHandoffs.id, 'handoff-valid'),
178
+ });
179
+ expect(validRecord).toBeDefined();
180
+ });
181
+
182
+ it('should not throw when no expired records exist', async () => {
183
+ await expect(oauthHandoffModel.cleanupExpired()).resolves.not.toThrow();
184
+ });
185
+
186
+ it('should delete multiple expired records', async () => {
187
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
188
+
189
+ await serverDB.insert(oauthHandoffs).values([
190
+ {
191
+ id: 'handoff-expired-1',
192
+ client: 'desktop',
193
+ payload: { code: 'code-1' },
194
+ createdAt: sixMinutesAgo,
195
+ updatedAt: sixMinutesAgo,
196
+ },
197
+ {
198
+ id: 'handoff-expired-2',
199
+ client: 'browser',
200
+ payload: { code: 'code-2' },
201
+ createdAt: sixMinutesAgo,
202
+ updatedAt: sixMinutesAgo,
203
+ },
204
+ ]);
205
+
206
+ await oauthHandoffModel.cleanupExpired();
207
+
208
+ // Verify all expired records are deleted
209
+ const remainingRecords = await serverDB.query.oauthHandoffs.findMany();
210
+ expect(remainingRecords).toHaveLength(0);
211
+ });
212
+ });
213
+
214
+ describe('exists', () => {
215
+ it('should return true for existing valid credentials', async () => {
216
+ await oauthHandoffModel.create({
217
+ id: 'handoff-1',
218
+ client: 'desktop',
219
+ payload: { code: 'auth-code' },
220
+ });
221
+
222
+ const result = await oauthHandoffModel.exists('handoff-1', 'desktop');
223
+
224
+ expect(result).toBe(true);
225
+ });
226
+
227
+ it('should return false for non-existent credentials', async () => {
228
+ const result = await oauthHandoffModel.exists('non-existent', 'desktop');
229
+
230
+ expect(result).toBe(false);
231
+ });
232
+
233
+ it('should return false when client type does not match', async () => {
234
+ await oauthHandoffModel.create({
235
+ id: 'handoff-1',
236
+ client: 'desktop',
237
+ payload: { code: 'auth-code' },
238
+ });
239
+
240
+ const result = await oauthHandoffModel.exists('handoff-1', 'browser-extension');
241
+
242
+ expect(result).toBe(false);
243
+ });
244
+
245
+ it('should return false for expired credentials', async () => {
246
+ const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000);
247
+
248
+ await serverDB.insert(oauthHandoffs).values({
249
+ id: 'handoff-expired',
250
+ client: 'desktop',
251
+ payload: { code: 'auth-code' },
252
+ createdAt: sixMinutesAgo,
253
+ updatedAt: sixMinutesAgo,
254
+ });
255
+
256
+ const result = await oauthHandoffModel.exists('handoff-expired', 'desktop');
257
+
258
+ expect(result).toBe(false);
259
+ });
260
+ });
261
+ });
@@ -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
+ });