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 { DrizzleCollectionRepository } from '../../infrastructure/repositories/DrizzleCollectionRepository';
10import { CuratorId } from '../../domain/value-objects/CuratorId';
11import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID';
12import { cards } from '../../infrastructure/repositories/schema/card.sql';
13import {
14 collections,
15 collectionCards,
16} from '../../infrastructure/repositories/schema/collection.sql';
17import { libraryMemberships } from '../../infrastructure/repositories/schema/libraryMembership.sql';
18import { publishedRecords } from '../../infrastructure/repositories/schema/publishedRecord.sql';
19import { Collection, CollectionAccessType } from '../../domain/Collection';
20import { CardBuilder } from '../utils/builders/CardBuilder';
21import { URL } from '../../domain/value-objects/URL';
22import { UrlMetadata } from '../../domain/value-objects/UrlMetadata';
23import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository';
24import { createTestSchema } from '../test-utils/createTestSchema';
25import { CardTypeEnum } from '../../domain/value-objects/CardType';
26
27describe('DrizzleCardQueryRepository - getCardsInCollection', () => {
28 let container: StartedPostgreSqlContainer;
29 let db: PostgresJsDatabase;
30 let queryRepository: DrizzleCardQueryRepository;
31 let cardRepository: DrizzleCardRepository;
32 let collectionRepository: DrizzleCollectionRepository;
33
34 // Test data
35 let curatorId: CuratorId;
36 let otherCuratorId: CuratorId;
37 let thirdCuratorId: CuratorId;
38
39 // Setup before all tests
40 beforeAll(async () => {
41 // Start PostgreSQL container
42 container = await new PostgreSqlContainer('postgres:14').start();
43
44 // Create database connection
45 const connectionString = container.getConnectionUri();
46 process.env.DATABASE_URL = connectionString;
47 const client = postgres(connectionString);
48 db = drizzle(client);
49
50 // Create repositories
51 queryRepository = new DrizzleCardQueryRepository(db);
52 cardRepository = new DrizzleCardRepository(db);
53 collectionRepository = new DrizzleCollectionRepository(db);
54
55 // Create schema using helper function
56 await createTestSchema(db);
57
58 // Create test data
59 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
60 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
61 thirdCuratorId = CuratorId.create('did:plc:thirdcurator').unwrap();
62 }, 60000); // Increase timeout for container startup
63
64 // Cleanup after all tests
65 afterAll(async () => {
66 // Stop container
67 await container.stop();
68 });
69
70 // Clear data between tests
71 beforeEach(async () => {
72 await db.delete(collectionCards);
73 await db.delete(collections);
74 await db.delete(libraryMemberships);
75 await db.delete(cards);
76 await db.delete(publishedRecords);
77 });
78
79 describe('getCardsInCollection', () => {
80 it('should return empty result when collection has no URL cards', async () => {
81 // Create empty collection
82 const collection = Collection.create(
83 {
84 authorId: curatorId,
85 name: 'Empty Collection',
86 accessType: CollectionAccessType.OPEN,
87 collaboratorIds: [],
88 createdAt: new Date(),
89 updatedAt: new Date(),
90 },
91 new UniqueEntityID(),
92 ).unwrap();
93
94 await collectionRepository.save(collection);
95
96 const result = await queryRepository.getCardsInCollection(
97 collection.collectionId.getStringValue(),
98 {
99 page: 1,
100 limit: 10,
101 sortBy: CardSortField.UPDATED_AT,
102 sortOrder: SortOrder.DESC,
103 },
104 );
105
106 expect(result.items).toHaveLength(0);
107 expect(result.totalCount).toBe(0);
108 expect(result.hasMore).toBe(false);
109 });
110
111 it('should return URL cards in a collection', async () => {
112 // Create URL cards
113 const url1 = URL.create('https://example.com/article1').unwrap();
114 const urlMetadata1 = UrlMetadata.create({
115 url: url1.value,
116 title: 'Collection Article 1',
117 description: 'First article in collection',
118 author: 'John Doe',
119 imageUrl: 'https://example.com/image1.jpg',
120 }).unwrap();
121
122 const urlCard1 = new CardBuilder()
123 .withCuratorId(curatorId.value)
124 .withUrlCard(url1, urlMetadata1)
125 .withCreatedAt(new Date('2023-01-01'))
126 .withUpdatedAt(new Date('2023-01-01'))
127 .buildOrThrow();
128
129 const url2 = URL.create('https://example.com/article2').unwrap();
130 const urlCard2 = new CardBuilder()
131 .withCuratorId(curatorId.value)
132 .withUrlCard(url2)
133 .withCreatedAt(new Date('2023-01-02'))
134 .withUpdatedAt(new Date('2023-01-02'))
135 .buildOrThrow();
136
137 // Save cards
138 await cardRepository.save(urlCard1);
139 await cardRepository.save(urlCard2);
140
141 // Create collection and add cards
142 const collection = Collection.create(
143 {
144 authorId: curatorId,
145 name: 'Test Collection',
146 accessType: CollectionAccessType.OPEN,
147 collaboratorIds: [],
148 createdAt: new Date(),
149 updatedAt: new Date(),
150 },
151 new UniqueEntityID(),
152 ).unwrap();
153
154 collection.addCard(urlCard1.cardId, curatorId);
155 collection.addCard(urlCard2.cardId, curatorId);
156
157 await collectionRepository.save(collection);
158
159 // Query cards in collection
160 const result = await queryRepository.getCardsInCollection(
161 collection.collectionId.getStringValue(),
162 {
163 page: 1,
164 limit: 10,
165 sortBy: CardSortField.UPDATED_AT,
166 sortOrder: SortOrder.DESC,
167 },
168 );
169
170 expect(result.items).toHaveLength(2);
171 expect(result.totalCount).toBe(2);
172 expect(result.hasMore).toBe(false);
173
174 // Check URL card data
175 const card1Result = result.items.find((item) => item.url === url1.value);
176 const card2Result = result.items.find((item) => item.url === url2.value);
177
178 expect(card1Result).toBeDefined();
179 expect(card1Result?.type).toBe(CardTypeEnum.URL);
180 expect(card1Result?.cardContent.title).toBe('Collection Article 1');
181 expect(card1Result?.cardContent.description).toBe(
182 'First article in collection',
183 );
184 expect(card1Result?.cardContent.author).toBe('John Doe');
185 expect(card1Result?.cardContent.thumbnailUrl).toBe(
186 'https://example.com/image1.jpg',
187 );
188
189 expect(card2Result).toBeDefined();
190 expect(card2Result?.type).toBe(CardTypeEnum.URL);
191 expect(card2Result?.cardContent.title).toBeUndefined(); // No metadata provided
192 });
193
194 it('should only include notes by collection author, not notes by other users', async () => {
195 // Create URL card
196 const url = URL.create('https://example.com/shared-article').unwrap();
197 const urlCard = new CardBuilder()
198 .withCuratorId(curatorId.value)
199 .withUrlCard(url)
200 .buildOrThrow();
201
202 urlCard.addToLibrary(curatorId);
203
204 await cardRepository.save(urlCard);
205
206 // Create note by collection author
207 const authorNote = new CardBuilder()
208 .withCuratorId(curatorId.value)
209 .withNoteCard('Note by collection author')
210 .withParentCard(urlCard.cardId)
211 .buildOrThrow();
212
213 authorNote.addToLibrary(curatorId);
214
215 await cardRepository.save(authorNote);
216
217 // Create note by different user on the same URL card
218 const otherUserNote = new CardBuilder()
219 .withCuratorId(otherCuratorId.value)
220 .withNoteCard('Note by other user')
221 .withParentCard(urlCard.cardId)
222 .buildOrThrow();
223
224 otherUserNote.addToLibrary(otherCuratorId);
225
226 await cardRepository.save(otherUserNote);
227
228 // Create collection authored by curatorId and add URL card
229 const collection = Collection.create(
230 {
231 authorId: curatorId,
232 name: 'Collection by First User',
233 accessType: CollectionAccessType.OPEN,
234 collaboratorIds: [],
235 createdAt: new Date(),
236 updatedAt: new Date(),
237 },
238 new UniqueEntityID(),
239 ).unwrap();
240
241 collection.addCard(urlCard.cardId, curatorId);
242 await collectionRepository.save(collection);
243
244 // Query cards in collection
245 const result = await queryRepository.getCardsInCollection(
246 collection.collectionId.getStringValue(),
247 {
248 page: 1,
249 limit: 10,
250 sortBy: CardSortField.UPDATED_AT,
251 sortOrder: SortOrder.DESC,
252 },
253 );
254
255 expect(result.items).toHaveLength(1);
256 const urlCardResult = result.items[0];
257
258 // Should only include the note by the collection author, not the other user's note
259 expect(urlCardResult?.note).toBeDefined();
260 expect(urlCardResult?.note?.id).toBe(authorNote.cardId.getStringValue());
261 expect(urlCardResult?.note?.text).toBe('Note by collection author');
262 });
263
264 it('should not include notes when only other users have notes, not collection author', async () => {
265 // Create URL card
266 const url = URL.create(
267 'https://example.com/article-with-other-notes',
268 ).unwrap();
269 const urlCard = new CardBuilder()
270 .withCuratorId(curatorId.value)
271 .withUrlCard(url)
272 .buildOrThrow();
273
274 urlCard.addToLibrary(curatorId);
275
276 await cardRepository.save(urlCard);
277
278 // Create note by different user on the URL card (NO note by collection author)
279 const otherUserNote = new CardBuilder()
280 .withCuratorId(otherCuratorId.value)
281 .withNoteCard('Note by other user only')
282 .withParentCard(urlCard.cardId)
283 .buildOrThrow();
284
285 otherUserNote.addToLibrary(otherCuratorId);
286
287 await cardRepository.save(otherUserNote);
288
289 // Create collection authored by curatorId and add URL card
290 const collection = Collection.create(
291 {
292 authorId: curatorId,
293 name: 'Collection with Other User Notes Only',
294 accessType: CollectionAccessType.OPEN,
295 collaboratorIds: [],
296 createdAt: new Date(),
297 updatedAt: new Date(),
298 },
299 new UniqueEntityID(),
300 ).unwrap();
301
302 collection.addCard(urlCard.cardId, curatorId);
303 await collectionRepository.save(collection);
304
305 // Query cards in collection
306 const result = await queryRepository.getCardsInCollection(
307 collection.collectionId.getStringValue(),
308 {
309 page: 1,
310 limit: 10,
311 sortBy: CardSortField.UPDATED_AT,
312 sortOrder: SortOrder.DESC,
313 },
314 );
315
316 expect(result.items).toHaveLength(1);
317 const urlCardResult = result.items[0];
318
319 // Should not include any note since only other users have notes, not the collection author
320 expect(urlCardResult?.note).toBeUndefined();
321 });
322
323 it('should not include note cards that are not connected to collection URL cards', async () => {
324 // Create URL card in collection
325 const url = URL.create('https://example.com/collection-article').unwrap();
326 const urlCard = new CardBuilder()
327 .withCuratorId(curatorId.value)
328 .withUrlCard(url)
329 .buildOrThrow();
330
331 await cardRepository.save(urlCard);
332
333 // Create another URL card NOT in collection
334 const otherUrl = URL.create('https://example.com/other-article').unwrap();
335 const otherUrlCard = new CardBuilder()
336 .withCuratorId(curatorId.value)
337 .withUrlCard(otherUrl)
338 .buildOrThrow();
339
340 await cardRepository.save(otherUrlCard);
341
342 // Create note card connected to the OTHER URL card (not in collection)
343 const noteCard = new CardBuilder()
344 .withCuratorId(curatorId.value)
345 .withNoteCard('This note is for the other article')
346 .withParentCard(otherUrlCard.cardId)
347 .buildOrThrow();
348
349 await cardRepository.save(noteCard);
350
351 // Create collection and add only the first URL card
352 const collection = Collection.create(
353 {
354 authorId: curatorId,
355 name: 'Selective Collection',
356 accessType: CollectionAccessType.OPEN,
357 collaboratorIds: [],
358 createdAt: new Date(),
359 updatedAt: new Date(),
360 },
361 new UniqueEntityID(),
362 ).unwrap();
363
364 collection.addCard(urlCard.cardId, curatorId);
365 await collectionRepository.save(collection);
366
367 // Query cards in collection
368 const result = await queryRepository.getCardsInCollection(
369 collection.collectionId.getStringValue(),
370 {
371 page: 1,
372 limit: 10,
373 sortBy: CardSortField.UPDATED_AT,
374 sortOrder: SortOrder.DESC,
375 },
376 );
377
378 expect(result.items).toHaveLength(1);
379 const urlCardResult = result.items[0];
380
381 // Should not have a note since the note is connected to a different URL card
382 expect(urlCardResult?.note).toBeUndefined();
383 });
384
385 it('should handle collection with cards from multiple users', async () => {
386 // Create URL cards from different users
387 const url1 = URL.create('https://example.com/user1-article').unwrap();
388 const urlCard1 = new CardBuilder()
389 .withCuratorId(curatorId.value)
390 .withUrlCard(url1)
391 .buildOrThrow();
392
393 const url2 = URL.create('https://example.com/user2-article').unwrap();
394 const urlCard2 = new CardBuilder()
395 .withCuratorId(otherCuratorId.value)
396 .withUrlCard(url2)
397 .buildOrThrow();
398
399 await cardRepository.save(urlCard1);
400 await cardRepository.save(urlCard2);
401
402 // Create collection and add both cards
403 const collection = Collection.create(
404 {
405 authorId: curatorId,
406 name: 'Multi-User Collection',
407 accessType: CollectionAccessType.OPEN,
408 collaboratorIds: [],
409 createdAt: new Date(),
410 updatedAt: new Date(),
411 },
412 new UniqueEntityID(),
413 ).unwrap();
414
415 collection.addCard(urlCard1.cardId, curatorId);
416 collection.addCard(urlCard2.cardId, curatorId);
417 await collectionRepository.save(collection);
418
419 // Query cards in collection
420 const result = await queryRepository.getCardsInCollection(
421 collection.collectionId.getStringValue(),
422 {
423 page: 1,
424 limit: 10,
425 sortBy: CardSortField.UPDATED_AT,
426 sortOrder: SortOrder.DESC,
427 },
428 );
429
430 expect(result.items).toHaveLength(2);
431
432 const urls = result.items.map((item) => item.url);
433 expect(urls).toContain(url1.value);
434 expect(urls).toContain(url2.value);
435 });
436
437 it('should not return note cards directly from collection', async () => {
438 // Create a standalone note card
439 const noteCard = new CardBuilder()
440 .withCuratorId(curatorId.value)
441 .withNoteCard('Standalone note in collection')
442 .buildOrThrow();
443
444 await cardRepository.save(noteCard);
445
446 // Create collection and add note card directly
447 const collection = Collection.create(
448 {
449 authorId: curatorId,
450 name: 'Collection with Direct Note',
451 accessType: CollectionAccessType.OPEN,
452 collaboratorIds: [],
453 createdAt: new Date(),
454 updatedAt: new Date(),
455 },
456 new UniqueEntityID(),
457 ).unwrap();
458
459 collection.addCard(noteCard.cardId, curatorId);
460 await collectionRepository.save(collection);
461
462 // Query cards in collection
463 const result = await queryRepository.getCardsInCollection(
464 collection.collectionId.getStringValue(),
465 {
466 page: 1,
467 limit: 10,
468 sortBy: CardSortField.UPDATED_AT,
469 sortOrder: SortOrder.DESC,
470 },
471 );
472
473 // Should not return the note card since we only return URL cards
474 expect(result.items).toHaveLength(0);
475 expect(result.totalCount).toBe(0);
476 });
477
478 it('should handle sorting by library count in collection', async () => {
479 // Create URL cards - each URL card can only be in its creator's library
480 const url1 = URL.create('https://example.com/popular').unwrap();
481 const urlCard1 = new CardBuilder()
482 .withCuratorId(curatorId.value)
483 .withUrlCard(url1)
484 .buildOrThrow();
485
486 const url2 = URL.create('https://example.com/less-popular').unwrap();
487 const urlCard2 = new CardBuilder()
488 .withCuratorId(curatorId.value)
489 .withUrlCard(url2)
490 .buildOrThrow();
491
492 await cardRepository.save(urlCard1);
493 await cardRepository.save(urlCard2);
494
495 // Add cards to creator's library - URL cards can only be in creator's library
496 urlCard1.addToLibrary(curatorId);
497 await cardRepository.save(urlCard1);
498
499 urlCard2.addToLibrary(curatorId);
500 await cardRepository.save(urlCard2);
501
502 // Create collection and add both cards
503 const collection = Collection.create(
504 {
505 authorId: curatorId,
506 name: 'Popularity Collection',
507 accessType: CollectionAccessType.OPEN,
508 collaboratorIds: [],
509 createdAt: new Date(),
510 updatedAt: new Date(),
511 },
512 new UniqueEntityID(),
513 ).unwrap();
514
515 collection.addCard(urlCard1.cardId, curatorId);
516 collection.addCard(urlCard2.cardId, curatorId);
517 await collectionRepository.save(collection);
518
519 // Query cards sorted by library count descending
520 const result = await queryRepository.getCardsInCollection(
521 collection.collectionId.getStringValue(),
522 {
523 page: 1,
524 limit: 10,
525 sortBy: CardSortField.LIBRARY_COUNT,
526 sortOrder: SortOrder.DESC,
527 },
528 );
529
530 expect(result.items).toHaveLength(2);
531 // Both URL cards have library count of 1 (only in creator's library)
532 expect(result.items[0]?.libraryCount).toBe(1);
533 expect(result.items[1]?.libraryCount).toBe(1);
534 });
535
536 it('should handle pagination for collection cards', async () => {
537 // Create multiple URL cards
538 const urlCards = [];
539 for (let i = 1; i <= 5; i++) {
540 const url = URL.create(
541 `https://example.com/collection-article${i}`,
542 ).unwrap();
543 const urlCard = new CardBuilder()
544 .withCuratorId(curatorId.value)
545 .withUrlCard(url)
546 .withCreatedAt(new Date(`2023-01-${i.toString().padStart(2, '0')}`))
547 .withUpdatedAt(new Date(`2023-01-${i.toString().padStart(2, '0')}`))
548 .buildOrThrow();
549
550 await cardRepository.save(urlCard);
551 urlCards.push(urlCard);
552 }
553
554 // Create collection and add all cards
555 const collection = Collection.create(
556 {
557 authorId: curatorId,
558 name: 'Large Collection',
559 accessType: CollectionAccessType.OPEN,
560 collaboratorIds: [],
561 createdAt: new Date(),
562 updatedAt: new Date(),
563 },
564 new UniqueEntityID(),
565 ).unwrap();
566
567 for (const urlCard of urlCards) {
568 collection.addCard(urlCard.cardId, curatorId);
569 }
570 await collectionRepository.save(collection);
571
572 // Test first page
573 const page1 = await queryRepository.getCardsInCollection(
574 collection.collectionId.getStringValue(),
575 {
576 page: 1,
577 limit: 2,
578 sortBy: CardSortField.UPDATED_AT,
579 sortOrder: SortOrder.ASC,
580 },
581 );
582
583 expect(page1.items).toHaveLength(2);
584 expect(page1.totalCount).toBe(5);
585 expect(page1.hasMore).toBe(true);
586
587 // Test second page
588 const page2 = await queryRepository.getCardsInCollection(
589 collection.collectionId.getStringValue(),
590 {
591 page: 2,
592 limit: 2,
593 sortBy: CardSortField.UPDATED_AT,
594 sortOrder: SortOrder.ASC,
595 },
596 );
597
598 expect(page2.items).toHaveLength(2);
599 expect(page2.totalCount).toBe(5);
600 expect(page2.hasMore).toBe(true);
601
602 // Test last page
603 const page3 = await queryRepository.getCardsInCollection(
604 collection.collectionId.getStringValue(),
605 {
606 page: 3,
607 limit: 2,
608 sortBy: CardSortField.UPDATED_AT,
609 sortOrder: SortOrder.ASC,
610 },
611 );
612
613 expect(page3.items).toHaveLength(1);
614 expect(page3.totalCount).toBe(5);
615 expect(page3.hasMore).toBe(false);
616 });
617 });
618
619 describe('urlInLibrary', () => {
620 it('should return urlInLibrary as undefined when callingUserId is not provided', async () => {
621 const url = URL.create('https://example.com/collection-url').unwrap();
622 const urlCard = new CardBuilder()
623 .withCuratorId(curatorId.value)
624 .withUrlCard(url)
625 .buildOrThrow();
626
627 await cardRepository.save(urlCard);
628
629 // Create collection and add card
630 const collection = Collection.create(
631 {
632 authorId: curatorId,
633 name: 'Test Collection',
634 accessType: CollectionAccessType.OPEN,
635 collaboratorIds: [],
636 createdAt: new Date(),
637 updatedAt: new Date(),
638 },
639 new UniqueEntityID(),
640 ).unwrap();
641
642 collection.addCard(urlCard.cardId, curatorId);
643 await collectionRepository.save(collection);
644
645 // Query without callingUserId
646 const result = await queryRepository.getCardsInCollection(
647 collection.collectionId.getStringValue(),
648 {
649 page: 1,
650 limit: 10,
651 sortBy: CardSortField.UPDATED_AT,
652 sortOrder: SortOrder.DESC,
653 },
654 );
655
656 expect(result.items).toHaveLength(1);
657 expect(result.items[0]?.urlInLibrary).toBeUndefined();
658 });
659
660 it('should return urlInLibrary as true when callingUserId has the URL in their library', async () => {
661 const sharedUrl = 'https://example.com/shared-collection-url';
662 const url = URL.create(sharedUrl).unwrap();
663
664 // Create URL card for first user and add to collection
665 const urlCard1 = new CardBuilder()
666 .withCuratorId(curatorId.value)
667 .withUrlCard(url)
668 .buildOrThrow();
669
670 await cardRepository.save(urlCard1);
671
672 // Create collection and add card
673 const collection = Collection.create(
674 {
675 authorId: curatorId,
676 name: 'Shared Collection',
677 accessType: CollectionAccessType.OPEN,
678 collaboratorIds: [],
679 createdAt: new Date(),
680 updatedAt: new Date(),
681 },
682 new UniqueEntityID(),
683 ).unwrap();
684
685 collection.addCard(urlCard1.cardId, curatorId);
686 await collectionRepository.save(collection);
687
688 // Create URL card for second user with the same URL
689 const urlCard2 = new CardBuilder()
690 .withCuratorId(otherCuratorId.value)
691 .withUrlCard(url)
692 .buildOrThrow();
693
694 await cardRepository.save(urlCard2);
695 urlCard2.addToLibrary(otherCuratorId);
696 await cardRepository.save(urlCard2);
697
698 // Query collection cards with second user as callingUserId
699 const result = await queryRepository.getCardsInCollection(
700 collection.collectionId.getStringValue(),
701 {
702 page: 1,
703 limit: 10,
704 sortBy: CardSortField.UPDATED_AT,
705 sortOrder: SortOrder.DESC,
706 },
707 otherCuratorId.value, // callingUserId
708 );
709
710 expect(result.items).toHaveLength(1);
711 expect(result.items[0]?.url).toBe(sharedUrl);
712 expect(result.items[0]?.urlInLibrary).toBe(true); // otherCurator has this URL
713 });
714
715 it('should return urlInLibrary as false when callingUserId does not have the URL in their library', async () => {
716 const url = URL.create(
717 'https://example.com/unique-collection-url',
718 ).unwrap();
719
720 // Create URL card for first user and add to collection
721 const urlCard = new CardBuilder()
722 .withCuratorId(curatorId.value)
723 .withUrlCard(url)
724 .buildOrThrow();
725
726 await cardRepository.save(urlCard);
727
728 // Create collection and add card
729 const collection = Collection.create(
730 {
731 authorId: curatorId,
732 name: 'Unique Collection',
733 accessType: CollectionAccessType.OPEN,
734 collaboratorIds: [],
735 createdAt: new Date(),
736 updatedAt: new Date(),
737 },
738 new UniqueEntityID(),
739 ).unwrap();
740
741 collection.addCard(urlCard.cardId, curatorId);
742 await collectionRepository.save(collection);
743
744 // Query collection cards with second user as callingUserId (who doesn't have this URL)
745 const result = await queryRepository.getCardsInCollection(
746 collection.collectionId.getStringValue(),
747 {
748 page: 1,
749 limit: 10,
750 sortBy: CardSortField.UPDATED_AT,
751 sortOrder: SortOrder.DESC,
752 },
753 otherCuratorId.value, // callingUserId who doesn't have this URL
754 );
755
756 expect(result.items).toHaveLength(1);
757 expect(result.items[0]?.urlInLibrary).toBe(false); // otherCurator doesn't have this URL
758 });
759
760 it('should return urlInLibrary as true when the collection author is the same as callingUserId', async () => {
761 const url = URL.create(
762 'https://example.com/author-collection-url',
763 ).unwrap();
764
765 // Create URL card for curator (collection author)
766 const urlCard = new CardBuilder()
767 .withCuratorId(curatorId.value)
768 .withUrlCard(url)
769 .buildOrThrow();
770
771 await cardRepository.save(urlCard);
772 urlCard.addToLibrary(curatorId);
773 await cardRepository.save(urlCard);
774
775 // Create collection and add card
776 const collection = Collection.create(
777 {
778 authorId: curatorId,
779 name: 'Author Collection',
780 accessType: CollectionAccessType.OPEN,
781 collaboratorIds: [],
782 createdAt: new Date(),
783 updatedAt: new Date(),
784 },
785 new UniqueEntityID(),
786 ).unwrap();
787
788 collection.addCard(urlCard.cardId, curatorId);
789 await collectionRepository.save(collection);
790
791 // Query collection cards with the author as callingUserId
792 const result = await queryRepository.getCardsInCollection(
793 collection.collectionId.getStringValue(),
794 {
795 page: 1,
796 limit: 10,
797 sortBy: CardSortField.UPDATED_AT,
798 sortOrder: SortOrder.DESC,
799 },
800 curatorId.value, // same as collection author
801 );
802
803 expect(result.items).toHaveLength(1);
804 expect(result.items[0]?.urlInLibrary).toBe(true); // curator has their own URL
805 });
806
807 it('should return urlInLibrary correctly when user has multiple cards with the same URL', async () => {
808 const sharedUrl = 'https://example.com/multi-card-collection-url';
809 const url = URL.create(sharedUrl).unwrap();
810
811 // Create URL card for first user and add to collection
812 const urlCard1 = new CardBuilder()
813 .withCuratorId(curatorId.value)
814 .withUrlCard(url)
815 .buildOrThrow();
816
817 await cardRepository.save(urlCard1);
818
819 // Create collection and add card
820 const collection = Collection.create(
821 {
822 authorId: curatorId,
823 name: 'Multi-Card Collection',
824 accessType: CollectionAccessType.OPEN,
825 collaboratorIds: [],
826 createdAt: new Date(),
827 updatedAt: new Date(),
828 },
829 new UniqueEntityID(),
830 ).unwrap();
831
832 collection.addCard(urlCard1.cardId, curatorId);
833 await collectionRepository.save(collection);
834
835 // Create URL card for otherCurator with the same URL (multiple cards)
836 const urlCard2a = new CardBuilder()
837 .withCuratorId(otherCuratorId.value)
838 .withUrlCard(url)
839 .buildOrThrow();
840
841 await cardRepository.save(urlCard2a);
842 urlCard2a.addToLibrary(otherCuratorId);
843 await cardRepository.save(urlCard2a);
844
845 // Create ANOTHER URL card for otherCurator with the same URL
846 const urlCard2b = new CardBuilder()
847 .withCuratorId(otherCuratorId.value)
848 .withUrlCard(url)
849 .buildOrThrow();
850
851 await cardRepository.save(urlCard2b);
852 urlCard2b.addToLibrary(otherCuratorId);
853 await cardRepository.save(urlCard2b);
854
855 // Query collection cards with second user as callingUserId
856 const result = await queryRepository.getCardsInCollection(
857 collection.collectionId.getStringValue(),
858 {
859 page: 1,
860 limit: 10,
861 sortBy: CardSortField.UPDATED_AT,
862 sortOrder: SortOrder.DESC,
863 },
864 otherCuratorId.value, // callingUserId who has multiple cards with this URL
865 );
866
867 expect(result.items).toHaveLength(1);
868 expect(result.items[0]?.url).toBe(sharedUrl);
869 expect(result.items[0]?.urlInLibrary).toBe(true); // otherCurator has this URL (even with multiple cards)
870 });
871
872 it('should handle multiple URLs in collection with different urlInLibrary values', async () => {
873 // URL 1: otherCurator also has it
874 const url1 = URL.create(
875 'https://example.com/shared-collection-url-1',
876 ).unwrap();
877 const urlCard1a = new CardBuilder()
878 .withCuratorId(curatorId.value)
879 .withUrlCard(url1)
880 .withCreatedAt(new Date('2023-01-01'))
881 .withUpdatedAt(new Date('2023-01-01'))
882 .buildOrThrow();
883
884 await cardRepository.save(urlCard1a);
885
886 const urlCard1b = new CardBuilder()
887 .withCuratorId(otherCuratorId.value)
888 .withUrlCard(url1)
889 .buildOrThrow();
890
891 await cardRepository.save(urlCard1b);
892 urlCard1b.addToLibrary(otherCuratorId);
893 await cardRepository.save(urlCard1b);
894
895 // URL 2: otherCurator does NOT have it
896 const url2 = URL.create(
897 'https://example.com/unique-collection-url-2',
898 ).unwrap();
899 const urlCard2 = new CardBuilder()
900 .withCuratorId(curatorId.value)
901 .withUrlCard(url2)
902 .withCreatedAt(new Date('2023-01-02'))
903 .withUpdatedAt(new Date('2023-01-02'))
904 .buildOrThrow();
905
906 await cardRepository.save(urlCard2);
907
908 // Create collection and add both cards
909 const collection = Collection.create(
910 {
911 authorId: curatorId,
912 name: 'Mixed Collection',
913 accessType: CollectionAccessType.OPEN,
914 collaboratorIds: [],
915 createdAt: new Date(),
916 updatedAt: new Date(),
917 },
918 new UniqueEntityID(),
919 ).unwrap();
920
921 collection.addCard(urlCard1a.cardId, curatorId);
922 collection.addCard(urlCard2.cardId, curatorId);
923 await collectionRepository.save(collection);
924
925 // Query collection cards with otherCurator as callingUserId
926 const result = await queryRepository.getCardsInCollection(
927 collection.collectionId.getStringValue(),
928 {
929 page: 1,
930 limit: 10,
931 sortBy: CardSortField.UPDATED_AT,
932 sortOrder: SortOrder.DESC,
933 },
934 otherCuratorId.value,
935 );
936
937 expect(result.items).toHaveLength(2);
938
939 const card1 = result.items.find((item) => item.url === url1.value);
940 const card2 = result.items.find((item) => item.url === url2.value);
941
942 expect(card1?.urlInLibrary).toBe(true); // otherCurator has this URL
943 expect(card2?.urlInLibrary).toBe(false); // otherCurator doesn't have this URL
944 });
945
946 it('should correctly handle urlInLibrary with third user viewing collection', async () => {
947 const sharedUrl = 'https://example.com/popular-collection-url';
948 const url = URL.create(sharedUrl).unwrap();
949
950 // First user creates card and adds to collection
951 const urlCard1 = new CardBuilder()
952 .withCuratorId(curatorId.value)
953 .withUrlCard(url)
954 .buildOrThrow();
955
956 await cardRepository.save(urlCard1);
957 urlCard1.addToLibrary(curatorId);
958 await cardRepository.save(urlCard1);
959
960 // Create collection and add card
961 const collection = Collection.create(
962 {
963 authorId: curatorId,
964 name: 'Popular Collection',
965 accessType: CollectionAccessType.OPEN,
966 collaboratorIds: [],
967 createdAt: new Date(),
968 updatedAt: new Date(),
969 },
970 new UniqueEntityID(),
971 ).unwrap();
972
973 collection.addCard(urlCard1.cardId, curatorId);
974 await collectionRepository.save(collection);
975
976 // Second user creates card with the same URL
977 const urlCard2 = new CardBuilder()
978 .withCuratorId(otherCuratorId.value)
979 .withUrlCard(url)
980 .buildOrThrow();
981
982 await cardRepository.save(urlCard2);
983 urlCard2.addToLibrary(otherCuratorId);
984 await cardRepository.save(urlCard2);
985
986 // Query collection cards with third user as callingUserId (who doesn't have this URL)
987 const result = await queryRepository.getCardsInCollection(
988 collection.collectionId.getStringValue(),
989 {
990 page: 1,
991 limit: 10,
992 sortBy: CardSortField.UPDATED_AT,
993 sortOrder: SortOrder.DESC,
994 },
995 thirdCuratorId.value, // third user checking
996 );
997
998 expect(result.items).toHaveLength(1);
999 expect(result.items[0]?.url).toBe(sharedUrl);
1000 expect(result.items[0]?.urlInLibrary).toBe(false); // thirdCurator doesn't have this URL
1001 expect(result.items[0]?.urlLibraryCount).toBe(2); // But 2 users have it
1002 });
1003 });
1004});