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