A social knowledge tool for researchers built on ATProto
at development 762 lines 24 kB view raw
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});