This repository has no description
30 kB
867 lines
1import { AddUrlToLibraryUseCase } from '../../application/useCases/commands/AddUrlToLibraryUseCase';
2import { InMemoryCardRepository } from '../utils/InMemoryCardRepository';
3import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository';
4import { FakeCardPublisher } from '../utils/FakeCardPublisher';
5import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher';
6import { FakeMetadataService } from '../utils/FakeMetadataService';
7import { CardLibraryService } from '../../domain/services/CardLibraryService';
8import { CardCollectionService } from '../../domain/services/CardCollectionService';
9import { CuratorId } from '../../domain/value-objects/CuratorId';
10import { CollectionBuilder } from '../utils/builders/CollectionBuilder';
11import { CardTypeEnum } from '../../domain/value-objects/CardType';
12import { FakeEventPublisher } from '../utils/FakeEventPublisher';
13import { CardAddedToLibraryEvent } from '../../domain/events/CardAddedToLibraryEvent';
14import { CardAddedToCollectionEvent } from '../../domain/events/CardAddedToCollectionEvent';
15import { EventNames } from 'src/shared/infrastructure/events/EventConfig';
16
17describe('AddUrlToLibraryUseCase', () => {
18 let useCase: AddUrlToLibraryUseCase;
19 let cardRepository: InMemoryCardRepository;
20 let collectionRepository: InMemoryCollectionRepository;
21 let cardPublisher: FakeCardPublisher;
22 let collectionPublisher: FakeCollectionPublisher;
23 let metadataService: FakeMetadataService;
24 let cardLibraryService: CardLibraryService;
25 let cardCollectionService: CardCollectionService;
26 let eventPublisher: FakeEventPublisher;
27 let curatorId: CuratorId;
28
29 beforeEach(() => {
30 cardRepository = InMemoryCardRepository.getInstance();
31 collectionRepository = InMemoryCollectionRepository.getInstance();
32 cardPublisher = new FakeCardPublisher();
33 collectionPublisher = new FakeCollectionPublisher();
34 metadataService = new FakeMetadataService();
35 eventPublisher = new FakeEventPublisher();
36
37 cardLibraryService = new CardLibraryService(
38 cardRepository,
39 cardPublisher,
40 collectionRepository,
41 cardCollectionService,
42 );
43 cardCollectionService = new CardCollectionService(
44 collectionRepository,
45 collectionPublisher,
46 cardRepository,
47 );
48
49 useCase = new AddUrlToLibraryUseCase(
50 cardRepository,
51 metadataService,
52 cardLibraryService,
53 cardCollectionService,
54 eventPublisher,
55 );
56
57 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
58 });
59
60 afterEach(() => {
61 cardRepository.clear();
62 collectionRepository.clear();
63 cardPublisher.clear();
64 collectionPublisher.clear();
65 metadataService.clear();
66 eventPublisher.clear();
67 });
68
69 describe('Basic URL card creation', () => {
70 it('should create and add a URL card to library', async () => {
71 const request = {
72 url: 'https://example.com/article',
73 curatorId: curatorId.value,
74 };
75
76 const result = await useCase.execute(request);
77
78 expect(result.isOk()).toBe(true);
79 const response = result.unwrap();
80 expect(response.urlCardId).toBeDefined();
81 expect(response.noteCardId).toBeUndefined();
82
83 // Verify card was saved
84 const savedCards = cardRepository.getAllCards();
85 expect(savedCards).toHaveLength(1);
86 expect(savedCards[0]!.content.type).toBe(CardTypeEnum.URL);
87
88 // Verify card was published to library
89 const publishedCards = cardPublisher.getPublishedCards();
90 expect(publishedCards).toHaveLength(1);
91
92 // Verify CardAddedToLibraryEvent was published
93 const libraryEvents = eventPublisher.getPublishedEventsOfType(
94 EventNames.CARD_ADDED_TO_LIBRARY,
95 ) as CardAddedToLibraryEvent[];
96 expect(libraryEvents).toHaveLength(1);
97 expect(libraryEvents[0]?.cardId.getStringValue()).toBe(
98 response.urlCardId,
99 );
100 expect(libraryEvents[0]?.curatorId.equals(curatorId)).toBe(true);
101 });
102
103 it('should create URL card with note when note is provided', async () => {
104 const request = {
105 url: 'https://example.com/article',
106 note: 'This is a great article about testing',
107 curatorId: curatorId.value,
108 };
109
110 const result = await useCase.execute(request);
111
112 expect(result.isOk()).toBe(true);
113 const response = result.unwrap();
114 expect(response.urlCardId).toBeDefined();
115 expect(response.noteCardId).toBeDefined();
116
117 // Verify both cards were saved
118 const savedCards = cardRepository.getAllCards();
119 expect(savedCards).toHaveLength(2);
120
121 const urlCard = savedCards.find(
122 (card) => card.content.type === CardTypeEnum.URL,
123 );
124 const noteCard = savedCards.find(
125 (card) => card.content.type === CardTypeEnum.NOTE,
126 );
127
128 expect(urlCard).toBeDefined();
129 expect(noteCard).toBeDefined();
130 expect(noteCard?.parentCardId?.getStringValue()).toBe(
131 urlCard?.cardId.getStringValue(),
132 );
133
134 // Verify both cards were published to library
135 const publishedCards = cardPublisher.getPublishedCards();
136 expect(publishedCards).toHaveLength(2);
137
138 // Verify CardAddedToLibraryEvent was published for only URL card
139 const libraryEvents = eventPublisher.getPublishedEventsOfType(
140 EventNames.CARD_ADDED_TO_LIBRARY,
141 ) as CardAddedToLibraryEvent[];
142 expect(libraryEvents).toHaveLength(1);
143
144 const urlCardEvent = libraryEvents.find(
145 (event) =>
146 event.cardId.getStringValue() === urlCard?.cardId.getStringValue(),
147 );
148
149 expect(urlCardEvent).toBeDefined();
150 expect(urlCardEvent?.curatorId.equals(curatorId)).toBe(true);
151 });
152 });
153
154 describe('Existing URL card handling', () => {
155 it('should reuse existing URL card instead of creating new one', async () => {
156 const url = 'https://example.com/existing';
157
158 // First request creates the URL card
159 const firstRequest = {
160 url,
161 curatorId: curatorId.value,
162 };
163
164 const firstResult = await useCase.execute(firstRequest);
165 expect(firstResult.isOk()).toBe(true);
166 const firstResponse = firstResult.unwrap();
167
168 // Second request should reuse the same URL card
169 const secondRequest = {
170 url,
171 note: 'Adding a note to existing URL',
172 curatorId: curatorId.value,
173 };
174
175 const secondResult = await useCase.execute(secondRequest);
176 expect(secondResult.isOk()).toBe(true);
177 const secondResponse = secondResult.unwrap();
178
179 // Should have same URL card ID
180 expect(secondResponse.urlCardId).toBe(firstResponse.urlCardId);
181 expect(secondResponse.noteCardId).toBeDefined();
182
183 // Should have URL card + note card
184 const savedCards = cardRepository.getAllCards();
185 expect(savedCards).toHaveLength(2);
186
187 const urlCards = savedCards.filter(
188 (card) => card.content.type === CardTypeEnum.URL,
189 );
190 expect(urlCards).toHaveLength(1); // Only one URL card
191 });
192
193 it('should create new URL card when another user has URL card with same URL', async () => {
194 const url = 'https://example.com/shared';
195 const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
196
197 // First user creates URL card
198 const firstRequest = {
199 url,
200 curatorId: otherCuratorId.value,
201 };
202
203 const firstResult = await useCase.execute(firstRequest);
204 expect(firstResult.isOk()).toBe(true);
205 const firstResponse = firstResult.unwrap();
206
207 // Second user (different curator) should create their own URL card
208 const secondRequest = {
209 url,
210 curatorId: curatorId.value,
211 };
212
213 const secondResult = await useCase.execute(secondRequest);
214 expect(secondResult.isOk()).toBe(true);
215 const secondResponse = secondResult.unwrap();
216
217 // Should have different URL card IDs
218 expect(secondResponse.urlCardId).not.toBe(firstResponse.urlCardId);
219
220 // Should have two separate URL cards
221 const savedCards = cardRepository.getAllCards();
222 expect(savedCards).toHaveLength(2);
223
224 const urlCards = savedCards.filter(
225 (card) => card.content.type === CardTypeEnum.URL,
226 );
227 expect(urlCards).toHaveLength(2); // Two separate URL cards
228
229 // Verify each card belongs to the correct curator
230 const firstUserCard = urlCards.find((card) =>
231 card.props.curatorId.equals(otherCuratorId),
232 );
233 const secondUserCard = urlCards.find((card) =>
234 card.props.curatorId.equals(curatorId),
235 );
236
237 expect(firstUserCard).toBeDefined();
238 expect(secondUserCard).toBeDefined();
239 });
240
241 it('should create new URL card when no one has URL card with that URL yet', async () => {
242 const url = 'https://example.com/brand-new';
243
244 // Verify no cards exist initially
245 expect(cardRepository.getAllCards()).toHaveLength(0);
246
247 const request = {
248 url,
249 curatorId: curatorId.value,
250 };
251
252 const result = await useCase.execute(request);
253
254 expect(result.isOk()).toBe(true);
255 const response = result.unwrap();
256 expect(response.urlCardId).toBeDefined();
257
258 // Verify new URL card was created
259 const savedCards = cardRepository.getAllCards();
260 expect(savedCards).toHaveLength(1);
261
262 const urlCard = savedCards[0];
263 expect(urlCard?.content.type).toBe(CardTypeEnum.URL);
264 expect(urlCard?.props.curatorId.equals(curatorId)).toBe(true);
265 });
266
267 it('should update existing note card when URL already exists with a note', async () => {
268 const url = 'https://example.com/existing';
269
270 // First request creates URL card with note
271 const firstRequest = {
272 url,
273 note: 'Original note',
274 curatorId: curatorId.value,
275 };
276
277 const firstResult = await useCase.execute(firstRequest);
278 expect(firstResult.isOk()).toBe(true);
279 const firstResponse = firstResult.unwrap();
280 expect(firstResponse.noteCardId).toBeDefined();
281
282 // Get the original note card
283 const cardsAfterFirst = cardRepository.getAllCards();
284 const originalNoteCard = cardsAfterFirst.find(
285 (card) => card.content.type === CardTypeEnum.NOTE,
286 );
287 expect(originalNoteCard).toBeDefined();
288 expect(originalNoteCard?.content.noteContent?.text).toBe('Original note');
289
290 // Second request updates the note
291 const secondRequest = {
292 url,
293 note: 'Updated note',
294 curatorId: curatorId.value,
295 };
296
297 const secondResult = await useCase.execute(secondRequest);
298 expect(secondResult.isOk()).toBe(true);
299 const secondResponse = secondResult.unwrap();
300
301 // Should still have the same note card ID
302 expect(secondResponse.noteCardId).toBe(firstResponse.noteCardId);
303
304 // Should still have 2 cards (URL + Note)
305 const savedCards = cardRepository.getAllCards();
306 expect(savedCards).toHaveLength(2);
307
308 // Verify note was updated
309 const updatedNoteCard = savedCards.find(
310 (card) => card.content.type === CardTypeEnum.NOTE,
311 );
312 expect(updatedNoteCard).toBeDefined();
313 expect(updatedNoteCard?.content.noteContent?.text).toBe('Updated note');
314 expect(updatedNoteCard?.cardId.getStringValue()).toBe(
315 firstResponse.noteCardId,
316 );
317 });
318 });
319
320 describe('Collection handling', () => {
321 it('should add URL card to open collections', async () => {
322 // Create an open test collection
323 const collection = new CollectionBuilder()
324 .withAuthorId(curatorId.value)
325 .withName('Open Test Collection')
326 .withAccessType('OPEN')
327 .build();
328
329 if (collection instanceof Error) {
330 throw new Error(`Failed to create collection: ${collection.message}`);
331 }
332
333 await collectionRepository.save(collection);
334
335 const request = {
336 url: 'https://example.com/article',
337 collectionIds: [collection.collectionId.getStringValue()],
338 curatorId: curatorId.value,
339 };
340
341 const result = await useCase.execute(request);
342
343 expect(result.isOk()).toBe(true);
344
345 // Verify collection link was published
346 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
347 collection.collectionId.getStringValue(),
348 );
349 expect(publishedLinks).toHaveLength(1);
350
351 // Verify CardAddedToLibraryEvent was published
352 const libraryEvents = eventPublisher.getPublishedEventsOfType(
353 EventNames.CARD_ADDED_TO_LIBRARY,
354 ) as CardAddedToLibraryEvent[];
355 expect(libraryEvents).toHaveLength(1);
356 expect(libraryEvents[0]?.curatorId.equals(curatorId)).toBe(true);
357
358 // Verify CardAddedToCollectionEvent was published
359 const collectionEvents = eventPublisher.getPublishedEventsOfType(
360 EventNames.CARD_ADDED_TO_COLLECTION,
361 ) as CardAddedToCollectionEvent[];
362 expect(collectionEvents).toHaveLength(1);
363 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe(
364 collection.collectionId.getStringValue(),
365 );
366 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true);
367 });
368
369 it('should add URL card to closed collections when user is the author', async () => {
370 // Create a closed test collection owned by the curator
371 const collection = new CollectionBuilder()
372 .withAuthorId(curatorId.value)
373 .withName('Closed Test Collection')
374 .withAccessType('CLOSED')
375 .build();
376
377 if (collection instanceof Error) {
378 throw new Error(`Failed to create collection: ${collection.message}`);
379 }
380
381 await collectionRepository.save(collection);
382
383 const request = {
384 url: 'https://example.com/article',
385 collectionIds: [collection.collectionId.getStringValue()],
386 curatorId: curatorId.value,
387 };
388
389 const result = await useCase.execute(request);
390
391 expect(result.isOk()).toBe(true);
392
393 // Verify collection link was published
394 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
395 collection.collectionId.getStringValue(),
396 );
397 expect(publishedLinks).toHaveLength(1);
398 });
399
400 it('should add URL card to closed collections when user is a collaborator', async () => {
401 const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
402
403 // Create a closed test collection with curator as collaborator
404 const collection = new CollectionBuilder()
405 .withAuthorId(otherCuratorId.value)
406 .withName('Closed Collaborative Collection')
407 .withAccessType('CLOSED')
408 .withCollaborators([curatorId.value])
409 .build();
410
411 if (collection instanceof Error) {
412 throw new Error(`Failed to create collection: ${collection.message}`);
413 }
414
415 await collectionRepository.save(collection);
416
417 const request = {
418 url: 'https://example.com/article',
419 collectionIds: [collection.collectionId.getStringValue()],
420 curatorId: curatorId.value,
421 };
422
423 const result = await useCase.execute(request);
424
425 expect(result.isOk()).toBe(true);
426
427 // Verify collection link was published
428 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
429 collection.collectionId.getStringValue(),
430 );
431 expect(publishedLinks).toHaveLength(1);
432 });
433
434 it('should fail to add URL card to closed collections when user lacks permission', async () => {
435 const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
436
437 // Create a closed test collection owned by someone else
438 const collection = new CollectionBuilder()
439 .withAuthorId(otherCuratorId.value)
440 .withName('Private Closed Collection')
441 .withAccessType('CLOSED')
442 .build();
443
444 if (collection instanceof Error) {
445 throw new Error(`Failed to create collection: ${collection.message}`);
446 }
447
448 await collectionRepository.save(collection);
449
450 const request = {
451 url: 'https://example.com/article',
452 collectionIds: [collection.collectionId.getStringValue()],
453 curatorId: curatorId.value,
454 };
455
456 const result = await useCase.execute(request);
457
458 expect(result.isErr()).toBe(true);
459 if (result.isErr()) {
460 expect(result.error.message).toContain('does not have permission');
461 }
462
463 // Verify no collection link was published
464 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
465 collection.collectionId.getStringValue(),
466 );
467 expect(publishedLinks).toHaveLength(0);
468 });
469
470 it('should add URL card to open collections owned by other users', async () => {
471 const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
472
473 // Create an open test collection owned by someone else
474 const collection = new CollectionBuilder()
475 .withAuthorId(otherCuratorId.value)
476 .withName('Public Open Collection')
477 .withAccessType('OPEN')
478 .build();
479
480 if (collection instanceof Error) {
481 throw new Error(`Failed to create collection: ${collection.message}`);
482 }
483
484 await collectionRepository.save(collection);
485
486 const request = {
487 url: 'https://example.com/article',
488 collectionIds: [collection.collectionId.getStringValue()],
489 curatorId: curatorId.value,
490 };
491
492 const result = await useCase.execute(request);
493
494 expect(result.isOk()).toBe(true);
495
496 // Verify collection link was published
497 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
498 collection.collectionId.getStringValue(),
499 );
500 expect(publishedLinks).toHaveLength(1);
501 });
502
503 it('should add URL card to collections with viaCardId', async () => {
504 // Create a test collection
505 const collection = new CollectionBuilder()
506 .withAuthorId(curatorId.value)
507 .withName('Test Collection')
508 .build();
509
510 if (collection instanceof Error) {
511 throw new Error(`Failed to create collection: ${collection.message}`);
512 }
513
514 await collectionRepository.save(collection);
515
516 // Create a via card first
517 const viaCardRequest = {
518 url: 'https://example.com/via-article',
519 curatorId: curatorId.value,
520 };
521
522 const viaCardResult = await useCase.execute(viaCardRequest);
523 expect(viaCardResult.isOk()).toBe(true);
524 const viaCardId = viaCardResult.unwrap().urlCardId;
525
526 // Now add URL with viaCardId
527 const request = {
528 url: 'https://example.com/article',
529 collectionIds: [collection.collectionId.getStringValue()],
530 viaCardId: viaCardId,
531 curatorId: curatorId.value,
532 };
533
534 const result = await useCase.execute(request);
535
536 expect(result.isOk()).toBe(true);
537 const response = result.unwrap();
538
539 // Verify URL card was created
540 expect(response.urlCardId).toBeDefined();
541 expect(response.urlCardId).not.toBe(viaCardId);
542
543 // Verify collection link was published
544 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
545 collection.collectionId.getStringValue(),
546 );
547 expect(publishedLinks).toHaveLength(1);
548
549 // Verify the published link is for the new URL card
550 const publishedLink = publishedLinks[0];
551 expect(publishedLink?.cardId).toBe(response.urlCardId);
552
553 // Verify CardAddedToCollectionEvent was published with correct viaCardId
554 const collectionEvents = eventPublisher.getPublishedEventsOfType(
555 EventNames.CARD_ADDED_TO_COLLECTION,
556 ) as CardAddedToCollectionEvent[];
557 expect(collectionEvents).toHaveLength(1);
558 expect(collectionEvents[0]?.cardId.getStringValue()).toBe(
559 response.urlCardId,
560 );
561 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe(
562 collection.collectionId.getStringValue(),
563 );
564 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true);
565 });
566
567 it('should add URL card (not note card) to collections when note is provided', async () => {
568 // Create a test collection
569 const collection = new CollectionBuilder()
570 .withAuthorId(curatorId.value)
571 .withName('Test Collection')
572 .build();
573
574 if (collection instanceof Error) {
575 throw new Error(`Failed to create collection: ${collection.message}`);
576 }
577
578 await collectionRepository.save(collection);
579
580 const request = {
581 url: 'https://example.com/article',
582 note: 'This is my note about the article',
583 collectionIds: [collection.collectionId.getStringValue()],
584 curatorId: curatorId.value,
585 };
586
587 const result = await useCase.execute(request);
588
589 expect(result.isOk()).toBe(true);
590 const response = result.unwrap();
591
592 // Verify both URL and note cards were created
593 expect(response.urlCardId).toBeDefined();
594 expect(response.noteCardId).toBeDefined();
595
596 // Verify both cards were saved
597 const savedCards = cardRepository.getAllCards();
598 expect(savedCards).toHaveLength(2);
599
600 const urlCard = savedCards.find(
601 (card) => card.content.type === CardTypeEnum.URL,
602 );
603 const noteCard = savedCards.find(
604 (card) => card.content.type === CardTypeEnum.NOTE,
605 );
606
607 expect(urlCard).toBeDefined();
608 expect(noteCard).toBeDefined();
609
610 // Verify collection link was published for URL card only
611 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
612 collection.collectionId.getStringValue(),
613 );
614 expect(publishedLinks).toHaveLength(1);
615
616 // Verify the published link is for the URL card, not the note card
617 const publishedLink = publishedLinks[0];
618 expect(publishedLink?.cardId).toBe(urlCard?.cardId.getStringValue());
619 expect(publishedLink?.cardId).not.toBe(noteCard?.cardId.getStringValue());
620
621 // Verify both cards are in the library
622 const publishedCards = cardPublisher.getPublishedCards();
623 expect(publishedCards).toHaveLength(2);
624
625 // Verify CardAddedToLibraryEvent was published for both cards
626 const libraryEvents = eventPublisher.getPublishedEventsOfType(
627 EventNames.CARD_ADDED_TO_LIBRARY,
628 ) as CardAddedToLibraryEvent[];
629 expect(libraryEvents).toHaveLength(1);
630
631 // Verify CardAddedToCollectionEvent was published for URL card only
632 const collectionEvents = eventPublisher.getPublishedEventsOfType(
633 EventNames.CARD_ADDED_TO_COLLECTION,
634 ) as CardAddedToCollectionEvent[];
635 expect(collectionEvents).toHaveLength(1);
636 expect(collectionEvents[0]?.cardId.getStringValue()).toBe(
637 urlCard?.cardId.getStringValue(),
638 );
639 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe(
640 collection.collectionId.getStringValue(),
641 );
642 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true);
643 });
644
645 it('should fail when collection does not exist', async () => {
646 const request = {
647 url: 'https://example.com/article',
648 collectionIds: ['non-existent-collection-id'],
649 curatorId: curatorId.value,
650 };
651
652 const result = await useCase.execute(request);
653
654 expect(result.isErr()).toBe(true);
655 if (result.isErr()) {
656 expect(result.error.message).toContain('Collection not found');
657 }
658 });
659
660 it('should handle mixed collection permissions correctly', async () => {
661 const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
662
663 // Create an open collection and a closed collection without permission
664 const openCollection = new CollectionBuilder()
665 .withAuthorId(otherCuratorId.value)
666 .withName('Open Collection')
667 .withAccessType('OPEN')
668 .build();
669
670 const closedCollection = new CollectionBuilder()
671 .withAuthorId(otherCuratorId.value)
672 .withName('Closed Collection')
673 .withAccessType('CLOSED')
674 .build();
675
676 if (
677 openCollection instanceof Error ||
678 closedCollection instanceof Error
679 ) {
680 throw new Error('Failed to create collections');
681 }
682
683 await collectionRepository.save(openCollection);
684 await collectionRepository.save(closedCollection);
685
686 const request = {
687 url: 'https://example.com/article',
688 collectionIds: [
689 openCollection.collectionId.getStringValue(),
690 closedCollection.collectionId.getStringValue(),
691 ],
692 curatorId: curatorId.value,
693 };
694
695 const result = await useCase.execute(request);
696
697 expect(result.isErr()).toBe(true);
698 if (result.isErr()) {
699 expect(result.error.message).toContain('does not have permission');
700 }
701
702 const openPublishedLinks =
703 collectionPublisher.getPublishedLinksForCollection(
704 openCollection.collectionId.getStringValue(),
705 );
706 const closedPublishedLinks =
707 collectionPublisher.getPublishedLinksForCollection(
708 closedCollection.collectionId.getStringValue(),
709 );
710 expect(openPublishedLinks).toHaveLength(1);
711 expect(closedPublishedLinks).toHaveLength(0);
712 });
713 });
714
715 describe('Validation', () => {
716 it('should fail with invalid URL', async () => {
717 const request = {
718 url: 'not-a-valid-url',
719 curatorId: curatorId.value,
720 };
721
722 const result = await useCase.execute(request);
723
724 expect(result.isErr()).toBe(true);
725 if (result.isErr()) {
726 expect(result.error.message).toContain('Invalid URL');
727 }
728 });
729
730 it('should fail with invalid curator ID', async () => {
731 const request = {
732 url: 'https://example.com/article',
733 curatorId: 'invalid-curator-id',
734 };
735
736 const result = await useCase.execute(request);
737
738 expect(result.isErr()).toBe(true);
739 if (result.isErr()) {
740 expect(result.error.message).toContain('Invalid curator ID');
741 }
742 });
743
744 it('should fail with invalid collection ID', async () => {
745 const request = {
746 url: 'https://example.com/article',
747 collectionIds: ['invalid-collection-id'],
748 curatorId: curatorId.value,
749 };
750
751 const result = await useCase.execute(request);
752
753 expect(result.isErr()).toBe(true);
754 if (result.isErr()) {
755 expect(result.error.message).toContain('Collection not found');
756 }
757 });
758 });
759
760 describe('Publishing and metadata integration', () => {
761 it('should handle metadata service failure gracefully', async () => {
762 // Configure metadata service to fail
763 metadataService.setShouldFail(true);
764
765 const request = {
766 url: 'https://example.com/article',
767 curatorId: curatorId.value,
768 };
769
770 const result = await useCase.execute(request);
771
772 expect(result.isOk()).toBe(true);
773 });
774
775 it('should not save cards when publishing to library fails', async () => {
776 // Configure card publisher to fail
777 cardPublisher.setShouldFail(true);
778
779 const request = {
780 url: 'https://example.com/article',
781 curatorId: curatorId.value,
782 };
783
784 const result = await useCase.execute(request);
785
786 expect(result.isErr()).toBe(true);
787 if (result.isErr()) {
788 expect(result.error.message).toContain('Failed to publish card');
789 }
790
791 // Verify no cards were saved when publishing failed
792 const savedCards = cardRepository.getAllCards();
793 expect(savedCards).toHaveLength(0);
794
795 // Verify no cards were published
796 const publishedCards = cardPublisher.getPublishedCards();
797 expect(publishedCards).toHaveLength(0);
798
799 // Verify no events were published
800 const libraryEvents = eventPublisher.getPublishedEventsOfType(
801 EventNames.CARD_ADDED_TO_LIBRARY,
802 );
803 expect(libraryEvents).toHaveLength(0);
804 });
805
806 it('should not save cards when collection publishing fails', async () => {
807 // Create a test collection
808 const collection = new CollectionBuilder()
809 .withAuthorId(curatorId.value)
810 .withName('Test Collection')
811 .build();
812
813 if (collection instanceof Error) {
814 throw new Error(`Failed to create collection: ${collection.message}`);
815 }
816
817 await collectionRepository.save(collection);
818
819 // Configure collection publisher to fail (for card-collection links)
820 collectionPublisher.setShouldFail(true);
821
822 const request = {
823 url: 'https://example.com/article',
824 collectionIds: [collection.collectionId.getStringValue()],
825 curatorId: curatorId.value,
826 };
827
828 const result = await useCase.execute(request);
829
830 expect(result.isErr()).toBe(true);
831 if (result.isErr()) {
832 expect(result.error.message).toContain('Failed to publish');
833 }
834
835 // card should still be published to library
836 const savedCards = cardRepository.getAllCards();
837 expect(savedCards).toHaveLength(1);
838
839 const savedCollection = await collectionRepository.findById(
840 collection.collectionId,
841 );
842 if (savedCollection.isErr()) {
843 throw new Error(
844 `Failed to retrieve collection: ${savedCollection.error.message}`,
845 );
846 }
847 expect(savedCollection.value?.cardCount).toBe(0); // card count should be 0 since link publishing failed
848
849 // Verify no collection links were published
850 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
851 collection.collectionId.getStringValue(),
852 );
853 expect(publishedLinks).toHaveLength(0);
854
855 // Verify no events were published
856 const libraryEvents = eventPublisher.getPublishedEventsOfType(
857 EventNames.CARD_ADDED_TO_LIBRARY,
858 );
859 expect(libraryEvents).toHaveLength(0);
860
861 const collectionEvents = eventPublisher.getPublishedEventsOfType(
862 EventNames.CARD_ADDED_TO_COLLECTION,
863 );
864 expect(collectionEvents).toHaveLength(0);
865 });
866 });
867});