This repository has no description
1import {
2 PostgreSqlContainer,
3 StartedPostgreSqlContainer,
4} from '@testcontainers/postgresql';
5import postgres from 'postgres';
6import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
7import { DrizzleCardQueryRepository } from '../../infrastructure/repositories/DrizzleCardQueryRepository';
8import { DrizzleCardRepository } from '../../infrastructure/repositories/DrizzleCardRepository';
9import { CuratorId } from '../../domain/value-objects/CuratorId';
10import { cards } from '../../infrastructure/repositories/schema/card.sql';
11import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql';
12import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql';
13import { CardBuilder } from '../utils/builders/CardBuilder';
14import { URL } from '../../domain/value-objects/URL';
15import { UrlMetadata } from '../../domain/value-objects/UrlMetadata';
16import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository';
17import { createTestSchema } from '../test-utils/createTestSchema';
18import { UrlType } from '../../domain/value-objects/UrlType';
19
20describe('DrizzleCardQueryRepository - searchUrls', () => {
21 let container: StartedPostgreSqlContainer;
22 let db: PostgresJsDatabase;
23 let queryRepository: DrizzleCardQueryRepository;
24 let cardRepository: DrizzleCardRepository;
25
26 // Test data
27 let curator1: CuratorId;
28 let curator2: CuratorId;
29 let curator3: CuratorId;
30
31 // Setup before all tests
32 beforeAll(async () => {
33 // Start PostgreSQL container
34 container = await new PostgreSqlContainer('postgres:14').start();
35
36 // Create database connection
37 const connectionString = container.getConnectionUri();
38 process.env.DATABASE_URL = connectionString;
39 const client = postgres(connectionString);
40 db = drizzle(client);
41
42 // Create repositories
43 queryRepository = new DrizzleCardQueryRepository(db);
44 cardRepository = new DrizzleCardRepository(db);
45
46 // Create schema using helper function
47 await createTestSchema(db);
48
49 // Create test data
50 curator1 = CuratorId.create('did:plc:curator1').unwrap();
51 curator2 = CuratorId.create('did:plc:curator2').unwrap();
52 curator3 = CuratorId.create('did:plc:curator3').unwrap();
53 }, 60000); // Increase timeout for container startup
54
55 // Cleanup after all tests
56 afterAll(async () => {
57 // Stop container
58 await container.stop();
59 });
60
61 // Clear data between tests
62 beforeEach(async () => {
63 await db.delete(libraryMemberships);
64 await db.delete(cards);
65 await db.delete(publishedRecords);
66 });
67
68 describe('Basic search functionality', () => {
69 it('should find URLs matching search query in title', async () => {
70 const url1 = URL.create('https://example.com/quantum').unwrap();
71 const metadata1 = UrlMetadata.create({
72 url: url1.value,
73 title: 'Introduction to Quantum Computing',
74 description: 'A comprehensive guide',
75 }).unwrap();
76
77 const url2 = URL.create('https://example.com/classical').unwrap();
78 const metadata2 = UrlMetadata.create({
79 url: url2.value,
80 title: 'Classical Computing Basics',
81 description: 'Learn the fundamentals',
82 }).unwrap();
83
84 const card1 = new CardBuilder()
85 .withCuratorId(curator1.value)
86 .withUrlCard(url1, metadata1)
87 .buildOrThrow();
88
89 const card2 = new CardBuilder()
90 .withCuratorId(curator1.value)
91 .withUrlCard(url2, metadata2)
92 .buildOrThrow();
93
94 card1.addToLibrary(curator1);
95 card2.addToLibrary(curator1);
96
97 await cardRepository.save(card1);
98 await cardRepository.save(card2);
99
100 const result = await queryRepository.searchUrls({
101 searchQuery: 'quantum',
102 page: 1,
103 limit: 10,
104 sortBy: CardSortField.UPDATED_AT,
105 sortOrder: SortOrder.DESC,
106 });
107
108 expect(result.items).toHaveLength(1);
109 expect(result.items[0]!.url).toBe(url1.value);
110 expect(result.items[0]!.contentData.metadata.title).toBe(
111 'Introduction to Quantum Computing',
112 );
113 expect(result.totalCount).toBe(1);
114 });
115
116 it('should find URLs matching search query in description', async () => {
117 const url1 = URL.create('https://example.com/article1').unwrap();
118 const metadata1 = UrlMetadata.create({
119 url: url1.value,
120 title: 'Article One',
121 description: 'This discusses machine learning algorithms',
122 }).unwrap();
123
124 const url2 = URL.create('https://example.com/article2').unwrap();
125 const metadata2 = UrlMetadata.create({
126 url: url2.value,
127 title: 'Article Two',
128 description: 'A different topic entirely',
129 }).unwrap();
130
131 const card1 = new CardBuilder()
132 .withCuratorId(curator1.value)
133 .withUrlCard(url1, metadata1)
134 .buildOrThrow();
135
136 const card2 = new CardBuilder()
137 .withCuratorId(curator1.value)
138 .withUrlCard(url2, metadata2)
139 .buildOrThrow();
140
141 card1.addToLibrary(curator1);
142 card2.addToLibrary(curator1);
143
144 await cardRepository.save(card1);
145 await cardRepository.save(card2);
146
147 const result = await queryRepository.searchUrls({
148 searchQuery: 'machine learning',
149 page: 1,
150 limit: 10,
151 sortBy: CardSortField.UPDATED_AT,
152 sortOrder: SortOrder.DESC,
153 });
154
155 expect(result.items).toHaveLength(1);
156 expect(result.items[0]!.url).toBe(url1.value);
157 });
158
159 it('should find URLs matching search query in URL field', async () => {
160 const url1 = URL.create('https://github.com/example/repo').unwrap();
161 const metadata1 = UrlMetadata.create({
162 url: url1.value,
163 title: 'Example Repository',
164 description: 'A code repository',
165 }).unwrap();
166
167 const url2 = URL.create('https://gitlab.com/example/repo').unwrap();
168 const metadata2 = UrlMetadata.create({
169 url: url2.value,
170 title: 'Another Repository',
171 description: 'Another code repository',
172 }).unwrap();
173
174 const card1 = new CardBuilder()
175 .withCuratorId(curator1.value)
176 .withUrlCard(url1, metadata1)
177 .buildOrThrow();
178
179 const card2 = new CardBuilder()
180 .withCuratorId(curator1.value)
181 .withUrlCard(url2, metadata2)
182 .buildOrThrow();
183
184 card1.addToLibrary(curator1);
185 card2.addToLibrary(curator1);
186
187 await cardRepository.save(card1);
188 await cardRepository.save(card2);
189
190 const result = await queryRepository.searchUrls({
191 searchQuery: 'github.com',
192 page: 1,
193 limit: 10,
194 sortBy: CardSortField.UPDATED_AT,
195 sortOrder: SortOrder.DESC,
196 });
197
198 expect(result.items).toHaveLength(1);
199 expect(result.items[0]!.url).toBe(url1.value);
200 });
201 });
202
203 describe('Tokenized substring search', () => {
204 it('should match all words in query across different fields', async () => {
205 const url1 = URL.create('https://example.com/article1').unwrap();
206 const metadata1 = UrlMetadata.create({
207 url: url1.value,
208 title: 'Machine Learning Fundamentals',
209 description: 'Introduction to neural networks',
210 }).unwrap();
211
212 const url2 = URL.create('https://example.com/article2').unwrap();
213 const metadata2 = UrlMetadata.create({
214 url: url2.value,
215 title: 'Deep Learning Guide',
216 description: 'Advanced machine learning techniques',
217 }).unwrap();
218
219 const card1 = new CardBuilder()
220 .withCuratorId(curator1.value)
221 .withUrlCard(url1, metadata1)
222 .buildOrThrow();
223
224 const card2 = new CardBuilder()
225 .withCuratorId(curator1.value)
226 .withUrlCard(url2, metadata2)
227 .buildOrThrow();
228
229 card1.addToLibrary(curator1);
230 card2.addToLibrary(curator1);
231
232 await cardRepository.save(card1);
233 await cardRepository.save(card2);
234
235 // Search for "machine neural" - should only match card1
236 const result = await queryRepository.searchUrls({
237 searchQuery: 'machine neural',
238 page: 1,
239 limit: 10,
240 sortBy: CardSortField.UPDATED_AT,
241 sortOrder: SortOrder.DESC,
242 });
243
244 expect(result.items).toHaveLength(1);
245 expect(result.items[0]!.url).toBe(url1.value);
246 });
247
248 it('should use substring matching, not full word matching', async () => {
249 const url1 = URL.create('https://example.com/article').unwrap();
250 const metadata1 = UrlMetadata.create({
251 url: url1.value,
252 title: 'Understanding Programming',
253 description: 'A guide',
254 }).unwrap();
255
256 const card1 = new CardBuilder()
257 .withCuratorId(curator1.value)
258 .withUrlCard(url1, metadata1)
259 .buildOrThrow();
260
261 card1.addToLibrary(curator1);
262 await cardRepository.save(card1);
263
264 // Search for "program" - should match "Programming" via substring
265 const result = await queryRepository.searchUrls({
266 searchQuery: 'program',
267 page: 1,
268 limit: 10,
269 sortBy: CardSortField.UPDATED_AT,
270 sortOrder: SortOrder.DESC,
271 });
272
273 expect(result.items).toHaveLength(1);
274 expect(result.items[0]!.url).toBe(url1.value);
275 });
276 });
277
278 describe('URL deduplication', () => {
279 it('should deduplicate same URL from different users and return most recent', async () => {
280 const sharedUrl = URL.create('https://example.com/shared').unwrap();
281
282 // Curator 1 creates card first (older)
283 const metadata1 = UrlMetadata.create({
284 url: sharedUrl.value,
285 title: 'Shared Article',
286 description: 'Version 1',
287 }).unwrap();
288
289 const card1 = new CardBuilder()
290 .withCuratorId(curator1.value)
291 .withUrlCard(sharedUrl, metadata1)
292 .withCreatedAt(new Date('2023-01-01'))
293 .withUpdatedAt(new Date('2023-01-01'))
294 .buildOrThrow();
295
296 card1.addToLibrary(curator1);
297 await cardRepository.save(card1);
298
299 // Wait a bit to ensure different timestamp
300 await new Promise((resolve) => setTimeout(resolve, 10));
301
302 // Curator 2 creates card with same URL (newer)
303 const metadata2 = UrlMetadata.create({
304 url: sharedUrl.value,
305 title: 'Shared Article',
306 description: 'Version 2 - Updated',
307 }).unwrap();
308
309 const card2 = new CardBuilder()
310 .withCuratorId(curator2.value)
311 .withUrlCard(sharedUrl, metadata2)
312 .withCreatedAt(new Date('2023-01-02'))
313 .withUpdatedAt(new Date('2023-01-02'))
314 .buildOrThrow();
315
316 card2.addToLibrary(curator2);
317 await cardRepository.save(card2);
318
319 const result = await queryRepository.searchUrls({
320 searchQuery: 'shared',
321 page: 1,
322 limit: 10,
323 sortBy: CardSortField.UPDATED_AT,
324 sortOrder: SortOrder.DESC,
325 });
326
327 // Should return only one URL (deduplicated)
328 expect(result.items).toHaveLength(1);
329 expect(result.items[0]!.url).toBe(sharedUrl.value);
330 // Should have the newer metadata
331 expect(result.items[0]!.contentData.metadata.description).toBe(
332 'Version 2 - Updated',
333 );
334 expect(result.totalCount).toBe(1);
335 });
336 });
337
338 describe('URL type filtering', () => {
339 it('should filter by URL type when specified', async () => {
340 const articleUrl = URL.create('https://example.com/article').unwrap();
341 const articleMetadata = UrlMetadata.create({
342 url: articleUrl.value,
343 title: 'Test Article',
344 description: 'An article',
345 type: UrlType.ARTICLE,
346 }).unwrap();
347
348 const videoUrl = URL.create('https://example.com/video').unwrap();
349 const videoMetadata = UrlMetadata.create({
350 url: videoUrl.value,
351 title: 'Test Video',
352 description: 'A video',
353 type: UrlType.VIDEO,
354 }).unwrap();
355
356 const articleCard = new CardBuilder()
357 .withCuratorId(curator1.value)
358 .withUrlCard(articleUrl, articleMetadata)
359 .buildOrThrow();
360
361 const videoCard = new CardBuilder()
362 .withCuratorId(curator1.value)
363 .withUrlCard(videoUrl, videoMetadata)
364 .buildOrThrow();
365
366 articleCard.addToLibrary(curator1);
367 videoCard.addToLibrary(curator1);
368
369 await cardRepository.save(articleCard);
370 await cardRepository.save(videoCard);
371
372 // Search for articles only
373 const articleResult = await queryRepository.searchUrls({
374 searchQuery: 'test',
375 page: 1,
376 limit: 10,
377 sortBy: CardSortField.UPDATED_AT,
378 sortOrder: SortOrder.DESC,
379 urlType: UrlType.ARTICLE,
380 });
381
382 expect(articleResult.items).toHaveLength(1);
383 expect(articleResult.items[0]!.url).toBe(articleUrl.value);
384
385 // Search for videos only
386 const videoResult = await queryRepository.searchUrls({
387 searchQuery: 'test',
388 page: 1,
389 limit: 10,
390 sortBy: CardSortField.UPDATED_AT,
391 sortOrder: SortOrder.DESC,
392 urlType: UrlType.VIDEO,
393 });
394
395 expect(videoResult.items).toHaveLength(1);
396 expect(videoResult.items[0]!.url).toBe(videoUrl.value);
397 });
398 });
399
400 describe('Pagination', () => {
401 it('should paginate results correctly', async () => {
402 // Create 5 cards
403 for (let i = 1; i <= 5; i++) {
404 const url = URL.create(`https://example.com/article${i}`).unwrap();
405 const metadata = UrlMetadata.create({
406 url: url.value,
407 title: `Article ${i}`,
408 description: 'Test article',
409 }).unwrap();
410
411 const card = new CardBuilder()
412 .withCuratorId(curator1.value)
413 .withUrlCard(url, metadata)
414 .buildOrThrow();
415
416 card.addToLibrary(curator1);
417 await cardRepository.save(card);
418 }
419
420 // First page with limit 2
421 const page1 = await queryRepository.searchUrls({
422 searchQuery: 'article',
423 page: 1,
424 limit: 2,
425 sortBy: CardSortField.UPDATED_AT,
426 sortOrder: SortOrder.DESC,
427 });
428
429 expect(page1.items).toHaveLength(2);
430 expect(page1.totalCount).toBe(5);
431 expect(page1.hasMore).toBe(true);
432
433 // Second page
434 const page2 = await queryRepository.searchUrls({
435 searchQuery: 'article',
436 page: 2,
437 limit: 2,
438 sortBy: CardSortField.UPDATED_AT,
439 sortOrder: SortOrder.DESC,
440 });
441
442 expect(page2.items).toHaveLength(2);
443 expect(page2.totalCount).toBe(5);
444 expect(page2.hasMore).toBe(true);
445
446 // Third page (last page with 1 item)
447 const page3 = await queryRepository.searchUrls({
448 searchQuery: 'article',
449 page: 3,
450 limit: 2,
451 sortBy: CardSortField.UPDATED_AT,
452 sortOrder: SortOrder.DESC,
453 });
454
455 expect(page3.items).toHaveLength(1);
456 expect(page3.totalCount).toBe(5);
457 expect(page3.hasMore).toBe(false);
458 });
459 });
460
461 describe('Empty results', () => {
462 it('should return empty results when no matches found', async () => {
463 const url1 = URL.create('https://example.com/article').unwrap();
464 const metadata1 = UrlMetadata.create({
465 url: url1.value,
466 title: 'Test Article',
467 description: 'A test',
468 }).unwrap();
469
470 const card1 = new CardBuilder()
471 .withCuratorId(curator1.value)
472 .withUrlCard(url1, metadata1)
473 .buildOrThrow();
474
475 card1.addToLibrary(curator1);
476 await cardRepository.save(card1);
477
478 const result = await queryRepository.searchUrls({
479 searchQuery: 'nonexistent',
480 page: 1,
481 limit: 10,
482 sortBy: CardSortField.UPDATED_AT,
483 sortOrder: SortOrder.DESC,
484 });
485
486 expect(result.items).toHaveLength(0);
487 expect(result.totalCount).toBe(0);
488 expect(result.hasMore).toBe(false);
489 });
490
491 it('should return empty results when no cards exist', async () => {
492 const result = await queryRepository.searchUrls({
493 searchQuery: 'anything',
494 page: 1,
495 limit: 10,
496 sortBy: CardSortField.UPDATED_AT,
497 sortOrder: SortOrder.DESC,
498 });
499
500 expect(result.items).toHaveLength(0);
501 expect(result.totalCount).toBe(0);
502 expect(result.hasMore).toBe(false);
503 });
504 });
505
506 describe('Case insensitive search', () => {
507 it('should match regardless of case', async () => {
508 const url1 = URL.create('https://example.com/article').unwrap();
509 const metadata1 = UrlMetadata.create({
510 url: url1.value,
511 title: 'UPPERCASE TITLE',
512 description: 'lowercase description',
513 }).unwrap();
514
515 const card1 = new CardBuilder()
516 .withCuratorId(curator1.value)
517 .withUrlCard(url1, metadata1)
518 .buildOrThrow();
519
520 card1.addToLibrary(curator1);
521 await cardRepository.save(card1);
522
523 // Search with lowercase
524 const result1 = await queryRepository.searchUrls({
525 searchQuery: 'uppercase',
526 page: 1,
527 limit: 10,
528 sortBy: CardSortField.UPDATED_AT,
529 sortOrder: SortOrder.DESC,
530 });
531
532 expect(result1.items).toHaveLength(1);
533
534 // Search with mixed case
535 const result2 = await queryRepository.searchUrls({
536 searchQuery: 'LOWERCASE',
537 page: 1,
538 limit: 10,
539 sortBy: CardSortField.UPDATED_AT,
540 sortOrder: SortOrder.DESC,
541 });
542
543 expect(result2.items).toHaveLength(1);
544 });
545 });
546});