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 = new InMemoryCardRepository();
31 collectionRepository = new InMemoryCollectionRepository();
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
267 describe('Collection handling', () => {
268 it('should add URL card to specified collections', async () => {
269 // Create a test collection
270 const collection = new CollectionBuilder()
271 .withAuthorId(curatorId.value)
272 .withName('Test Collection')
273 .build();
274
275 if (collection instanceof Error) {
276 throw new Error(`Failed to create collection: ${collection.message}`);
277 }
278
279 await collectionRepository.save(collection);
280
281 const request = {
282 url: 'https://example.com/article',
283 collectionIds: [collection.collectionId.getStringValue()],
284 curatorId: curatorId.value,
285 };
286
287 const result = await useCase.execute(request);
288
289 expect(result.isOk()).toBe(true);
290
291 // Verify collection link was published
292 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
293 collection.collectionId.getStringValue(),
294 );
295 expect(publishedLinks).toHaveLength(1);
296
297 // Verify CardAddedToLibraryEvent was published
298 const libraryEvents = eventPublisher.getPublishedEventsOfType(
299 EventNames.CARD_ADDED_TO_LIBRARY,
300 ) as CardAddedToLibraryEvent[];
301 expect(libraryEvents).toHaveLength(1);
302 expect(libraryEvents[0]?.curatorId.equals(curatorId)).toBe(true);
303
304 // Verify CardAddedToCollectionEvent was published
305 const collectionEvents = eventPublisher.getPublishedEventsOfType(
306 EventNames.CARD_ADDED_TO_COLLECTION,
307 ) as CardAddedToCollectionEvent[];
308 expect(collectionEvents).toHaveLength(1);
309 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe(
310 collection.collectionId.getStringValue(),
311 );
312 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true);
313 });
314
315 it('should add URL card (not note card) to collections when note is provided', async () => {
316 // Create a test collection
317 const collection = new CollectionBuilder()
318 .withAuthorId(curatorId.value)
319 .withName('Test Collection')
320 .build();
321
322 if (collection instanceof Error) {
323 throw new Error(`Failed to create collection: ${collection.message}`);
324 }
325
326 await collectionRepository.save(collection);
327
328 const request = {
329 url: 'https://example.com/article',
330 note: 'This is my note about the article',
331 collectionIds: [collection.collectionId.getStringValue()],
332 curatorId: curatorId.value,
333 };
334
335 const result = await useCase.execute(request);
336
337 expect(result.isOk()).toBe(true);
338 const response = result.unwrap();
339
340 // Verify both URL and note cards were created
341 expect(response.urlCardId).toBeDefined();
342 expect(response.noteCardId).toBeDefined();
343
344 // Verify both cards were saved
345 const savedCards = cardRepository.getAllCards();
346 expect(savedCards).toHaveLength(2);
347
348 const urlCard = savedCards.find(
349 (card) => card.content.type === CardTypeEnum.URL,
350 );
351 const noteCard = savedCards.find(
352 (card) => card.content.type === CardTypeEnum.NOTE,
353 );
354
355 expect(urlCard).toBeDefined();
356 expect(noteCard).toBeDefined();
357
358 // Verify collection link was published for URL card only
359 const publishedLinks = collectionPublisher.getPublishedLinksForCollection(
360 collection.collectionId.getStringValue(),
361 );
362 expect(publishedLinks).toHaveLength(1);
363
364 // Verify the published link is for the URL card, not the note card
365 const publishedLink = publishedLinks[0];
366 expect(publishedLink?.cardId).toBe(urlCard?.cardId.getStringValue());
367 expect(publishedLink?.cardId).not.toBe(noteCard?.cardId.getStringValue());
368
369 // Verify both cards are in the library
370 const publishedCards = cardPublisher.getPublishedCards();
371 expect(publishedCards).toHaveLength(2);
372
373 // Verify CardAddedToLibraryEvent was published for both cards
374 const libraryEvents = eventPublisher.getPublishedEventsOfType(
375 EventNames.CARD_ADDED_TO_LIBRARY,
376 ) as CardAddedToLibraryEvent[];
377 expect(libraryEvents).toHaveLength(1);
378
379 // Verify CardAddedToCollectionEvent was published for URL card only
380 const collectionEvents = eventPublisher.getPublishedEventsOfType(
381 EventNames.CARD_ADDED_TO_COLLECTION,
382 ) as CardAddedToCollectionEvent[];
383 expect(collectionEvents).toHaveLength(1);
384 expect(collectionEvents[0]?.cardId.getStringValue()).toBe(
385 urlCard?.cardId.getStringValue(),
386 );
387 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe(
388 collection.collectionId.getStringValue(),
389 );
390 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true);
391 });
392
393 it('should fail when collection does not exist', async () => {
394 const request = {
395 url: 'https://example.com/article',
396 collectionIds: ['non-existent-collection-id'],
397 curatorId: curatorId.value,
398 };
399
400 const result = await useCase.execute(request);
401
402 expect(result.isErr()).toBe(true);
403 if (result.isErr()) {
404 expect(result.error.message).toContain('Collection not found');
405 }
406 });
407 });
408
409 describe('Validation', () => {
410 it('should fail with invalid URL', async () => {
411 const request = {
412 url: 'not-a-valid-url',
413 curatorId: curatorId.value,
414 };
415
416 const result = await useCase.execute(request);
417
418 expect(result.isErr()).toBe(true);
419 if (result.isErr()) {
420 expect(result.error.message).toContain('Invalid URL');
421 }
422 });
423
424 it('should fail with invalid curator ID', async () => {
425 const request = {
426 url: 'https://example.com/article',
427 curatorId: 'invalid-curator-id',
428 };
429
430 const result = await useCase.execute(request);
431
432 expect(result.isErr()).toBe(true);
433 if (result.isErr()) {
434 expect(result.error.message).toContain('Invalid curator ID');
435 }
436 });
437
438 it('should fail with invalid collection ID', async () => {
439 const request = {
440 url: 'https://example.com/article',
441 collectionIds: ['invalid-collection-id'],
442 curatorId: curatorId.value,
443 };
444
445 const result = await useCase.execute(request);
446
447 expect(result.isErr()).toBe(true);
448 if (result.isErr()) {
449 expect(result.error.message).toContain('Collection not found');
450 }
451 });
452 });
453
454 describe('Metadata service integration', () => {
455 it('should handle metadata service failure gracefully', async () => {
456 // Configure metadata service to fail
457 metadataService.setShouldFail(true);
458
459 const request = {
460 url: 'https://example.com/article',
461 curatorId: curatorId.value,
462 };
463
464 const result = await useCase.execute(request);
465
466 expect(result.isErr()).toBe(true);
467 if (result.isErr()) {
468 expect(result.error.message).toContain('Failed to fetch metadata');
469 }
470 });
471 });
472});