A social knowledge tool for researchers built on ATProto
at main 617 lines 20 kB view raw
1import { GetUrlStatusForMyLibraryUseCase } from '../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; 2import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 3import { InMemoryCardQueryRepository } from '../utils/InMemoryCardQueryRepository'; 4import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 5import { InMemoryCollectionQueryRepository } from '../utils/InMemoryCollectionQueryRepository'; 6import { FakeCardPublisher } from '../utils/FakeCardPublisher'; 7import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 8import { FakeEventPublisher } from '../utils/FakeEventPublisher'; 9import { FakeProfileService } from '../utils/FakeProfileService'; 10import { CuratorId } from '../../domain/value-objects/CuratorId'; 11import { CardBuilder } from '../utils/builders/CardBuilder'; 12import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 13import { CardTypeEnum } from '../../domain/value-objects/CardType'; 14import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; 15import { URL } from '../../domain/value-objects/URL'; 16import { err } from 'src/shared/core/Result'; 17import { ICardRepository } from '../../domain/ICardRepository'; 18 19describe('GetUrlStatusForMyLibraryUseCase', () => { 20 let useCase: GetUrlStatusForMyLibraryUseCase; 21 let cardRepository: InMemoryCardRepository; 22 let cardQueryRepository: InMemoryCardQueryRepository; 23 let collectionRepository: InMemoryCollectionRepository; 24 let collectionQueryRepository: InMemoryCollectionQueryRepository; 25 let cardPublisher: FakeCardPublisher; 26 let collectionPublisher: FakeCollectionPublisher; 27 let eventPublisher: FakeEventPublisher; 28 let profileService: FakeProfileService; 29 let curatorId: CuratorId; 30 let otherCuratorId: CuratorId; 31 32 beforeEach(() => { 33 cardRepository = InMemoryCardRepository.getInstance(); 34 collectionRepository = InMemoryCollectionRepository.getInstance(); 35 cardQueryRepository = new InMemoryCardQueryRepository( 36 cardRepository, 37 collectionRepository, 38 ); 39 collectionQueryRepository = new InMemoryCollectionQueryRepository( 40 collectionRepository, 41 ); 42 cardPublisher = new FakeCardPublisher(); 43 collectionPublisher = new FakeCollectionPublisher(); 44 eventPublisher = new FakeEventPublisher(); 45 profileService = new FakeProfileService(); 46 47 useCase = new GetUrlStatusForMyLibraryUseCase( 48 cardRepository, 49 cardQueryRepository, 50 collectionQueryRepository, 51 collectionRepository, 52 profileService, 53 eventPublisher, 54 ); 55 56 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 57 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 58 59 // Set up profiles 60 profileService.addProfile({ 61 id: curatorId.value, 62 name: 'Test Curator', 63 handle: 'testcurator', 64 avatarUrl: 'https://example.com/avatar1.jpg', 65 bio: 'Test curator bio', 66 }); 67 68 profileService.addProfile({ 69 id: otherCuratorId.value, 70 name: 'Other Curator', 71 handle: 'othercurator', 72 avatarUrl: 'https://example.com/avatar2.jpg', 73 bio: 'Other curator bio', 74 }); 75 }); 76 77 afterEach(() => { 78 cardRepository.clear(); 79 cardQueryRepository.clear(); 80 collectionRepository.clear(); 81 collectionQueryRepository.clear(); 82 cardPublisher.clear(); 83 collectionPublisher.clear(); 84 eventPublisher.clear(); 85 profileService.clear(); 86 }); 87 88 describe('URL card in collections', () => { 89 it('should return card ID and collections when user has URL card in multiple collections', async () => { 90 const testUrl = 'https://example.com/test-article'; 91 92 // Create a URL card 93 const url = URL.create(testUrl).unwrap(); 94 const card = new CardBuilder() 95 .withCuratorId(curatorId.value) 96 .withType(CardTypeEnum.URL) 97 .withUrl(url) 98 .build(); 99 100 if (card instanceof Error) { 101 throw new Error(`Failed to create card: ${card.message}`); 102 } 103 104 // Add card to library 105 const addToLibResult = card.addToLibrary(curatorId); 106 if (addToLibResult.isErr()) { 107 throw new Error( 108 `Failed to add card to library: ${addToLibResult.error.message}`, 109 ); 110 } 111 112 await cardRepository.save(card); 113 114 // Publish the card to simulate it being published 115 cardPublisher.publishCardToLibrary(card, curatorId); 116 117 // Create first collection 118 const collection1 = new CollectionBuilder() 119 .withAuthorId(curatorId.value) 120 .withName('Tech Articles') 121 .withDescription('Collection of technology articles') 122 .build(); 123 124 if (collection1 instanceof Error) { 125 throw new Error(`Failed to create collection1: ${collection1.message}`); 126 } 127 128 // Create second collection 129 const collection2 = new CollectionBuilder() 130 .withAuthorId(curatorId.value) 131 .withName('Reading List') 132 .withDescription('My personal reading list') 133 .build(); 134 135 if (collection2 instanceof Error) { 136 throw new Error(`Failed to create collection2: ${collection2.message}`); 137 } 138 139 // Add card to both collections 140 const addToCollection1Result = collection1.addCard( 141 card.cardId, 142 curatorId, 143 ); 144 if (addToCollection1Result.isErr()) { 145 throw new Error( 146 `Failed to add card to collection1: ${addToCollection1Result.error.message}`, 147 ); 148 } 149 150 const addToCollection2Result = collection2.addCard( 151 card.cardId, 152 curatorId, 153 ); 154 if (addToCollection2Result.isErr()) { 155 throw new Error( 156 `Failed to add card to collection2: ${addToCollection2Result.error.message}`, 157 ); 158 } 159 160 // Mark collections as published 161 const collection1PublishedRecordId = PublishedRecordId.create({ 162 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1', 163 cid: 'bafyreicollection1cid', 164 }); 165 166 const collection2PublishedRecordId = PublishedRecordId.create({ 167 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2', 168 cid: 'bafyreicollection2cid', 169 }); 170 171 collection1.markAsPublished(collection1PublishedRecordId); 172 collection2.markAsPublished(collection2PublishedRecordId); 173 174 // Mark card links as published in collections 175 const cardLinkPublishedRecordId1 = PublishedRecordId.create({ 176 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1/link1', 177 cid: 'bafyreilink1cid', 178 }); 179 180 const cardLinkPublishedRecordId2 = PublishedRecordId.create({ 181 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2/link2', 182 cid: 'bafyreilink2cid', 183 }); 184 185 collection1.markCardLinkAsPublished( 186 card.cardId, 187 cardLinkPublishedRecordId1, 188 ); 189 collection2.markCardLinkAsPublished( 190 card.cardId, 191 cardLinkPublishedRecordId2, 192 ); 193 194 // Save collections 195 await collectionRepository.save(collection1); 196 await collectionRepository.save(collection2); 197 198 // Publish collections and links 199 collectionPublisher.publish(collection1); 200 collectionPublisher.publish(collection2); 201 collectionPublisher.publishCardAddedToCollection( 202 card, 203 collection1, 204 curatorId, 205 ); 206 collectionPublisher.publishCardAddedToCollection( 207 card, 208 collection2, 209 curatorId, 210 ); 211 212 // Execute the use case 213 const query = { 214 url: testUrl, 215 curatorId: curatorId.value, 216 }; 217 218 const result = await useCase.execute(query); 219 220 // Verify the result 221 expect(result.isOk()).toBe(true); 222 const response = result.unwrap(); 223 224 expect(response.card).toBeDefined(); 225 expect(response.card?.id).toBe(card.cardId.getStringValue()); 226 expect(response.collections).toHaveLength(2); 227 228 // Verify collection details 229 const techArticlesCollection = response.collections?.find( 230 (c) => c.name === 'Tech Articles', 231 ); 232 const readingListCollection = response.collections?.find( 233 (c) => c.name === 'Reading List', 234 ); 235 236 expect(techArticlesCollection).toBeDefined(); 237 expect(techArticlesCollection?.id).toBe( 238 collection1.collectionId.getStringValue(), 239 ); 240 expect(techArticlesCollection?.uri).toBe( 241 'at://did:plc:testcurator/network.cosmik.collection/collection1', 242 ); 243 expect(techArticlesCollection?.name).toBe('Tech Articles'); 244 expect(techArticlesCollection?.description).toBe( 245 'Collection of technology articles', 246 ); 247 248 expect(readingListCollection).toBeDefined(); 249 expect(readingListCollection?.id).toBe( 250 collection2.collectionId.getStringValue(), 251 ); 252 expect(readingListCollection?.uri).toBe( 253 'at://did:plc:testcurator/network.cosmik.collection/collection2', 254 ); 255 expect(readingListCollection?.name).toBe('Reading List'); 256 expect(readingListCollection?.description).toBe( 257 'My personal reading list', 258 ); 259 }); 260 261 it('should return card ID and empty collections when user has URL card but not in any collections', async () => { 262 const testUrl = 'https://example.com/standalone-article'; 263 264 // Create a URL card 265 const url = URL.create(testUrl).unwrap(); 266 const card = new CardBuilder() 267 .withCuratorId(curatorId.value) 268 .withType(CardTypeEnum.URL) 269 .withUrl(url) 270 .build(); 271 272 if (card instanceof Error) { 273 throw new Error(`Failed to create card: ${card.message}`); 274 } 275 276 // Add card to library 277 const addToLibResult = card.addToLibrary(curatorId); 278 if (addToLibResult.isErr()) { 279 throw new Error( 280 `Failed to add card to library: ${addToLibResult.error.message}`, 281 ); 282 } 283 284 await cardRepository.save(card); 285 286 // Publish the card 287 cardPublisher.publishCardToLibrary(card, curatorId); 288 289 // Execute the use case 290 const query = { 291 url: testUrl, 292 curatorId: curatorId.value, 293 }; 294 295 const result = await useCase.execute(query); 296 297 // Verify the result 298 expect(result.isOk()).toBe(true); 299 const response = result.unwrap(); 300 301 expect(response.card).toBeDefined(); 302 expect(response.card?.id).toBe(card.cardId.getStringValue()); 303 expect(response.collections).toHaveLength(0); 304 }); 305 306 it('should return empty result when user does not have URL card for the URL', async () => { 307 const testUrl = 'https://example.com/nonexistent-article'; 308 309 // Execute the use case without creating any cards 310 const query = { 311 url: testUrl, 312 curatorId: curatorId.value, 313 }; 314 315 const result = await useCase.execute(query); 316 317 // Verify the result 318 expect(result.isOk()).toBe(true); 319 const response = result.unwrap(); 320 321 expect(response.card).toBeUndefined(); 322 expect(response.collections).toBeUndefined(); 323 }); 324 325 it('should not return collections from other users even if they have the same URL', async () => { 326 const testUrl = 'https://example.com/shared-article'; 327 328 // Create URL card for first user 329 const url = URL.create(testUrl).unwrap(); 330 const card1 = new CardBuilder() 331 .withCuratorId(curatorId.value) 332 .withType(CardTypeEnum.URL) 333 .withUrl(url) 334 .build(); 335 336 if (card1 instanceof Error) { 337 throw new Error(`Failed to create card1: ${card1.message}`); 338 } 339 340 const addToLibResult1 = card1.addToLibrary(curatorId); 341 if (addToLibResult1.isErr()) { 342 throw new Error( 343 `Failed to add card1 to library: ${addToLibResult1.error.message}`, 344 ); 345 } 346 347 await cardRepository.save(card1); 348 349 // Create URL card for second user (different card, same URL) 350 const card2 = new CardBuilder() 351 .withCuratorId(otherCuratorId.value) 352 .withType(CardTypeEnum.URL) 353 .withUrl(url) 354 .build(); 355 356 if (card2 instanceof Error) { 357 throw new Error(`Failed to create card2: ${card2.message}`); 358 } 359 360 const addToLibResult2 = card2.addToLibrary(otherCuratorId); 361 if (addToLibResult2.isErr()) { 362 throw new Error( 363 `Failed to add card2 to library: ${addToLibResult2.error.message}`, 364 ); 365 } 366 367 await cardRepository.save(card2); 368 369 // Create collection for second user and add their card 370 const otherUserCollection = new CollectionBuilder() 371 .withAuthorId(otherCuratorId.value) 372 .withName('Other User Collection') 373 .build(); 374 375 if (otherUserCollection instanceof Error) { 376 throw new Error( 377 `Failed to create other user collection: ${otherUserCollection.message}`, 378 ); 379 } 380 381 const addToOtherCollectionResult = otherUserCollection.addCard( 382 card2.cardId, 383 otherCuratorId, 384 ); 385 if (addToOtherCollectionResult.isErr()) { 386 throw new Error( 387 `Failed to add card2 to other collection: ${addToOtherCollectionResult.error.message}`, 388 ); 389 } 390 391 await collectionRepository.save(otherUserCollection); 392 393 // Execute the use case for first user 394 const query = { 395 url: testUrl, 396 curatorId: curatorId.value, 397 }; 398 399 const result = await useCase.execute(query); 400 401 // Verify the result - should only see first user's card, no collections 402 expect(result.isOk()).toBe(true); 403 const response = result.unwrap(); 404 405 expect(response.card).toBeDefined(); 406 expect(response.card?.id).toBe(card1.cardId.getStringValue()); 407 expect(response.collections).toHaveLength(0); // No collections for first user 408 }); 409 410 it('should only return collections owned by the requesting user', async () => { 411 const testUrl = 'https://example.com/multi-user-article'; 412 413 // Create URL card for the user 414 const url = URL.create(testUrl).unwrap(); 415 const card = new CardBuilder() 416 .withCuratorId(curatorId.value) 417 .withType(CardTypeEnum.URL) 418 .withUrl(url) 419 .build(); 420 421 if (card instanceof Error) { 422 throw new Error(`Failed to create card: ${card.message}`); 423 } 424 425 const addToLibResult = card.addToLibrary(curatorId); 426 if (addToLibResult.isErr()) { 427 throw new Error( 428 `Failed to add card to library: ${addToLibResult.error.message}`, 429 ); 430 } 431 432 await cardRepository.save(card); 433 434 // Create user's own collection 435 const userCollection = new CollectionBuilder() 436 .withAuthorId(curatorId.value) 437 .withName('My Collection') 438 .build(); 439 440 if (userCollection instanceof Error) { 441 throw new Error( 442 `Failed to create user collection: ${userCollection.message}`, 443 ); 444 } 445 446 const addToUserCollectionResult = userCollection.addCard( 447 card.cardId, 448 curatorId, 449 ); 450 if (addToUserCollectionResult.isErr()) { 451 throw new Error( 452 `Failed to add card to user collection: ${addToUserCollectionResult.error.message}`, 453 ); 454 } 455 456 await collectionRepository.save(userCollection); 457 458 // Create another user's collection (this should not appear in results) 459 const otherUserCollection = new CollectionBuilder() 460 .withAuthorId(otherCuratorId.value) 461 .withName('Other User Collection') 462 .build(); 463 464 if (otherUserCollection instanceof Error) { 465 throw new Error( 466 `Failed to create other user collection: ${otherUserCollection.message}`, 467 ); 468 } 469 470 // Note: We don't add the card to the other user's collection since they can't add 471 // another user's card to their collection in this domain model 472 473 await collectionRepository.save(otherUserCollection); 474 475 // Execute the use case 476 const query = { 477 url: testUrl, 478 curatorId: curatorId.value, 479 }; 480 481 const result = await useCase.execute(query); 482 483 // Verify the result - should only see user's own collection 484 expect(result.isOk()).toBe(true); 485 const response = result.unwrap(); 486 487 expect(response.card).toBeDefined(); 488 expect(response.card?.id).toBe(card.cardId.getStringValue()); 489 expect(response.collections).toHaveLength(1); 490 expect(response.collections?.[0]?.name).toBe('My Collection'); 491 expect(response.collections?.[0]?.id).toBe( 492 userCollection.collectionId.getStringValue(), 493 ); 494 }); 495 }); 496 497 describe('Validation', () => { 498 it('should fail with invalid URL', async () => { 499 const query = { 500 url: 'not-a-valid-url', 501 curatorId: curatorId.value, 502 }; 503 504 const result = await useCase.execute(query); 505 506 expect(result.isErr()).toBe(true); 507 if (result.isErr()) { 508 expect(result.error.message).toContain('Invalid URL'); 509 } 510 }); 511 512 it('should fail with invalid curator ID', async () => { 513 const query = { 514 url: 'https://example.com/valid-url', 515 curatorId: 'invalid-curator-id', 516 }; 517 518 const result = await useCase.execute(query); 519 520 expect(result.isErr()).toBe(true); 521 if (result.isErr()) { 522 expect(result.error.message).toContain('Invalid curator ID'); 523 } 524 }); 525 }); 526 527 describe('Error handling', () => { 528 it('should handle repository errors gracefully', async () => { 529 // Create a mock repository that returns an error Result 530 const errorCardRepository: ICardRepository = { 531 findUsersUrlCardByUrl: jest 532 .fn() 533 .mockResolvedValue(err(new Error('Database error'))), 534 save: jest.fn(), 535 findById: jest.fn(), 536 delete: jest.fn(), 537 findUsersNoteCardByUrl: jest.fn(), 538 }; 539 540 const errorUseCase = new GetUrlStatusForMyLibraryUseCase( 541 errorCardRepository, 542 cardQueryRepository, 543 collectionQueryRepository, 544 collectionRepository, 545 profileService, 546 eventPublisher, 547 ); 548 549 const query = { 550 url: 'https://example.com/test-url', 551 curatorId: curatorId.value, 552 }; 553 554 const result = await errorUseCase.execute(query); 555 556 expect(result.isErr()).toBe(true); 557 if (result.isErr()) { 558 expect(result.error.message).toContain('Database error'); 559 } 560 }); 561 562 it('should handle collection query repository errors gracefully', async () => { 563 const testUrl = 'https://example.com/error-test'; 564 565 // Create a URL card 566 const url = URL.create(testUrl).unwrap(); 567 const card = new CardBuilder() 568 .withCuratorId(curatorId.value) 569 .withType(CardTypeEnum.URL) 570 .withUrl(url) 571 .build(); 572 573 if (card instanceof Error) { 574 throw new Error(`Failed to create card: ${card.message}`); 575 } 576 577 const addToLibResult = card.addToLibrary(curatorId); 578 if (addToLibResult.isErr()) { 579 throw new Error( 580 `Failed to add card to library: ${addToLibResult.error.message}`, 581 ); 582 } 583 584 await cardRepository.save(card); 585 586 // Create a mock collection query repository that throws an error 587 const errorCollectionQueryRepository = { 588 findByCreator: jest.fn(), 589 getCollectionsContainingCardForUser: jest 590 .fn() 591 .mockRejectedValue(new Error('Collection query error')), 592 getCollectionsWithUrl: jest.fn(), 593 }; 594 595 const errorUseCase = new GetUrlStatusForMyLibraryUseCase( 596 cardRepository, 597 cardQueryRepository, 598 errorCollectionQueryRepository, 599 collectionRepository, 600 profileService, 601 eventPublisher, 602 ); 603 604 const query = { 605 url: testUrl, 606 curatorId: curatorId.value, 607 }; 608 609 const result = await errorUseCase.execute(query); 610 611 expect(result.isErr()).toBe(true); 612 if (result.isErr()) { 613 expect(result.error.message).toContain('Collection query error'); 614 } 615 }); 616 }); 617});