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});