A social knowledge tool for researchers built on ATProto
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 { CardSortField, SortOrder } from '../../domain/ICardQueryRepository';
16import { createTestSchema } from '../test-utils/createTestSchema';
17import { CardTypeEnum } from '../../domain/value-objects/CardType';
18import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId';
19
20describe('DrizzleCardQueryRepository - getLibrariesForUrl', () => {
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('getLibrariesForUrl', () => {
69 it('should return all users who have cards with the specified URL', async () => {
70 const testUrl = 'https://example.com/shared-article';
71 const url = URL.create(testUrl).unwrap();
72
73 // Create URL cards for different users with the same URL
74 const card1 = new CardBuilder()
75 .withCuratorId(curator1.value)
76 .withType(CardTypeEnum.URL)
77 .withUrl(url)
78 .buildOrThrow();
79
80 const card2 = new CardBuilder()
81 .withCuratorId(curator2.value)
82 .withType(CardTypeEnum.URL)
83 .withUrl(url)
84 .buildOrThrow();
85
86 const card3 = new CardBuilder()
87 .withCuratorId(curator3.value)
88 .withType(CardTypeEnum.URL)
89 .withUrl(url)
90 .buildOrThrow();
91
92 // Add cards to their respective libraries
93 card1.addToLibrary(curator1);
94 card2.addToLibrary(curator2);
95 card3.addToLibrary(curator3);
96
97 // Save cards
98 await cardRepository.save(card1);
99 await cardRepository.save(card2);
100 await cardRepository.save(card3);
101
102 // Execute the query
103 const result = await queryRepository.getLibrariesForUrl(testUrl, {
104 page: 1,
105 limit: 10,
106 sortBy: CardSortField.UPDATED_AT,
107 sortOrder: SortOrder.DESC,
108 });
109
110 // Verify the result
111 expect(result.items).toHaveLength(3);
112 expect(result.totalCount).toBe(3);
113 expect(result.hasMore).toBe(false);
114
115 // Check that all three users are included
116 const userIds = result.items.map((lib) => lib.userId);
117 expect(userIds).toContain(curator1.value);
118 expect(userIds).toContain(curator2.value);
119 expect(userIds).toContain(curator3.value);
120
121 // Check that card IDs are correct
122 const cardIds = result.items.map((lib) => lib.card.id);
123 expect(cardIds).toContain(card1.cardId.getStringValue());
124 expect(cardIds).toContain(card2.cardId.getStringValue());
125 expect(cardIds).toContain(card3.cardId.getStringValue());
126 });
127
128 it('should return empty result when no users have cards with the specified URL', async () => {
129 const testUrl = 'https://example.com/nonexistent-article';
130
131 const result = await queryRepository.getLibrariesForUrl(testUrl, {
132 page: 1,
133 limit: 10,
134 sortBy: CardSortField.UPDATED_AT,
135 sortOrder: SortOrder.DESC,
136 });
137
138 expect(result.items).toHaveLength(0);
139 expect(result.totalCount).toBe(0);
140 expect(result.hasMore).toBe(false);
141 });
142
143 it('should not return users who have different URLs', async () => {
144 const testUrl1 = 'https://example.com/article1';
145 const testUrl2 = 'https://example.com/article2';
146 const url1 = URL.create(testUrl1).unwrap();
147 const url2 = URL.create(testUrl2).unwrap();
148
149 // Create cards with different URLs
150 const card1 = new CardBuilder()
151 .withCuratorId(curator1.value)
152 .withType(CardTypeEnum.URL)
153 .withUrl(url1)
154 .buildOrThrow();
155
156 const card2 = new CardBuilder()
157 .withCuratorId(curator2.value)
158 .withType(CardTypeEnum.URL)
159 .withUrl(url2)
160 .buildOrThrow();
161
162 card1.addToLibrary(curator1);
163 card2.addToLibrary(curator2);
164
165 await cardRepository.save(card1);
166 await cardRepository.save(card2);
167
168 // Query for testUrl1
169 const result = await queryRepository.getLibrariesForUrl(testUrl1, {
170 page: 1,
171 limit: 10,
172 sortBy: CardSortField.UPDATED_AT,
173 sortOrder: SortOrder.DESC,
174 });
175
176 expect(result.items).toHaveLength(1);
177 expect(result.items[0]!.userId).toBe(curator1.value);
178 expect(result.items[0]!.card.id).toBe(card1.cardId.getStringValue());
179 });
180
181 it('should not return NOTE cards even if they have the same URL', async () => {
182 const testUrl = 'https://example.com/article';
183 const url = URL.create(testUrl).unwrap();
184
185 // Create URL card
186 const urlCard = new CardBuilder()
187 .withCuratorId(curator1.value)
188 .withType(CardTypeEnum.URL)
189 .withUrl(url)
190 .buildOrThrow();
191
192 // Create NOTE card with same URL (shouldn't happen in practice but test edge case)
193 const noteCard = new CardBuilder()
194 .withCuratorId(curator2.value)
195 .withType(CardTypeEnum.NOTE)
196 .withUrl(url)
197 .buildOrThrow();
198
199 urlCard.addToLibrary(curator1);
200 noteCard.addToLibrary(curator2);
201
202 await cardRepository.save(urlCard);
203 await cardRepository.save(noteCard);
204
205 const result = await queryRepository.getLibrariesForUrl(testUrl, {
206 page: 1,
207 limit: 10,
208 sortBy: CardSortField.UPDATED_AT,
209 sortOrder: SortOrder.DESC,
210 });
211
212 // Should only return the URL card, not the NOTE card
213 expect(result.items).toHaveLength(1);
214 expect(result.items[0]!.userId).toBe(curator1.value);
215 expect(result.items[0]!.card.id).toBe(urlCard.cardId.getStringValue());
216 });
217
218 it('should handle multiple cards from same user with same URL', async () => {
219 const testUrl = 'https://example.com/article';
220 const url = URL.create(testUrl).unwrap();
221
222 // Create two URL cards from same user with same URL
223 const card1 = new CardBuilder()
224 .withCuratorId(curator1.value)
225 .withType(CardTypeEnum.URL)
226 .withUrl(url)
227 .buildOrThrow();
228
229 const card2 = new CardBuilder()
230 .withCuratorId(curator1.value)
231 .withType(CardTypeEnum.URL)
232 .withUrl(url)
233 .buildOrThrow();
234
235 card1.addToLibrary(curator1);
236 card2.addToLibrary(curator1);
237
238 await cardRepository.save(card1);
239 await cardRepository.save(card2);
240
241 const result = await queryRepository.getLibrariesForUrl(testUrl, {
242 page: 1,
243 limit: 10,
244 sortBy: CardSortField.UPDATED_AT,
245 sortOrder: SortOrder.DESC,
246 });
247
248 // Should return both cards
249 expect(result.items).toHaveLength(2);
250 expect(result.totalCount).toBe(2);
251
252 // Both should be from the same user
253 expect(result.items[0]!.userId).toBe(curator1.value);
254 expect(result.items[1]!.userId).toBe(curator1.value);
255
256 // But different card IDs
257 const cardIds = result.items.map((lib) => lib.card.id);
258 expect(cardIds).toContain(card1.cardId.getStringValue());
259 expect(cardIds).toContain(card2.cardId.getStringValue());
260 });
261
262 it('should handle cards not in any library', async () => {
263 const testUrl = 'https://example.com/article';
264 const url = URL.create(testUrl).unwrap();
265
266 // Create URL card but don't add to library
267 const card = new CardBuilder()
268 .withCuratorId(curator1.value)
269 .withType(CardTypeEnum.URL)
270 .withUrl(url)
271 .buildOrThrow();
272
273 await cardRepository.save(card);
274
275 const result = await queryRepository.getLibrariesForUrl(testUrl, {
276 page: 1,
277 limit: 10,
278 sortBy: CardSortField.UPDATED_AT,
279 sortOrder: SortOrder.DESC,
280 });
281
282 // Should return empty since card is not in any library
283 expect(result.items).toHaveLength(0);
284 expect(result.totalCount).toBe(0);
285 });
286 });
287
288 describe('sorting', () => {
289 it('should sort by createdAt in descending order by default', async () => {
290 const testUrl = 'https://example.com/sort-test';
291 const url = URL.create(testUrl).unwrap();
292
293 // Create cards with different creation times
294 const card1 = new CardBuilder()
295 .withCuratorId(curator1.value)
296 .withType(CardTypeEnum.URL)
297 .withUrl(url)
298 .buildOrThrow();
299
300 await new Promise((resolve) => setTimeout(resolve, 1000));
301 const card2 = new CardBuilder()
302 .withCuratorId(curator2.value)
303 .withType(CardTypeEnum.URL)
304 .withUrl(url)
305 .buildOrThrow();
306
307 await new Promise((resolve) => setTimeout(resolve, 1000));
308 const card3 = new CardBuilder()
309 .withCuratorId(curator3.value)
310 .withType(CardTypeEnum.URL)
311 .withUrl(url)
312 .buildOrThrow();
313
314 card1.addToLibrary(curator1);
315 card2.addToLibrary(curator2);
316 card3.addToLibrary(curator3);
317
318 // Save cards with slight delays to ensure different timestamps
319 await cardRepository.save(card1);
320 await new Promise((resolve) => setTimeout(resolve, 10));
321 await cardRepository.save(card2);
322 await new Promise((resolve) => setTimeout(resolve, 10));
323 await cardRepository.save(card3);
324
325 const result = await queryRepository.getLibrariesForUrl(testUrl, {
326 page: 1,
327 limit: 10,
328 sortBy: CardSortField.CREATED_AT,
329 sortOrder: SortOrder.DESC,
330 });
331
332 expect(result.items).toHaveLength(3);
333
334 // Should be sorted by creation time, newest first
335 const cardIds = result.items.map((lib) => lib.card.id);
336 expect(cardIds[0]).toBe(card3.cardId.getStringValue()); // Most recent
337 expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Middle
338 expect(cardIds[2]).toBe(card1.cardId.getStringValue()); // Oldest
339 });
340
341 it('should sort by createdAt in ascending order when specified', async () => {
342 const testUrl = 'https://example.com/sort-asc-test';
343 const url = URL.create(testUrl).unwrap();
344
345 // Create cards with different creation times
346 const card1 = new CardBuilder()
347 .withCuratorId(curator1.value)
348 .withType(CardTypeEnum.URL)
349 .withUrl(url)
350 .buildOrThrow();
351
352 const card2 = new CardBuilder()
353 .withCuratorId(curator2.value)
354 .withType(CardTypeEnum.URL)
355 .withUrl(url)
356 .buildOrThrow();
357
358 card1.addToLibrary(curator1);
359 card2.addToLibrary(curator2);
360
361 // Save cards with slight delay to ensure different timestamps
362 await cardRepository.save(card1);
363 await new Promise((resolve) => setTimeout(resolve, 10));
364 await cardRepository.save(card2);
365
366 const result = await queryRepository.getLibrariesForUrl(testUrl, {
367 page: 1,
368 limit: 10,
369 sortBy: CardSortField.CREATED_AT,
370 sortOrder: SortOrder.ASC,
371 });
372
373 expect(result.items).toHaveLength(2);
374
375 // Should be sorted by creation time, oldest first
376 const cardIds = result.items.map((lib) => lib.card.id);
377 expect(cardIds[0]).toBe(card1.cardId.getStringValue()); // Oldest
378 expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Newest
379 });
380
381 it('should sort by updatedAt in descending order', async () => {
382 const testUrl = 'https://example.com/sort-updated-test';
383 const url = URL.create(testUrl).unwrap();
384
385 // Create cards
386 const card1 = new CardBuilder()
387 .withCuratorId(curator1.value)
388 .withType(CardTypeEnum.URL)
389 .withUrl(url)
390 .buildOrThrow();
391
392 const card2 = new CardBuilder()
393 .withCuratorId(curator2.value)
394 .withType(CardTypeEnum.URL)
395 .withUrl(url)
396 .buildOrThrow();
397
398 card1.addToLibrary(curator1);
399 card2.addToLibrary(curator2);
400
401 // Save cards
402 await cardRepository.save(card1);
403 await cardRepository.save(card2);
404
405 // Update card1 to have a more recent updatedAt
406 await new Promise((resolve) => setTimeout(resolve, 1000));
407 card1.markAsPublished(
408 PublishedRecordId.create({
409 uri: 'at://did:plc:publishedrecord1',
410 cid: 'bafyreicpublishedrecord1',
411 }),
412 );
413 await cardRepository.save(card1); // This should update the updatedAt timestamp
414
415 const result = await queryRepository.getLibrariesForUrl(testUrl, {
416 page: 1,
417 limit: 10,
418 sortBy: CardSortField.UPDATED_AT,
419 sortOrder: SortOrder.DESC,
420 });
421
422 expect(result.items).toHaveLength(2);
423
424 // card1 should be first since it was updated more recently
425 const cardIds = result.items.map((lib) => lib.card.id);
426 expect(cardIds[0]).toBe(card1.cardId.getStringValue()); // Most recently updated
427 expect(cardIds[1]).toBe(card2.cardId.getStringValue()); // Less recently updated
428 });
429
430 it('should sort by libraryCount in descending order', async () => {
431 const testUrl = 'https://example.com/sort-library-count-test';
432 const url = URL.create(testUrl).unwrap();
433
434 // Create cards
435 const card1 = new CardBuilder()
436 .withCuratorId(curator1.value)
437 .withType(CardTypeEnum.URL)
438 .withUrl(url)
439 .buildOrThrow();
440
441 const card2 = new CardBuilder()
442 .withCuratorId(curator2.value)
443 .withType(CardTypeEnum.URL)
444 .withUrl(url)
445 .buildOrThrow();
446
447 const card3 = new CardBuilder()
448 .withCuratorId(curator3.value)
449 .withType(CardTypeEnum.URL)
450 .withUrl(url)
451 .buildOrThrow();
452
453 // Add cards to libraries with different counts
454 card1.addToLibrary(curator1);
455
456 card2.addToLibrary(curator2);
457 card2.addToLibrary(curator1); // card2 has 2 library memberships
458
459 card3.addToLibrary(curator3);
460 card3.addToLibrary(curator1); // card3 has 3 library memberships
461 card3.addToLibrary(curator2);
462
463 await cardRepository.save(card1);
464 await cardRepository.save(card2);
465 await cardRepository.save(card3);
466
467 const result = await queryRepository.getLibrariesForUrl(testUrl, {
468 page: 1,
469 limit: 10,
470 sortBy: CardSortField.LIBRARY_COUNT,
471 sortOrder: SortOrder.DESC,
472 });
473
474 // Should return all library memberships, but sorted by the card's library count
475 expect(result.items.length).toBeGreaterThan(0);
476
477 // Group by card ID to check sorting
478 const cardGroups = new Map<string, any[]>();
479 result.items.forEach((item) => {
480 const cardId = item.card.id;
481 if (!cardGroups.has(cardId)) {
482 cardGroups.set(cardId, []);
483 }
484 cardGroups.get(cardId)!.push(item);
485 });
486
487 // Get the first occurrence of each card to check library count ordering
488 const uniqueCards = Array.from(cardGroups.entries()).map(
489 ([cardId, items]) => ({
490 cardId,
491 libraryCount: items[0]!.card.libraryCount,
492 }),
493 );
494
495 // Should be sorted by library count descending
496 for (let i = 0; i < uniqueCards.length - 1; i++) {
497 expect(uniqueCards[i]!.libraryCount).toBeGreaterThanOrEqual(
498 uniqueCards[i + 1]!.libraryCount,
499 );
500 }
501 });
502
503 it('should sort by libraryCount in ascending order when specified', async () => {
504 const testUrl = 'https://example.com/sort-library-count-asc-test';
505 const url = URL.create(testUrl).unwrap();
506
507 // Create cards with different library counts
508 const card1 = new CardBuilder()
509 .withCuratorId(curator1.value)
510 .withType(CardTypeEnum.URL)
511 .withUrl(url)
512 .buildOrThrow();
513
514 const card2 = new CardBuilder()
515 .withCuratorId(curator2.value)
516 .withType(CardTypeEnum.URL)
517 .withUrl(url)
518 .buildOrThrow();
519
520 // card1 has 1 library membership, card2 has 2
521 card1.addToLibrary(curator1);
522 card2.addToLibrary(curator2);
523 card2.addToLibrary(curator1);
524
525 await cardRepository.save(card1);
526 await cardRepository.save(card2);
527
528 const result = await queryRepository.getLibrariesForUrl(testUrl, {
529 page: 1,
530 limit: 10,
531 sortBy: CardSortField.LIBRARY_COUNT,
532 sortOrder: SortOrder.ASC,
533 });
534
535 expect(result.items.length).toBeGreaterThan(0);
536
537 // Group by card ID and check ascending order
538 const cardGroups = new Map<string, any[]>();
539 result.items.forEach((item) => {
540 const cardId = item.card.id;
541 if (!cardGroups.has(cardId)) {
542 cardGroups.set(cardId, []);
543 }
544 cardGroups.get(cardId)!.push(item);
545 });
546
547 const uniqueCards = Array.from(cardGroups.entries()).map(
548 ([cardId, items]) => ({
549 cardId,
550 libraryCount: items[0]!.card.libraryCount,
551 }),
552 );
553
554 // Should be sorted by library count ascending
555 for (let i = 0; i < uniqueCards.length - 1; i++) {
556 expect(uniqueCards[i]!.libraryCount).toBeLessThanOrEqual(
557 uniqueCards[i + 1]!.libraryCount,
558 );
559 }
560 });
561 });
562
563 describe('pagination', () => {
564 it('should paginate results correctly', async () => {
565 const testUrl = 'https://example.com/popular-article';
566 const url = URL.create(testUrl).unwrap();
567
568 // Create 5 cards with the same URL from different users
569 const cards = [];
570 const curators = [];
571 for (let i = 1; i <= 5; i++) {
572 const curator = CuratorId.create(`did:plc:curator${i}`).unwrap();
573 curators.push(curator);
574
575 const card = new CardBuilder()
576 .withCuratorId(curator.value)
577 .withType(CardTypeEnum.URL)
578 .withUrl(url)
579 .buildOrThrow();
580
581 card.addToLibrary(curator);
582 cards.push(card);
583 await cardRepository.save(card);
584 }
585
586 // Test first page with limit 2
587 const result1 = await queryRepository.getLibrariesForUrl(testUrl, {
588 page: 1,
589 limit: 2,
590 sortBy: CardSortField.UPDATED_AT,
591 sortOrder: SortOrder.DESC,
592 });
593
594 expect(result1.items).toHaveLength(2);
595 expect(result1.totalCount).toBe(5);
596 expect(result1.hasMore).toBe(true);
597
598 // Test second page
599 const result2 = await queryRepository.getLibrariesForUrl(testUrl, {
600 page: 2,
601 limit: 2,
602 sortBy: CardSortField.UPDATED_AT,
603 sortOrder: SortOrder.DESC,
604 });
605
606 expect(result2.items).toHaveLength(2);
607 expect(result2.totalCount).toBe(5);
608 expect(result2.hasMore).toBe(true);
609
610 // Test last page
611 const result3 = await queryRepository.getLibrariesForUrl(testUrl, {
612 page: 3,
613 limit: 2,
614 sortBy: CardSortField.UPDATED_AT,
615 sortOrder: SortOrder.DESC,
616 });
617
618 expect(result3.items).toHaveLength(1);
619 expect(result3.totalCount).toBe(5);
620 expect(result3.hasMore).toBe(false);
621
622 // Verify no duplicate entries across pages
623 const allUserIds = [
624 ...result1.items.map((lib) => lib.userId),
625 ...result2.items.map((lib) => lib.userId),
626 ...result3.items.map((lib) => lib.userId),
627 ];
628 const uniqueUserIds = [...new Set(allUserIds)];
629 expect(uniqueUserIds).toHaveLength(5);
630 });
631
632 it('should handle empty pages correctly', async () => {
633 const testUrl = 'https://example.com/empty-test';
634
635 const result = await queryRepository.getLibrariesForUrl(testUrl, {
636 page: 2,
637 limit: 10,
638 sortBy: CardSortField.UPDATED_AT,
639 sortOrder: SortOrder.DESC,
640 });
641
642 expect(result.items).toHaveLength(0);
643 expect(result.totalCount).toBe(0);
644 expect(result.hasMore).toBe(false);
645 });
646
647 it('should handle large page numbers gracefully', async () => {
648 const testUrl = 'https://example.com/single-card';
649 const url = URL.create(testUrl).unwrap();
650
651 // Create single card
652 const card = new CardBuilder()
653 .withCuratorId(curator1.value)
654 .withType(CardTypeEnum.URL)
655 .withUrl(url)
656 .buildOrThrow();
657
658 card.addToLibrary(curator1);
659 await cardRepository.save(card);
660
661 // Request page 10 when there's only 1 item
662 const result = await queryRepository.getLibrariesForUrl(testUrl, {
663 page: 10,
664 limit: 10,
665 sortBy: CardSortField.UPDATED_AT,
666 sortOrder: SortOrder.DESC,
667 });
668
669 expect(result.items).toHaveLength(0);
670 expect(result.totalCount).toBe(1);
671 expect(result.hasMore).toBe(false);
672 });
673 });
674});