A social knowledge tool for researchers built on ATProto
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 );
47
48 useCase = new AddUrlToLibraryUseCase(
49 cardRepository,
50 metadataService,
51 cardLibraryService,
52 cardCollectionService,
53 eventPublisher,
54 );
55
56 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
57 });
58
59 afterEach(() => {
60 cardRepository.clear();
61 collectionRepository.clear();
62 cardPublisher.clear();
63 collectionPublisher.clear();
64 metadataService.clear();
65 eventPublisher.clear();
66 });
67
68 describe('Basic URL card creation', () => {
69 it('should create and add a URL card to library', async () => {
70 const request = {
71 url: 'https://example.com/article',
72 curatorId: curatorId.value,
73 };
74
75 const result = await useCase.execute(request);
76
77 expect(result.isOk()).toBe(true);
78 const response = result.unwrap();
79 expect(response.urlCardId).toBeDefined();
80 expect(response.noteCardId).toBeUndefined();
81
82 // Verify card was saved
83 const savedCards = cardRepository.getAllCards();
84 expect(savedCards).toHaveLength(1);
85 expect(savedCards[0]!.content.type).toBe(CardTypeEnum.URL);
86
87 // Verify card was published to library
88 const publishedCards = cardPublisher.getPublishedCards();
89 expect(publishedCards).toHaveLength(1);
90
91 // Verify CardAddedToLibraryEvent was published
92 const libraryEvents = eventPublisher.getPublishedEventsOfType(
93 EventNames.CARD_ADDED_TO_LIBRARY,
94 ) as CardAddedToLibraryEvent[];
95 expect(libraryEvents).toHaveLength(1);
96 expect(libraryEvents[0]?.cardId.getStringValue()).toBe(
97 response.urlCardId,
98 );
99 expect(libraryEvents[0]?.curatorId.equals(curatorId)).toBe(true);
100 });
101
102 it('should create URL card with note when note is provided', async () => {
103 const request = {
104 url: 'https://example.com/article',
105 note: 'This is a great article about testing',
106 curatorId: curatorId.value,
107 };
108
109 const result = await useCase.execute(request);
110
111 expect(result.isOk()).toBe(true);
112 const response = result.unwrap();
113 expect(response.urlCardId).toBeDefined();
114 expect(response.noteCardId).toBeDefined();
115
116 // Verify both cards were saved
117 const savedCards = cardRepository.getAllCards();
118 expect(savedCards).toHaveLength(2);
119
120 const urlCard = savedCards.find(
121 (card) => card.content.type === CardTypeEnum.URL,
122 );
123 const noteCard = savedCards.find(
124 (card) => card.content.type === CardTypeEnum.NOTE,
125 );
126
127 expect(urlCard).toBeDefined();
128 expect(noteCard).toBeDefined();
129 expect(noteCard?.parentCardId?.getStringValue()).toBe(
130 urlCard?.cardId.getStringValue(),
131 );
132
133 // Verify both cards were published to library
134 const publishedCards = cardPublisher.getPublishedCards();
135 expect(publishedCards).toHaveLength(2);
136
137 // Verify CardAddedToLibraryEvent was published for only URL card
138 const libraryEvents = eventPublisher.getPublishedEventsOfType(
139 EventNames.CARD_ADDED_TO_LIBRARY,
140 ) as CardAddedToLibraryEvent[];
141 expect(libraryEvents).toHaveLength(1);
142
143 const urlCardEvent = libraryEvents.find(
144 (event) =>
145 event.cardId.getStringValue() === urlCard?.cardId.getStringValue(),
146 );
147
148 expect(urlCardEvent).toBeDefined();
149 expect(urlCardEvent?.curatorId.equals(curatorId)).toBe(true);
150 });
151 });
152
153 describe('Existing URL card handling', () => {
154 it('should reuse existing URL card instead of creating new one', async () => {
155 const url = 'https://example.com/existing';
156
157 // First request creates the URL card
158 const firstRequest = {
159 url,
160 curatorId: curatorId.value,
161 };
162
163 const firstResult = await useCase.execute(firstRequest);
164 expect(firstResult.isOk()).toBe(true);
165 const firstResponse = firstResult.unwrap();
166
167 // Second request should reuse the same URL card
168 const secondRequest = {
169 url,
170 note: 'Adding a note to existing URL',
171 curatorId: curatorId.value,
172 };
173
174 const secondResult = await useCase.execute(secondRequest);
175 expect(secondResult.isOk()).toBe(true);
176 const secondResponse = secondResult.unwrap();
177
178 // Should have same URL card ID
179 expect(secondResponse.urlCardId).toBe(firstResponse.urlCardId);
180 expect(secondResponse.noteCardId).toBeDefined();
181
182 // Should have URL card + note card
183 const savedCards = cardRepository.getAllCards();
184 expect(savedCards).toHaveLength(2);
185
186 const urlCards = savedCards.filter(
187 (card) => card.content.type === CardTypeEnum.URL,
188 );
189 expect(urlCards).toHaveLength(1); // Only one URL card
190 });
191
192 it('should create new URL card when another user has URL card with same URL', async () => {
193 const url = 'https://example.com/shared';
194 const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
195
196 // First user creates URL card
197 const firstRequest = {
198 url,
199 curatorId: otherCuratorId.value,
200 };
201
202 const firstResult = await useCase.execute(firstRequest);
203 expect(firstResult.isOk()).toBe(true);
204 const firstResponse = firstResult.unwrap();
205
206 // Second user (different curator) should create their own URL card
207 const secondRequest = {
208 url,
209 curatorId: curatorId.value,
210 };
211
212 const secondResult = await useCase.execute(secondRequest);
213 expect(secondResult.isOk()).toBe(true);
214 const secondResponse = secondResult.unwrap();
215
216 // Should have different URL card IDs
217 expect(secondResponse.urlCardId).not.toBe(firstResponse.urlCardId);
218
219 // Should have two separate URL cards
220 const savedCards = cardRepository.getAllCards();
221 expect(savedCards).toHaveLength(2);
222
223 const urlCards = savedCards.filter(
224 (card) => card.content.type === CardTypeEnum.URL,
225 );
226 expect(urlCards).toHaveLength(2); // Two separate URL cards
227
228 // Verify each card belongs to the correct curator
229 const firstUserCard = urlCards.find((card) =>
230 card.props.curatorId.equals(otherCuratorId),
231 );
232 const secondUserCard = urlCards.find((card) =>
233 card.props.curatorId.equals(curatorId),
234 );
235
236 expect(firstUserCard).toBeDefined();
237 expect(secondUserCard).toBeDefined();
238 });
239
240 it('should create new URL card when no one has URL card with that URL yet', async () => {
241 const url = 'https://example.com/brand-new';
242
243 // Verify no cards exist initially
244 expect(cardRepository.getAllCards()).toHaveLength(0);
245
246 const request = {
247 url,
248 curatorId: curatorId.value,
249 };
250
251 const result = await useCase.execute(request);
252
253 expect(result.isOk()).toBe(true);
254 const response = result.unwrap();
255 expect(response.urlCardId).toBeDefined();
256
257 // Verify new URL card was created
258 const savedCards = cardRepository.getAllCards();
259 expect(savedCards).toHaveLength(1);
260
261 const urlCard = savedCards[0];
262 expect(urlCard?.content.type).toBe(CardTypeEnum.URL);
263 expect(urlCard?.props.curatorId.equals(curatorId)).toBe(true);
264 });
265
266 it('should update existing note card when URL already exists with a note', async () => {
267 const url = 'https://example.com/existing';
268
269 // First request creates URL card with note
270 const firstRequest = {
271 url,
272 note: 'Original note',
273 curatorId: curatorId.value,
274 };
275
276 const firstResult = await useCase.execute(firstRequest);
277 expect(firstResult.isOk()).toBe(true);
278 const firstResponse = firstResult.unwrap();
279 expect(firstResponse.noteCardId).toBeDefined();
280
281 // Get the original note card
282 const cardsAfterFirst = cardRepository.getAllCards();
283 const originalNoteCard = cardsAfterFirst.find(
284 (card) => card.content.type === CardTypeEnum.NOTE,
285 );
286 expect(originalNoteCard).toBeDefined();
287 expect(originalNoteCard?.content.noteContent?.text).toBe('Original note');
288
289 // Second request updates the note
290 const secondRequest = {
291 url,
292 note: 'Updated note',
293 curatorId: curatorId.value,
294 };
295
296 const secondResult = await useCase.execute(secondRequest);
297 expect(secondResult.isOk()).toBe(true);
298 const secondResponse = secondResult.unwrap();
299
300 // Should still have the same note card ID
301 expect(secondResponse.noteCardId).toBe(firstResponse.noteCardId);
302
303 // Should still have 2 cards (URL + Note)
304 const savedCards = cardRepository.getAllCards();
305 expect(savedCards).toHaveLength(2);
306
307 // Verify note was updated
308 const updatedNoteCard = savedCards.find(
309 (card) => card.content.type === CardTypeEnum.NOTE,
310 );
311 expect(updatedNoteCard).toBeDefined();
312 expect(updatedNoteCard?.content.noteContent?.text).toBe('Updated note');
313 expect(updatedNoteCard?.cardId.getStringValue()).toBe(
314 firstResponse.noteCardId,
315 );
316 });
317 });
318
319 describe('Collection handling', () => {
320 it('should add URL card to specified collections', async () => {
321 // Create a test collection
322 const collection = new CollectionBuilder()
323 .withAuthorId(curatorId.value)
324 .withName('Test Collection')
325 .build();
326
327 if (collection instanceof Error) {
328 throw new Error(`Failed to create collection: ${collection.message}`);
329 }
330
331 await collectionRepository.save(collection);
332
333 const request = {
334 url: 'https://example.com/article',
335 collectionIds: [collection.collectionId.getStringValue()],
336 curatorId: curatorId.value,
337 };
338
339 const result = await useCase.execute(request);
340
341 expect(result.isOk()).toBe(true);
342
343 // Verify collection link was published
344 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
345 collection.collectionId.getStringValue(),
346 );
347 expect(publishedLinks).toHaveLength(1);
348
349 // Verify CardAddedToLibraryEvent was published
350 const libraryEvents = eventPublisher.getPublishedEventsOfType(
351 EventNames.CARD_ADDED_TO_LIBRARY,
352 ) as CardAddedToLibraryEvent[];
353 expect(libraryEvents).toHaveLength(1);
354 expect(libraryEvents[0]?.curatorId.equals(curatorId)).toBe(true);
355
356 // Verify CardAddedToCollectionEvent was published
357 const collectionEvents = eventPublisher.getPublishedEventsOfType(
358 EventNames.CARD_ADDED_TO_COLLECTION,
359 ) as CardAddedToCollectionEvent[];
360 expect(collectionEvents).toHaveLength(1);
361 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe(
362 collection.collectionId.getStringValue(),
363 );
364 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true);
365 });
366
367 it('should add URL card (not note card) to collections when note is provided', async () => {
368 // Create a test collection
369 const collection = new CollectionBuilder()
370 .withAuthorId(curatorId.value)
371 .withName('Test Collection')
372 .build();
373
374 if (collection instanceof Error) {
375 throw new Error(`Failed to create collection: ${collection.message}`);
376 }
377
378 await collectionRepository.save(collection);
379
380 const request = {
381 url: 'https://example.com/article',
382 note: 'This is my note about the article',
383 collectionIds: [collection.collectionId.getStringValue()],
384 curatorId: curatorId.value,
385 };
386
387 const result = await useCase.execute(request);
388
389 expect(result.isOk()).toBe(true);
390 const response = result.unwrap();
391
392 // Verify both URL and note cards were created
393 expect(response.urlCardId).toBeDefined();
394 expect(response.noteCardId).toBeDefined();
395
396 // Verify both cards were saved
397 const savedCards = cardRepository.getAllCards();
398 expect(savedCards).toHaveLength(2);
399
400 const urlCard = savedCards.find(
401 (card) => card.content.type === CardTypeEnum.URL,
402 );
403 const noteCard = savedCards.find(
404 (card) => card.content.type === CardTypeEnum.NOTE,
405 );
406
407 expect(urlCard).toBeDefined();
408 expect(noteCard).toBeDefined();
409
410 // Verify collection link was published for URL card only
411 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
412 collection.collectionId.getStringValue(),
413 );
414 expect(publishedLinks).toHaveLength(1);
415
416 // Verify the published link is for the URL card, not the note card
417 const publishedLink = publishedLinks[0];
418 expect(publishedLink?.cardId).toBe(urlCard?.cardId.getStringValue());
419 expect(publishedLink?.cardId).not.toBe(noteCard?.cardId.getStringValue());
420
421 // Verify both cards are in the library
422 const publishedCards = cardPublisher.getPublishedCards();
423 expect(publishedCards).toHaveLength(2);
424
425 // Verify CardAddedToLibraryEvent was published for both cards
426 const libraryEvents = eventPublisher.getPublishedEventsOfType(
427 EventNames.CARD_ADDED_TO_LIBRARY,
428 ) as CardAddedToLibraryEvent[];
429 expect(libraryEvents).toHaveLength(1);
430
431 // Verify CardAddedToCollectionEvent was published for URL card only
432 const collectionEvents = eventPublisher.getPublishedEventsOfType(
433 EventNames.CARD_ADDED_TO_COLLECTION,
434 ) as CardAddedToCollectionEvent[];
435 expect(collectionEvents).toHaveLength(1);
436 expect(collectionEvents[0]?.cardId.getStringValue()).toBe(
437 urlCard?.cardId.getStringValue(),
438 );
439 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe(
440 collection.collectionId.getStringValue(),
441 );
442 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true);
443 });
444
445 it('should fail when collection does not exist', async () => {
446 const request = {
447 url: 'https://example.com/article',
448 collectionIds: ['non-existent-collection-id'],
449 curatorId: curatorId.value,
450 };
451
452 const result = await useCase.execute(request);
453
454 expect(result.isErr()).toBe(true);
455 if (result.isErr()) {
456 expect(result.error.message).toContain('Collection not found');
457 }
458 });
459 });
460
461 describe('Validation', () => {
462 it('should fail with invalid URL', async () => {
463 const request = {
464 url: 'not-a-valid-url',
465 curatorId: curatorId.value,
466 };
467
468 const result = await useCase.execute(request);
469
470 expect(result.isErr()).toBe(true);
471 if (result.isErr()) {
472 expect(result.error.message).toContain('Invalid URL');
473 }
474 });
475
476 it('should fail with invalid curator ID', async () => {
477 const request = {
478 url: 'https://example.com/article',
479 curatorId: 'invalid-curator-id',
480 };
481
482 const result = await useCase.execute(request);
483
484 expect(result.isErr()).toBe(true);
485 if (result.isErr()) {
486 expect(result.error.message).toContain('Invalid curator ID');
487 }
488 });
489
490 it('should fail with invalid collection ID', async () => {
491 const request = {
492 url: 'https://example.com/article',
493 collectionIds: ['invalid-collection-id'],
494 curatorId: curatorId.value,
495 };
496
497 const result = await useCase.execute(request);
498
499 expect(result.isErr()).toBe(true);
500 if (result.isErr()) {
501 expect(result.error.message).toContain('Collection not found');
502 }
503 });
504 });
505
506 describe('Metadata service integration', () => {
507 it('should handle metadata service failure gracefully', async () => {
508 // Configure metadata service to fail
509 metadataService.setShouldFail(true);
510
511 const request = {
512 url: 'https://example.com/article',
513 curatorId: curatorId.value,
514 };
515
516 const result = await useCase.execute(request);
517
518 expect(result.isErr()).toBe(true);
519 if (result.isErr()) {
520 expect(result.error.message).toContain('Failed to fetch metadata');
521 }
522 });
523 });
524});