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