A social knowledge tool for researchers built on ATProto
1import { RemoveCardFromLibraryUseCase } from '../../application/useCases/commands/RemoveCardFromLibraryUseCase'; 2import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 3import { FakeCardPublisher } from '../utils/FakeCardPublisher'; 4import { CardLibraryService } from '../../domain/services/CardLibraryService'; 5import { CuratorId } from '../../domain/value-objects/CuratorId'; 6import { CardBuilder } from '../utils/builders/CardBuilder'; 7import { CardTypeEnum } from '../../domain/value-objects/CardType'; 8import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 9import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 10import { CardCollectionService } from '../../domain/services/CardCollectionService'; 11import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 12 13describe('RemoveCardFromLibraryUseCase', () => { 14 let useCase: RemoveCardFromLibraryUseCase; 15 let cardRepository: InMemoryCardRepository; 16 let collectionRepository: InMemoryCollectionRepository; 17 let cardPublisher: FakeCardPublisher; 18 let collectionPublisher: FakeCollectionPublisher; 19 let cardCollectionService: CardCollectionService; 20 let cardLibraryService: CardLibraryService; 21 let curatorId: CuratorId; 22 let otherCuratorId: CuratorId; 23 24 beforeEach(() => { 25 cardRepository = new InMemoryCardRepository(); 26 cardPublisher = new FakeCardPublisher(); 27 collectionRepository = new InMemoryCollectionRepository(); 28 collectionPublisher = new FakeCollectionPublisher(); 29 cardCollectionService = new CardCollectionService( 30 collectionRepository, 31 collectionPublisher, 32 ); 33 cardLibraryService = new CardLibraryService( 34 cardRepository, 35 cardPublisher, 36 collectionRepository, 37 cardCollectionService, 38 ); 39 40 useCase = new RemoveCardFromLibraryUseCase( 41 cardRepository, 42 cardLibraryService, 43 ); 44 45 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 46 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 47 }); 48 49 afterEach(() => { 50 cardRepository.clear(); 51 cardPublisher.clear(); 52 }); 53 54 const createCard = async ( 55 type: CardTypeEnum = CardTypeEnum.URL, 56 creatorId: CuratorId = curatorId, 57 ) => { 58 const card = new CardBuilder() 59 .withType(type) 60 .withCuratorId(creatorId.value) 61 .build(); 62 63 if (card instanceof Error) { 64 throw new Error(`Failed to create card: ${card.message}`); 65 } 66 67 await cardRepository.save(card); 68 return card; 69 }; 70 71 const addCardToLibrary = async (card: any, curatorId: CuratorId) => { 72 // For URL cards, can only add to creator's library 73 if (card.isUrlCard && !card.curatorId.equals(curatorId)) { 74 throw new Error("URL cards can only be added to creator's library"); 75 } 76 77 const addResult = await cardLibraryService.addCardToLibrary( 78 card, 79 curatorId, 80 ); 81 if (addResult.isErr()) { 82 throw new Error( 83 `Failed to add card to library: ${addResult.error.message}`, 84 ); 85 } 86 }; 87 88 const createCollection = async ( 89 curatorId: CuratorId, 90 name: string = 'Test Collection', 91 ) => { 92 const collection = new CollectionBuilder() 93 .withAuthorId(curatorId.value) 94 .withName(name) 95 .build(); 96 97 if (collection instanceof Error) { 98 throw new Error(`Failed to create collection: ${collection.message}`); 99 } 100 101 await collectionRepository.save(collection); 102 return collection; 103 }; 104 105 const addCardToCollection = async ( 106 card: any, 107 collection: any, 108 curatorId: CuratorId, 109 ) => { 110 const addResult = await cardCollectionService.addCardToCollection( 111 card, 112 collection.collectionId, 113 curatorId, 114 ); 115 if (addResult.isErr()) { 116 throw new Error( 117 `Failed to add card to collection: ${addResult.error.message}`, 118 ); 119 } 120 }; 121 122 describe('Basic card removal from library', () => { 123 it('should successfully remove card from library', async () => { 124 const card = await createCard(); 125 126 // Add card to library first 127 await addCardToLibrary(card, curatorId); 128 129 const request = { 130 cardId: card.cardId.getStringValue(), 131 curatorId: curatorId.value, 132 }; 133 134 const result = await useCase.execute(request); 135 136 expect(result.isOk()).toBe(true); 137 const response = result.unwrap(); 138 expect(response.cardId).toBe(card.cardId.getStringValue()); 139 140 // Verify card was removed from library and deleted (since it's no longer in any libraries and curator is owner) 141 const updatedCardResult = await cardRepository.findById(card.cardId); 142 const updatedCard = updatedCardResult.unwrap(); 143 expect(updatedCard).toBeNull(); 144 145 // Verify unpublish operation occurred 146 const unpublishedCards = cardPublisher.getUnpublishedCards(); 147 expect(unpublishedCards).toHaveLength(1); 148 }); 149 150 it("should remove URL card from creator's library", async () => { 151 const card = await createCard(); 152 153 // Add card to creator's library only (URL cards can only be in creator's library) 154 await addCardToLibrary(card, curatorId); 155 156 const request = { 157 cardId: card.cardId.getStringValue(), 158 curatorId: curatorId.value, 159 }; 160 161 const result = await useCase.execute(request); 162 163 expect(result.isOk()).toBe(true); 164 165 // Verify card was removed from creator's library and deleted (since it's no longer in any libraries and curator is owner) 166 const updatedCardResult = await cardRepository.findById(card.cardId); 167 const updatedCard = updatedCardResult.unwrap(); 168 expect(updatedCard).toBeNull(); 169 170 // Verify unpublish operation occurred 171 const unpublishedCards = cardPublisher.getUnpublishedCards(); 172 expect(unpublishedCards).toHaveLength(1); 173 }); 174 175 it('should handle different card types', async () => { 176 // Create URL card with curatorId as creator 177 const urlCard = await createCard(CardTypeEnum.URL, curatorId); 178 // Create note card with curatorId as creator 179 const noteCard = await createCard(CardTypeEnum.NOTE, curatorId); 180 181 await addCardToLibrary(urlCard, curatorId); 182 await addCardToLibrary(noteCard, curatorId); 183 184 // Remove URL card 185 const urlRequest = { 186 cardId: urlCard.cardId.getStringValue(), 187 curatorId: curatorId.value, 188 }; 189 190 const urlResult = await useCase.execute(urlRequest); 191 expect(urlResult.isOk()).toBe(true); 192 193 // Remove note card 194 const noteRequest = { 195 cardId: noteCard.cardId.getStringValue(), 196 curatorId: curatorId.value, 197 }; 198 199 const noteResult = await useCase.execute(noteRequest); 200 expect(noteResult.isOk()).toBe(true); 201 202 // Verify both cards were removed from library and deleted (since they're no longer in any libraries and curator is owner) 203 const updatedUrlCardResult = await cardRepository.findById( 204 urlCard.cardId, 205 ); 206 const updatedNoteCardResult = await cardRepository.findById( 207 noteCard.cardId, 208 ); 209 210 const updatedUrlCard = updatedUrlCardResult.unwrap(); 211 const updatedNoteCard = updatedNoteCardResult.unwrap(); 212 213 expect(updatedUrlCard).toBeNull(); 214 expect(updatedNoteCard).toBeNull(); 215 }); 216 }); 217 218 describe('Validation', () => { 219 it('should fail with invalid card ID', async () => { 220 const request = { 221 cardId: 'invalid-card-id', 222 curatorId: curatorId.value, 223 }; 224 225 const result = await useCase.execute(request); 226 227 expect(result.isErr()).toBe(true); 228 if (result.isErr()) { 229 expect(result.error.message).toContain('Card not found'); 230 } 231 }); 232 233 it('should fail with invalid curator ID', async () => { 234 const card = await createCard(); 235 236 const request = { 237 cardId: card.cardId.getStringValue(), 238 curatorId: 'invalid-curator-id', 239 }; 240 241 const result = await useCase.execute(request); 242 243 expect(result.isErr()).toBe(true); 244 if (result.isErr()) { 245 expect(result.error.message).toContain('Invalid curator ID'); 246 } 247 }); 248 249 it('should fail when card does not exist', async () => { 250 const request = { 251 cardId: 'non-existent-card-id', 252 curatorId: curatorId.value, 253 }; 254 255 const result = await useCase.execute(request); 256 257 expect(result.isErr()).toBe(true); 258 if (result.isErr()) { 259 expect(result.error.message).toContain('Card not found'); 260 } 261 }); 262 }); 263 264 describe('Publishing integration', () => { 265 it('should unpublish card from library when removed', async () => { 266 const card = await createCard(); 267 await addCardToLibrary(card, curatorId); 268 269 const initialUnpublishCount = cardPublisher.getUnpublishedCards().length; 270 271 const request = { 272 cardId: card.cardId.getStringValue(), 273 curatorId: curatorId.value, 274 }; 275 276 const result = await useCase.execute(request); 277 278 expect(result.isOk()).toBe(true); 279 280 // Verify unpublish operation occurred 281 const finalUnpublishCount = cardPublisher.getUnpublishedCards().length; 282 expect(finalUnpublishCount).toBe(initialUnpublishCount + 1); 283 284 // Verify the correct card was unpublished 285 const unpublishedCards = cardPublisher.getUnpublishedCards(); 286 const unpublishedCard = unpublishedCards.find( 287 (uc) => uc.cardId === card.cardId.getStringValue(), 288 ); 289 expect(unpublishedCard).toBeDefined(); 290 }); 291 292 it('should handle unpublish failure gracefully', async () => { 293 const card = await createCard(); 294 await addCardToLibrary(card, curatorId); 295 296 // Configure publisher to fail unpublish 297 cardPublisher.setShouldFailUnpublish(true); 298 299 const request = { 300 cardId: card.cardId.getStringValue(), 301 curatorId: curatorId.value, 302 }; 303 304 const result = await useCase.execute(request); 305 306 expect(result.isErr()).toBe(true); 307 308 // Verify card was not removed from library if unpublish failed 309 const cardResult = await cardRepository.findById(card.cardId); 310 const cardFromRepo = cardResult.unwrap()!; 311 expect(cardFromRepo.isInLibrary(curatorId)).toBe(true); 312 }); 313 314 it('should not unpublish if card was never published', async () => { 315 const card = await createCard(); 316 317 // Manually add to library without publishing 318 const addResult = card.addToLibrary(curatorId); 319 expect(addResult.isOk()).toBe(true); 320 await cardRepository.save(card); 321 322 const initialUnpublishCount = cardPublisher.getUnpublishedCards().length; 323 324 const request = { 325 cardId: card.cardId.getStringValue(), 326 curatorId: curatorId.value, 327 }; 328 329 const result = await useCase.execute(request); 330 331 expect(result.isOk()).toBe(true); 332 333 // Verify no unpublish operation occurred 334 const finalUnpublishCount = cardPublisher.getUnpublishedCards().length; 335 expect(finalUnpublishCount).toBe(initialUnpublishCount); 336 337 // Verify card was still removed from library and deleted (since it's no longer in any libraries and curator is owner) 338 const updatedCardResult = await cardRepository.findById(card.cardId); 339 const updatedCard = updatedCardResult.unwrap(); 340 expect(updatedCard).toBeNull(); 341 }); 342 }); 343 344 describe('Collection integration', () => { 345 it('should remove card from all curator collections when removed from library', async () => { 346 const card = await createCard(); 347 await addCardToLibrary(card, curatorId); 348 349 // Create multiple collections for the curator 350 const collection1 = await createCollection(curatorId, 'Collection 1'); 351 const collection2 = await createCollection(curatorId, 'Collection 2'); 352 const collection3 = await createCollection(curatorId, 'Collection 3'); 353 354 // Add card to all collections 355 await addCardToCollection(card, collection1, curatorId); 356 await addCardToCollection(card, collection2, curatorId); 357 await addCardToCollection(card, collection3, curatorId); 358 359 // Verify card is in all collections 360 const initialCollection1Result = await collectionRepository.findById( 361 collection1.collectionId, 362 ); 363 const initialCollection2Result = await collectionRepository.findById( 364 collection2.collectionId, 365 ); 366 const initialCollection3Result = await collectionRepository.findById( 367 collection3.collectionId, 368 ); 369 370 expect( 371 initialCollection1Result 372 .unwrap()! 373 .cardIds.some((id) => id.equals(card.cardId)), 374 ).toBe(true); 375 expect( 376 initialCollection2Result 377 .unwrap()! 378 .cardIds.some((id) => id.equals(card.cardId)), 379 ).toBe(true); 380 expect( 381 initialCollection3Result 382 .unwrap()! 383 .cardIds.some((id) => id.equals(card.cardId)), 384 ).toBe(true); 385 386 // Remove card from library 387 const request = { 388 cardId: card.cardId.getStringValue(), 389 curatorId: curatorId.value, 390 }; 391 392 const result = await useCase.execute(request); 393 394 expect(result.isOk()).toBe(true); 395 396 // Verify card was removed from library and deleted (since it's no longer in any libraries and curator is owner) 397 const updatedCardResult = await cardRepository.findById(card.cardId); 398 const updatedCard = updatedCardResult.unwrap(); 399 expect(updatedCard).toBeNull(); 400 401 // Verify card was removed from all collections 402 const finalCollection1Result = await collectionRepository.findById( 403 collection1.collectionId, 404 ); 405 const finalCollection2Result = await collectionRepository.findById( 406 collection2.collectionId, 407 ); 408 const finalCollection3Result = await collectionRepository.findById( 409 collection3.collectionId, 410 ); 411 412 expect( 413 finalCollection1Result 414 .unwrap()! 415 .cardIds.some((id) => id.equals(card.cardId)), 416 ).toBe(false); 417 expect( 418 finalCollection2Result 419 .unwrap()! 420 .cardIds.some((id) => id.equals(card.cardId)), 421 ).toBe(false); 422 expect( 423 finalCollection3Result 424 .unwrap()! 425 .cardIds.some((id) => id.equals(card.cardId)), 426 ).toBe(false); 427 428 const unpublishedCollectionLinks = 429 collectionPublisher.getAllRemovedLinks(); 430 expect(unpublishedCollectionLinks).toHaveLength(3); 431 }); 432 433 it('should remove URL card from creator collections only', async () => { 434 const card = await createCard(); 435 await addCardToLibrary(card, curatorId); 436 437 // Create collections for the creator only (URL cards can only be in creator's library) 438 const curatorCollection = await createCollection( 439 curatorId, 440 'Curator Collection', 441 ); 442 443 // Add card to creator's collection 444 await addCardToCollection(card, curatorCollection, curatorId); 445 446 // Remove card from creator's library 447 const request = { 448 cardId: card.cardId.getStringValue(), 449 curatorId: curatorId.value, 450 }; 451 452 const result = await useCase.execute(request); 453 454 expect(result.isOk()).toBe(true); 455 456 // Verify card was removed from curator's collection 457 const curatorCollectionResult = await collectionRepository.findById( 458 curatorCollection.collectionId, 459 ); 460 461 expect( 462 curatorCollectionResult 463 .unwrap()! 464 .cardIds.some((id) => id.equals(card.cardId)), 465 ).toBe(false); 466 467 // Verify card was removed from creator's library and deleted (since it's no longer in any libraries and curator is owner) 468 const updatedCardResult = await cardRepository.findById(card.cardId); 469 const updatedCard = updatedCardResult.unwrap(); 470 expect(updatedCard).toBeNull(); 471 }); 472 473 it('should handle card removal when no collections contain the card', async () => { 474 const card = await createCard(); 475 await addCardToLibrary(card, curatorId); 476 477 // Create collections but don't add the card to them 478 await createCollection(curatorId, 'Empty Collection 1'); 479 await createCollection(curatorId, 'Empty Collection 2'); 480 481 const request = { 482 cardId: card.cardId.getStringValue(), 483 curatorId: curatorId.value, 484 }; 485 486 const result = await useCase.execute(request); 487 488 expect(result.isOk()).toBe(true); 489 490 // Verify card was removed from library and deleted (since it's no longer in any libraries and curator is owner) 491 const updatedCardResult = await cardRepository.findById(card.cardId); 492 const updatedCard = updatedCardResult.unwrap(); 493 expect(updatedCard).toBeNull(); 494 495 // Verify no collection unpublish operations occurred 496 const unpublishedCollections = 497 collectionPublisher.getUnpublishedCollections(); 498 expect(unpublishedCollections).toHaveLength(0); 499 }); 500 }); 501 502 describe('Card deletion behavior', () => { 503 it('should delete card when removed from last library and curator is owner', async () => { 504 const card = await createCard(); 505 await addCardToLibrary(card, curatorId); 506 507 // Verify card exists and is in library 508 expect(card.isInLibrary(curatorId)).toBe(true); 509 expect(card.libraryMembershipCount).toBe(1); 510 511 const request = { 512 cardId: card.cardId.getStringValue(), 513 curatorId: curatorId.value, 514 }; 515 516 const result = await useCase.execute(request); 517 518 expect(result.isOk()).toBe(true); 519 520 // Verify card was deleted 521 const cardResult = await cardRepository.findById(card.cardId); 522 const cardFromRepo = cardResult.unwrap(); 523 expect(cardFromRepo).toBeNull(); 524 }); 525 526 it('should not delete card when curator is not the owner', async () => { 527 // Create card with different owner 528 const card = await createCard(CardTypeEnum.NOTE, otherCuratorId); 529 530 // Add to other curator's library first 531 await addCardToLibrary(card, otherCuratorId); 532 533 // Add to current curator's library (note cards can be in multiple libraries) 534 await addCardToLibrary(card, curatorId); 535 536 // Remove from current curator's library 537 const request = { 538 cardId: card.cardId.getStringValue(), 539 curatorId: curatorId.value, 540 }; 541 542 const result = await useCase.execute(request); 543 544 expect(result.isOk()).toBe(true); 545 546 // Verify card still exists (not deleted because curator is not owner) 547 const cardResult = await cardRepository.findById(card.cardId); 548 const cardFromRepo = cardResult.unwrap()!; 549 expect(cardFromRepo).not.toBeNull(); 550 expect(cardFromRepo.isInLibrary(curatorId)).toBe(false); 551 expect(cardFromRepo.isInLibrary(otherCuratorId)).toBe(true); 552 }); 553 554 it('should not delete card when it still has other library memberships', async () => { 555 // Create note card (can be in multiple libraries) 556 const card = await createCard(CardTypeEnum.NOTE, curatorId); 557 558 // Add to both curator's libraries 559 await addCardToLibrary(card, curatorId); 560 await addCardToLibrary(card, otherCuratorId); 561 562 expect(card.libraryMembershipCount).toBe(2); 563 564 // Remove from curator's library 565 const request = { 566 cardId: card.cardId.getStringValue(), 567 curatorId: curatorId.value, 568 }; 569 570 const result = await useCase.execute(request); 571 572 expect(result.isOk()).toBe(true); 573 574 // Verify card still exists (not deleted because it's still in other curator's library) 575 const cardResult = await cardRepository.findById(card.cardId); 576 const cardFromRepo = cardResult.unwrap()!; 577 expect(cardFromRepo).not.toBeNull(); 578 expect(cardFromRepo.isInLibrary(curatorId)).toBe(false); 579 expect(cardFromRepo.isInLibrary(otherCuratorId)).toBe(true); 580 expect(cardFromRepo.libraryMembershipCount).toBe(1); 581 }); 582 }); 583 584 describe('Edge cases', () => { 585 it('should handle URL card with single library membership', async () => { 586 // Create URL card with curatorId as creator 587 const card = await createCard(CardTypeEnum.URL, curatorId); 588 589 // Add to creator's library only (URL cards can only be in creator's library) 590 await addCardToLibrary(card, curatorId); 591 592 expect(card.libraryMembershipCount).toBe(1); 593 594 const request = { 595 cardId: card.cardId.getStringValue(), 596 curatorId: curatorId.value, 597 }; 598 599 const result = await useCase.execute(request); 600 601 expect(result.isOk()).toBe(true); 602 603 // Verify card was deleted (since it's no longer in any libraries and curator is owner) 604 const updatedCardResult = await cardRepository.findById(card.cardId); 605 const updatedCard = updatedCardResult.unwrap(); 606 expect(updatedCard).toBeNull(); 607 }); 608 609 it('should handle repository save failure', async () => { 610 const card = await createCard(); 611 await addCardToLibrary(card, curatorId); 612 613 // Configure repository to fail save 614 cardRepository.setShouldFailSave(true); 615 616 const request = { 617 cardId: card.cardId.getStringValue(), 618 curatorId: curatorId.value, 619 }; 620 621 const result = await useCase.execute(request); 622 623 expect(result.isErr()).toBe(true); 624 }); 625 626 it('should preserve card properties when removing from library', async () => { 627 // Create URL card with curatorId as creator 628 const card = await createCard(CardTypeEnum.URL, curatorId); 629 await addCardToLibrary(card, curatorId); 630 631 const originalCreatedAt = card.createdAt; 632 const originalType = card.type.value; 633 const originalContent = card.content; 634 635 const request = { 636 cardId: card.cardId.getStringValue(), 637 curatorId: curatorId.value, 638 }; 639 640 const result = await useCase.execute(request); 641 642 expect(result.isOk()).toBe(true); 643 644 // Verify card was deleted (since it's no longer in any libraries and curator is owner) 645 const updatedCardResult = await cardRepository.findById(card.cardId); 646 const updatedCard = updatedCardResult.unwrap(); 647 expect(updatedCard).toBeNull(); 648 }); 649 }); 650});