A social knowledge tool for researchers built on ATProto
1import { GetCollectionsForUrlUseCase } from '../../application/useCases/queries/GetCollectionsForUrlUseCase';
2import { InMemoryCardRepository } from '../utils/InMemoryCardRepository';
3import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository';
4import { InMemoryCollectionQueryRepository } from '../utils/InMemoryCollectionQueryRepository';
5import { CuratorId } from '../../domain/value-objects/CuratorId';
6import { CardBuilder } from '../utils/builders/CardBuilder';
7import { CollectionBuilder } from '../utils/builders/CollectionBuilder';
8import { CardTypeEnum } from '../../domain/value-objects/CardType';
9import { URL } from '../../domain/value-objects/URL';
10import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId';
11import {
12 CollectionSortField,
13 SortOrder,
14} from '../../domain/ICollectionQueryRepository';
15import { FakeProfileService } from '../utils/FakeProfileService';
16
17describe('GetCollectionsForUrlUseCase', () => {
18 let useCase: GetCollectionsForUrlUseCase;
19 let cardRepository: InMemoryCardRepository;
20 let collectionRepository: InMemoryCollectionRepository;
21 let collectionQueryRepository: InMemoryCollectionQueryRepository;
22 let profileService: FakeProfileService;
23 let curator1: CuratorId;
24 let curator2: CuratorId;
25 let curator3: CuratorId;
26
27 beforeEach(() => {
28 cardRepository = InMemoryCardRepository.getInstance();
29 collectionRepository = InMemoryCollectionRepository.getInstance();
30 collectionQueryRepository = new InMemoryCollectionQueryRepository(
31 collectionRepository,
32 cardRepository,
33 );
34 profileService = new FakeProfileService();
35
36 useCase = new GetCollectionsForUrlUseCase(
37 collectionQueryRepository,
38 profileService,
39 collectionRepository,
40 );
41
42 curator1 = CuratorId.create('did:plc:curator1').unwrap();
43 curator2 = CuratorId.create('did:plc:curator2').unwrap();
44 curator3 = CuratorId.create('did:plc:curator3').unwrap();
45
46 // Add profiles for test curators
47 profileService.addProfile({
48 id: curator1.value,
49 name: 'Curator One',
50 handle: 'curator1',
51 avatarUrl: 'https://example.com/avatar1.jpg',
52 });
53
54 profileService.addProfile({
55 id: curator2.value,
56 name: 'Curator Two',
57 handle: 'curator2',
58 avatarUrl: 'https://example.com/avatar2.jpg',
59 });
60
61 profileService.addProfile({
62 id: curator3.value,
63 name: 'Curator Three',
64 handle: 'curator3',
65 avatarUrl: 'https://example.com/avatar3.jpg',
66 });
67 });
68
69 afterEach(() => {
70 cardRepository.clear();
71 collectionRepository.clear();
72 collectionQueryRepository.clear();
73 profileService.clear();
74 });
75
76 describe('Collections with URL cards', () => {
77 it('should return all collections containing cards with the specified URL', async () => {
78 const testUrl = 'https://example.com/shared-article';
79 const url = URL.create(testUrl).unwrap();
80
81 // Create URL cards for different users with the same URL
82 const card1 = new CardBuilder()
83 .withCuratorId(curator1.value)
84 .withType(CardTypeEnum.URL)
85 .withUrl(url)
86 .build();
87
88 const card2 = new CardBuilder()
89 .withCuratorId(curator2.value)
90 .withType(CardTypeEnum.URL)
91 .withUrl(url)
92 .build();
93
94 const card3 = new CardBuilder()
95 .withCuratorId(curator3.value)
96 .withType(CardTypeEnum.URL)
97 .withUrl(url)
98 .build();
99
100 if (
101 card1 instanceof Error ||
102 card2 instanceof Error ||
103 card3 instanceof Error
104 ) {
105 throw new Error('Failed to create cards');
106 }
107
108 // Add cards to their respective libraries
109 card1.addToLibrary(curator1);
110 card2.addToLibrary(curator2);
111 card3.addToLibrary(curator3);
112
113 await cardRepository.save(card1);
114 await cardRepository.save(card2);
115 await cardRepository.save(card3);
116
117 // Create collections for each user and add their cards
118 const collection1 = new CollectionBuilder()
119 .withAuthorId(curator1.value)
120 .withName('Tech Articles')
121 .withDescription('My tech articles')
122 .build();
123
124 const collection2 = new CollectionBuilder()
125 .withAuthorId(curator2.value)
126 .withName('Reading List')
127 .withDescription('Articles to read')
128 .build();
129
130 const collection3 = new CollectionBuilder()
131 .withAuthorId(curator3.value)
132 .withName('Favorites')
133 .build();
134
135 if (
136 collection1 instanceof Error ||
137 collection2 instanceof Error ||
138 collection3 instanceof Error
139 ) {
140 throw new Error('Failed to create collections');
141 }
142
143 // Add cards to collections
144 collection1.addCard(card1.cardId, curator1);
145 collection2.addCard(card2.cardId, curator2);
146 collection3.addCard(card3.cardId, curator3);
147
148 // Mark collections as published
149 const publishedRecordId1 = PublishedRecordId.create({
150 uri: 'at://did:plc:curator1/network.cosmik.collection/collection1',
151 cid: 'bafyreicollection1',
152 });
153 const publishedRecordId2 = PublishedRecordId.create({
154 uri: 'at://did:plc:curator2/network.cosmik.collection/collection2',
155 cid: 'bafyreicollection2',
156 });
157 const publishedRecordId3 = PublishedRecordId.create({
158 uri: 'at://did:plc:curator3/network.cosmik.collection/collection3',
159 cid: 'bafyreicollection3',
160 });
161
162 collection1.markAsPublished(publishedRecordId1);
163 collection2.markAsPublished(publishedRecordId2);
164 collection3.markAsPublished(publishedRecordId3);
165
166 await collectionRepository.save(collection1);
167 await collectionRepository.save(collection2);
168 await collectionRepository.save(collection3);
169
170 // Execute the use case
171 const query = {
172 url: testUrl,
173 };
174
175 const result = await useCase.execute(query);
176
177 // Verify the result
178 expect(result.isOk()).toBe(true);
179 const response = result.unwrap();
180
181 expect(response.collections).toHaveLength(3);
182 expect(response.pagination.totalCount).toBe(3);
183
184 // Check that all three collections are included
185 const collectionIds = response.collections.map((c) => c.id);
186 expect(collectionIds).toContain(
187 collection1.collectionId.getStringValue(),
188 );
189 expect(collectionIds).toContain(
190 collection2.collectionId.getStringValue(),
191 );
192 expect(collectionIds).toContain(
193 collection3.collectionId.getStringValue(),
194 );
195
196 // Verify collection details
197 const techArticles = response.collections.find(
198 (c) => c.name === 'Tech Articles',
199 );
200 expect(techArticles).toBeDefined();
201 expect(techArticles?.description).toBe('My tech articles');
202 expect(techArticles?.author.id).toBe(curator1.value);
203 expect(techArticles?.uri).toBe(
204 'at://did:plc:curator1/network.cosmik.collection/collection1',
205 );
206
207 const readingList = response.collections.find(
208 (c) => c.name === 'Reading List',
209 );
210 expect(readingList).toBeDefined();
211 expect(readingList?.description).toBe('Articles to read');
212 expect(readingList?.author.id).toBe(curator2.value);
213
214 const favorites = response.collections.find(
215 (c) => c.name === 'Favorites',
216 );
217 expect(favorites).toBeDefined();
218 expect(favorites?.description).toBeUndefined();
219 expect(favorites?.author.id).toBe(curator3.value);
220 });
221
222 it('should return empty array when no collections contain cards with the specified URL', async () => {
223 const testUrl = 'https://example.com/nonexistent-article';
224
225 const query = {
226 url: testUrl,
227 };
228
229 const result = await useCase.execute(query);
230
231 expect(result.isOk()).toBe(true);
232 const response = result.unwrap();
233
234 expect(response.collections).toHaveLength(0);
235 expect(response.pagination.totalCount).toBe(0);
236 });
237
238 it('should not return collections that contain cards with different URLs', async () => {
239 const testUrl1 = 'https://example.com/article1';
240 const testUrl2 = 'https://example.com/article2';
241 const url1 = URL.create(testUrl1).unwrap();
242 const url2 = URL.create(testUrl2).unwrap();
243
244 // Create cards with different URLs
245 const card1 = new CardBuilder()
246 .withCuratorId(curator1.value)
247 .withType(CardTypeEnum.URL)
248 .withUrl(url1)
249 .build();
250
251 const card2 = new CardBuilder()
252 .withCuratorId(curator2.value)
253 .withType(CardTypeEnum.URL)
254 .withUrl(url2)
255 .build();
256
257 if (card1 instanceof Error || card2 instanceof Error) {
258 throw new Error('Failed to create cards');
259 }
260
261 card1.addToLibrary(curator1);
262 card2.addToLibrary(curator2);
263
264 await cardRepository.save(card1);
265 await cardRepository.save(card2);
266
267 // Create collections
268 const collection1 = new CollectionBuilder()
269 .withAuthorId(curator1.value)
270 .withName('Collection 1')
271 .build();
272
273 const collection2 = new CollectionBuilder()
274 .withAuthorId(curator2.value)
275 .withName('Collection 2')
276 .build();
277
278 if (collection1 instanceof Error || collection2 instanceof Error) {
279 throw new Error('Failed to create collections');
280 }
281
282 collection1.addCard(card1.cardId, curator1);
283 collection2.addCard(card2.cardId, curator2);
284
285 await collectionRepository.save(collection1);
286 await collectionRepository.save(collection2);
287
288 // Query for testUrl1
289 const query = {
290 url: testUrl1,
291 };
292
293 const result = await useCase.execute(query);
294
295 expect(result.isOk()).toBe(true);
296 const response = result.unwrap();
297
298 expect(response.collections).toHaveLength(1);
299 expect(response.collections[0]!.name).toBe('Collection 1');
300 expect(response.collections[0]!.author.id).toBe(curator1.value);
301 });
302
303 it('should return multiple collections from the same user if they contain the URL', async () => {
304 const testUrl = 'https://example.com/popular-article';
305 const url = URL.create(testUrl).unwrap();
306
307 // Create URL card
308 const card = new CardBuilder()
309 .withCuratorId(curator1.value)
310 .withType(CardTypeEnum.URL)
311 .withUrl(url)
312 .build();
313
314 if (card instanceof Error) {
315 throw new Error('Failed to create card');
316 }
317
318 card.addToLibrary(curator1);
319 await cardRepository.save(card);
320
321 // Create multiple collections for the same user
322 const collection1 = new CollectionBuilder()
323 .withAuthorId(curator1.value)
324 .withName('Tech')
325 .build();
326
327 const collection2 = new CollectionBuilder()
328 .withAuthorId(curator1.value)
329 .withName('Favorites')
330 .build();
331
332 const collection3 = new CollectionBuilder()
333 .withAuthorId(curator1.value)
334 .withName('To Read')
335 .build();
336
337 if (
338 collection1 instanceof Error ||
339 collection2 instanceof Error ||
340 collection3 instanceof Error
341 ) {
342 throw new Error('Failed to create collections');
343 }
344
345 // Add the same card to all collections
346 collection1.addCard(card.cardId, curator1);
347 collection2.addCard(card.cardId, curator1);
348 collection3.addCard(card.cardId, curator1);
349
350 await collectionRepository.save(collection1);
351 await collectionRepository.save(collection2);
352 await collectionRepository.save(collection3);
353
354 // Execute the use case
355 const query = {
356 url: testUrl,
357 };
358
359 const result = await useCase.execute(query);
360
361 expect(result.isOk()).toBe(true);
362 const response = result.unwrap();
363
364 expect(response.collections).toHaveLength(3);
365
366 const collectionNames = response.collections.map((c) => c.name);
367 expect(collectionNames).toContain('Tech');
368 expect(collectionNames).toContain('Favorites');
369 expect(collectionNames).toContain('To Read');
370
371 // All should have the same author
372 response.collections.forEach((collection) => {
373 expect(collection.author.id).toBe(curator1.value);
374 });
375 });
376
377 it('should handle collections without published record IDs', async () => {
378 const testUrl = 'https://example.com/unpublished-article';
379 const url = URL.create(testUrl).unwrap();
380
381 // Create URL card
382 const card = new CardBuilder()
383 .withCuratorId(curator1.value)
384 .withType(CardTypeEnum.URL)
385 .withUrl(url)
386 .build();
387
388 if (card instanceof Error) {
389 throw new Error('Failed to create card');
390 }
391
392 card.addToLibrary(curator1);
393 await cardRepository.save(card);
394
395 // Create collection without publishing it
396 const collection = new CollectionBuilder()
397 .withAuthorId(curator1.value)
398 .withName('Unpublished Collection')
399 .build();
400
401 if (collection instanceof Error) {
402 throw new Error('Failed to create collection');
403 }
404
405 collection.addCard(card.cardId, curator1);
406 await collectionRepository.save(collection);
407
408 // Execute the use case
409 const query = {
410 url: testUrl,
411 };
412
413 const result = await useCase.execute(query);
414
415 expect(result.isOk()).toBe(true);
416 const response = result.unwrap();
417
418 expect(response.collections).toHaveLength(1);
419 expect(response.collections[0]!.name).toBe('Unpublished Collection');
420 expect(response.collections[0]!.uri).toBeUndefined();
421 });
422 });
423
424 describe('Pagination', () => {
425 it('should paginate results correctly', async () => {
426 const testUrl = 'https://example.com/popular-article';
427 const url = URL.create(testUrl).unwrap();
428
429 // Create 5 cards with the same URL from different users
430 const cards = [];
431 const curators = [];
432 const collections = [];
433
434 for (let i = 1; i <= 5; i++) {
435 const curator = CuratorId.create(`did:plc:curator${i}`).unwrap();
436 curators.push(curator);
437
438 // Add profile for this curator
439 profileService.addProfile({
440 id: curator.value,
441 name: `Curator ${i}`,
442 handle: `curator${i}`,
443 avatarUrl: `https://example.com/avatar${i}.jpg`,
444 });
445
446 const card = new CardBuilder()
447 .withCuratorId(curator.value)
448 .withType(CardTypeEnum.URL)
449 .withUrl(url)
450 .build();
451
452 if (card instanceof Error) {
453 throw new Error(`Failed to create card ${i}`);
454 }
455
456 card.addToLibrary(curator);
457 cards.push(card);
458 await cardRepository.save(card);
459
460 // Create collection for each user
461 const collection = new CollectionBuilder()
462 .withAuthorId(curator.value)
463 .withName(`Collection ${i}`)
464 .build();
465
466 if (collection instanceof Error) {
467 throw new Error(`Failed to create collection ${i}`);
468 }
469
470 collection.addCard(card.cardId, curator);
471 collections.push(collection);
472 await collectionRepository.save(collection);
473 }
474
475 // Test first page with limit 2
476 const query1 = {
477 url: testUrl,
478 page: 1,
479 limit: 2,
480 };
481
482 const result1 = await useCase.execute(query1);
483 expect(result1.isOk()).toBe(true);
484 const response1 = result1.unwrap();
485
486 expect(response1.collections).toHaveLength(2);
487 expect(response1.pagination.currentPage).toBe(1);
488 expect(response1.pagination.totalCount).toBe(5);
489 expect(response1.pagination.totalPages).toBe(3);
490 expect(response1.pagination.hasMore).toBe(true);
491
492 // Test second page
493 const query2 = {
494 url: testUrl,
495 page: 2,
496 limit: 2,
497 };
498
499 const result2 = await useCase.execute(query2);
500 expect(result2.isOk()).toBe(true);
501 const response2 = result2.unwrap();
502
503 expect(response2.collections).toHaveLength(2);
504 expect(response2.pagination.currentPage).toBe(2);
505 expect(response2.pagination.hasMore).toBe(true);
506
507 // Test last page
508 const query3 = {
509 url: testUrl,
510 page: 3,
511 limit: 2,
512 };
513
514 const result3 = await useCase.execute(query3);
515 expect(result3.isOk()).toBe(true);
516 const response3 = result3.unwrap();
517
518 expect(response3.collections).toHaveLength(1);
519 expect(response3.pagination.currentPage).toBe(3);
520 expect(response3.pagination.hasMore).toBe(false);
521 });
522
523 it('should respect limit cap of 100', async () => {
524 const query = {
525 url: 'https://example.com/test',
526 limit: 200, // Should be capped at 100
527 };
528
529 const result = await useCase.execute(query);
530 expect(result.isOk()).toBe(true);
531 const response = result.unwrap();
532
533 expect(response.pagination.limit).toBe(100);
534 });
535
536 it('should use default pagination values', async () => {
537 const testUrl = 'https://example.com/test-article';
538
539 const query = {
540 url: testUrl,
541 };
542
543 const result = await useCase.execute(query);
544 expect(result.isOk()).toBe(true);
545 const response = result.unwrap();
546
547 expect(response.pagination.currentPage).toBe(1);
548 expect(response.pagination.limit).toBe(20);
549 });
550 });
551
552 describe('Sorting', () => {
553 it('should use default sorting parameters (NAME, ASC)', async () => {
554 const testUrl = 'https://example.com/test-article';
555
556 const query = {
557 url: testUrl,
558 };
559
560 const result = await useCase.execute(query);
561 expect(result.isOk()).toBe(true);
562 const response = result.unwrap();
563
564 expect(response.sorting.sortBy).toBe(CollectionSortField.NAME);
565 expect(response.sorting.sortOrder).toBe(SortOrder.ASC);
566 });
567
568 it('should use provided sorting parameters', async () => {
569 const testUrl = 'https://example.com/test-article';
570
571 const query = {
572 url: testUrl,
573 sortBy: CollectionSortField.CREATED_AT,
574 sortOrder: SortOrder.DESC,
575 };
576
577 const result = await useCase.execute(query);
578 expect(result.isOk()).toBe(true);
579 const response = result.unwrap();
580
581 expect(response.sorting.sortBy).toBe(CollectionSortField.CREATED_AT);
582 expect(response.sorting.sortOrder).toBe(SortOrder.DESC);
583 });
584
585 it('should sort collections by name in ascending order by default', async () => {
586 const testUrl = 'https://example.com/article';
587 const url = URL.create(testUrl).unwrap();
588
589 // Create URL cards
590 const card1 = new CardBuilder()
591 .withCuratorId(curator1.value)
592 .withType(CardTypeEnum.URL)
593 .withUrl(url)
594 .build();
595
596 const card2 = new CardBuilder()
597 .withCuratorId(curator2.value)
598 .withType(CardTypeEnum.URL)
599 .withUrl(url)
600 .build();
601
602 const card3 = new CardBuilder()
603 .withCuratorId(curator3.value)
604 .withType(CardTypeEnum.URL)
605 .withUrl(url)
606 .build();
607
608 if (
609 card1 instanceof Error ||
610 card2 instanceof Error ||
611 card3 instanceof Error
612 ) {
613 throw new Error('Failed to create cards');
614 }
615
616 card1.addToLibrary(curator1);
617 card2.addToLibrary(curator2);
618 card3.addToLibrary(curator3);
619
620 await cardRepository.save(card1);
621 await cardRepository.save(card2);
622 await cardRepository.save(card3);
623
624 // Create collections with names that should be sorted
625 const collectionZ = new CollectionBuilder()
626 .withAuthorId(curator1.value)
627 .withName('Zebra Collection')
628 .build();
629
630 const collectionA = new CollectionBuilder()
631 .withAuthorId(curator2.value)
632 .withName('Apple Collection')
633 .build();
634
635 const collectionM = new CollectionBuilder()
636 .withAuthorId(curator3.value)
637 .withName('Mango Collection')
638 .build();
639
640 if (
641 collectionZ instanceof Error ||
642 collectionA instanceof Error ||
643 collectionM instanceof Error
644 ) {
645 throw new Error('Failed to create collections');
646 }
647
648 collectionZ.addCard(card1.cardId, curator1);
649 collectionA.addCard(card2.cardId, curator2);
650 collectionM.addCard(card3.cardId, curator3);
651
652 await collectionRepository.save(collectionZ);
653 await collectionRepository.save(collectionA);
654 await collectionRepository.save(collectionM);
655
656 const query = {
657 url: testUrl,
658 };
659
660 const result = await useCase.execute(query);
661 expect(result.isOk()).toBe(true);
662 const response = result.unwrap();
663
664 expect(response.collections).toHaveLength(3);
665 expect(response.collections[0]!.name).toBe('Apple Collection');
666 expect(response.collections[1]!.name).toBe('Mango Collection');
667 expect(response.collections[2]!.name).toBe('Zebra Collection');
668 });
669
670 it('should sort collections by name in descending order', async () => {
671 const testUrl = 'https://example.com/article';
672 const url = URL.create(testUrl).unwrap();
673
674 // Create URL cards
675 const card1 = new CardBuilder()
676 .withCuratorId(curator1.value)
677 .withType(CardTypeEnum.URL)
678 .withUrl(url)
679 .build();
680
681 const card2 = new CardBuilder()
682 .withCuratorId(curator2.value)
683 .withType(CardTypeEnum.URL)
684 .withUrl(url)
685 .build();
686
687 const card3 = new CardBuilder()
688 .withCuratorId(curator3.value)
689 .withType(CardTypeEnum.URL)
690 .withUrl(url)
691 .build();
692
693 if (
694 card1 instanceof Error ||
695 card2 instanceof Error ||
696 card3 instanceof Error
697 ) {
698 throw new Error('Failed to create cards');
699 }
700
701 card1.addToLibrary(curator1);
702 card2.addToLibrary(curator2);
703 card3.addToLibrary(curator3);
704
705 await cardRepository.save(card1);
706 await cardRepository.save(card2);
707 await cardRepository.save(card3);
708
709 // Create collections with names that should be sorted
710 const collectionZ = new CollectionBuilder()
711 .withAuthorId(curator1.value)
712 .withName('Zebra Collection')
713 .build();
714
715 const collectionA = new CollectionBuilder()
716 .withAuthorId(curator2.value)
717 .withName('Apple Collection')
718 .build();
719
720 const collectionM = new CollectionBuilder()
721 .withAuthorId(curator3.value)
722 .withName('Mango Collection')
723 .build();
724
725 if (
726 collectionZ instanceof Error ||
727 collectionA instanceof Error ||
728 collectionM instanceof Error
729 ) {
730 throw new Error('Failed to create collections');
731 }
732
733 collectionZ.addCard(card1.cardId, curator1);
734 collectionA.addCard(card2.cardId, curator2);
735 collectionM.addCard(card3.cardId, curator3);
736
737 await collectionRepository.save(collectionZ);
738 await collectionRepository.save(collectionA);
739 await collectionRepository.save(collectionM);
740
741 const query = {
742 url: testUrl,
743 sortBy: CollectionSortField.NAME,
744 sortOrder: SortOrder.DESC,
745 };
746
747 const result = await useCase.execute(query);
748 expect(result.isOk()).toBe(true);
749 const response = result.unwrap();
750
751 expect(response.collections).toHaveLength(3);
752 expect(response.collections[0]!.name).toBe('Zebra Collection');
753 expect(response.collections[1]!.name).toBe('Mango Collection');
754 expect(response.collections[2]!.name).toBe('Apple Collection');
755 });
756 });
757
758 describe('Validation', () => {
759 it('should fail with invalid URL', async () => {
760 const query = {
761 url: 'not-a-valid-url',
762 };
763
764 const result = await useCase.execute(query);
765
766 expect(result.isErr()).toBe(true);
767 if (result.isErr()) {
768 expect(result.error.message).toContain('Invalid URL');
769 }
770 });
771 });
772
773 describe('Error handling', () => {
774 it('should handle repository errors gracefully', async () => {
775 // Create a mock repository that throws an error
776 const errorCollectionQueryRepository = {
777 findByCreator: jest.fn(),
778 getCollectionsContainingCardForUser: jest.fn(),
779 getCollectionsWithUrl: jest
780 .fn()
781 .mockRejectedValue(new Error('Database error')),
782 };
783
784 const errorUseCase = new GetCollectionsForUrlUseCase(
785 errorCollectionQueryRepository,
786 profileService,
787 collectionRepository,
788 );
789
790 const query = {
791 url: 'https://example.com/test-url',
792 };
793
794 const result = await errorUseCase.execute(query);
795
796 expect(result.isErr()).toBe(true);
797 if (result.isErr()) {
798 expect(result.error.message).toContain('Database error');
799 }
800 });
801 });
802});