A social knowledge tool for researchers built on ATProto
1import { GetUrlStatusForMyLibraryUseCase } from '../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase';
2import { InMemoryCardRepository } from '../utils/InMemoryCardRepository';
3import { InMemoryCardQueryRepository } from '../utils/InMemoryCardQueryRepository';
4import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository';
5import { InMemoryCollectionQueryRepository } from '../utils/InMemoryCollectionQueryRepository';
6import { FakeCardPublisher } from '../utils/FakeCardPublisher';
7import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher';
8import { FakeEventPublisher } from '../utils/FakeEventPublisher';
9import { FakeProfileService } from '../utils/FakeProfileService';
10import { CuratorId } from '../../domain/value-objects/CuratorId';
11import { CardBuilder } from '../utils/builders/CardBuilder';
12import { CollectionBuilder } from '../utils/builders/CollectionBuilder';
13import { CardTypeEnum } from '../../domain/value-objects/CardType';
14import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId';
15import { URL } from '../../domain/value-objects/URL';
16import { err } from 'src/shared/core/Result';
17import { ICardRepository } from '../../domain/ICardRepository';
18
19describe('GetUrlStatusForMyLibraryUseCase', () => {
20 let useCase: GetUrlStatusForMyLibraryUseCase;
21 let cardRepository: InMemoryCardRepository;
22 let cardQueryRepository: InMemoryCardQueryRepository;
23 let collectionRepository: InMemoryCollectionRepository;
24 let collectionQueryRepository: InMemoryCollectionQueryRepository;
25 let cardPublisher: FakeCardPublisher;
26 let collectionPublisher: FakeCollectionPublisher;
27 let eventPublisher: FakeEventPublisher;
28 let profileService: FakeProfileService;
29 let curatorId: CuratorId;
30 let otherCuratorId: CuratorId;
31
32 beforeEach(() => {
33 cardRepository = InMemoryCardRepository.getInstance();
34 collectionRepository = InMemoryCollectionRepository.getInstance();
35 cardQueryRepository = new InMemoryCardQueryRepository(
36 cardRepository,
37 collectionRepository,
38 );
39 collectionQueryRepository = new InMemoryCollectionQueryRepository(
40 collectionRepository,
41 );
42 cardPublisher = new FakeCardPublisher();
43 collectionPublisher = new FakeCollectionPublisher();
44 eventPublisher = new FakeEventPublisher();
45 profileService = new FakeProfileService();
46
47 useCase = new GetUrlStatusForMyLibraryUseCase(
48 cardRepository,
49 cardQueryRepository,
50 collectionQueryRepository,
51 collectionRepository,
52 profileService,
53 eventPublisher,
54 );
55
56 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
57 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
58
59 // Set up profiles
60 profileService.addProfile({
61 id: curatorId.value,
62 name: 'Test Curator',
63 handle: 'testcurator',
64 avatarUrl: 'https://example.com/avatar1.jpg',
65 bio: 'Test curator bio',
66 });
67
68 profileService.addProfile({
69 id: otherCuratorId.value,
70 name: 'Other Curator',
71 handle: 'othercurator',
72 avatarUrl: 'https://example.com/avatar2.jpg',
73 bio: 'Other curator bio',
74 });
75 });
76
77 afterEach(() => {
78 cardRepository.clear();
79 cardQueryRepository.clear();
80 collectionRepository.clear();
81 collectionQueryRepository.clear();
82 cardPublisher.clear();
83 collectionPublisher.clear();
84 eventPublisher.clear();
85 profileService.clear();
86 });
87
88 describe('URL card in collections', () => {
89 it('should return card ID and collections when user has URL card in multiple collections', async () => {
90 const testUrl = 'https://example.com/test-article';
91
92 // Create a URL card
93 const url = URL.create(testUrl).unwrap();
94 const card = new CardBuilder()
95 .withCuratorId(curatorId.value)
96 .withType(CardTypeEnum.URL)
97 .withUrl(url)
98 .build();
99
100 if (card instanceof Error) {
101 throw new Error(`Failed to create card: ${card.message}`);
102 }
103
104 // Add card to library
105 const addToLibResult = card.addToLibrary(curatorId);
106 if (addToLibResult.isErr()) {
107 throw new Error(
108 `Failed to add card to library: ${addToLibResult.error.message}`,
109 );
110 }
111
112 await cardRepository.save(card);
113
114 // Publish the card to simulate it being published
115 cardPublisher.publishCardToLibrary(card, curatorId);
116
117 // Create first collection
118 const collection1 = new CollectionBuilder()
119 .withAuthorId(curatorId.value)
120 .withName('Tech Articles')
121 .withDescription('Collection of technology articles')
122 .build();
123
124 if (collection1 instanceof Error) {
125 throw new Error(`Failed to create collection1: ${collection1.message}`);
126 }
127
128 // Create second collection
129 const collection2 = new CollectionBuilder()
130 .withAuthorId(curatorId.value)
131 .withName('Reading List')
132 .withDescription('My personal reading list')
133 .build();
134
135 if (collection2 instanceof Error) {
136 throw new Error(`Failed to create collection2: ${collection2.message}`);
137 }
138
139 // Add card to both collections
140 const addToCollection1Result = collection1.addCard(
141 card.cardId,
142 curatorId,
143 );
144 if (addToCollection1Result.isErr()) {
145 throw new Error(
146 `Failed to add card to collection1: ${addToCollection1Result.error.message}`,
147 );
148 }
149
150 const addToCollection2Result = collection2.addCard(
151 card.cardId,
152 curatorId,
153 );
154 if (addToCollection2Result.isErr()) {
155 throw new Error(
156 `Failed to add card to collection2: ${addToCollection2Result.error.message}`,
157 );
158 }
159
160 // Mark collections as published
161 const collection1PublishedRecordId = PublishedRecordId.create({
162 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1',
163 cid: 'bafyreicollection1cid',
164 });
165
166 const collection2PublishedRecordId = PublishedRecordId.create({
167 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2',
168 cid: 'bafyreicollection2cid',
169 });
170
171 collection1.markAsPublished(collection1PublishedRecordId);
172 collection2.markAsPublished(collection2PublishedRecordId);
173
174 // Mark card links as published in collections
175 const cardLinkPublishedRecordId1 = PublishedRecordId.create({
176 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1/link1',
177 cid: 'bafyreilink1cid',
178 });
179
180 const cardLinkPublishedRecordId2 = PublishedRecordId.create({
181 uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2/link2',
182 cid: 'bafyreilink2cid',
183 });
184
185 collection1.markCardLinkAsPublished(
186 card.cardId,
187 cardLinkPublishedRecordId1,
188 );
189 collection2.markCardLinkAsPublished(
190 card.cardId,
191 cardLinkPublishedRecordId2,
192 );
193
194 // Save collections
195 await collectionRepository.save(collection1);
196 await collectionRepository.save(collection2);
197
198 // Publish collections and links
199 collectionPublisher.publish(collection1);
200 collectionPublisher.publish(collection2);
201 collectionPublisher.publishCardAddedToCollection(
202 card,
203 collection1,
204 curatorId,
205 );
206 collectionPublisher.publishCardAddedToCollection(
207 card,
208 collection2,
209 curatorId,
210 );
211
212 // Execute the use case
213 const query = {
214 url: testUrl,
215 curatorId: curatorId.value,
216 };
217
218 const result = await useCase.execute(query);
219
220 // Verify the result
221 expect(result.isOk()).toBe(true);
222 const response = result.unwrap();
223
224 expect(response.card).toBeDefined();
225 expect(response.card?.id).toBe(card.cardId.getStringValue());
226 expect(response.collections).toHaveLength(2);
227
228 // Verify collection details
229 const techArticlesCollection = response.collections?.find(
230 (c) => c.name === 'Tech Articles',
231 );
232 const readingListCollection = response.collections?.find(
233 (c) => c.name === 'Reading List',
234 );
235
236 expect(techArticlesCollection).toBeDefined();
237 expect(techArticlesCollection?.id).toBe(
238 collection1.collectionId.getStringValue(),
239 );
240 expect(techArticlesCollection?.uri).toBe(
241 'at://did:plc:testcurator/network.cosmik.collection/collection1',
242 );
243 expect(techArticlesCollection?.name).toBe('Tech Articles');
244 expect(techArticlesCollection?.description).toBe(
245 'Collection of technology articles',
246 );
247
248 expect(readingListCollection).toBeDefined();
249 expect(readingListCollection?.id).toBe(
250 collection2.collectionId.getStringValue(),
251 );
252 expect(readingListCollection?.uri).toBe(
253 'at://did:plc:testcurator/network.cosmik.collection/collection2',
254 );
255 expect(readingListCollection?.name).toBe('Reading List');
256 expect(readingListCollection?.description).toBe(
257 'My personal reading list',
258 );
259 });
260
261 it('should return card ID and empty collections when user has URL card but not in any collections', async () => {
262 const testUrl = 'https://example.com/standalone-article';
263
264 // Create a URL card
265 const url = URL.create(testUrl).unwrap();
266 const card = new CardBuilder()
267 .withCuratorId(curatorId.value)
268 .withType(CardTypeEnum.URL)
269 .withUrl(url)
270 .build();
271
272 if (card instanceof Error) {
273 throw new Error(`Failed to create card: ${card.message}`);
274 }
275
276 // Add card to library
277 const addToLibResult = card.addToLibrary(curatorId);
278 if (addToLibResult.isErr()) {
279 throw new Error(
280 `Failed to add card to library: ${addToLibResult.error.message}`,
281 );
282 }
283
284 await cardRepository.save(card);
285
286 // Publish the card
287 cardPublisher.publishCardToLibrary(card, curatorId);
288
289 // Execute the use case
290 const query = {
291 url: testUrl,
292 curatorId: curatorId.value,
293 };
294
295 const result = await useCase.execute(query);
296
297 // Verify the result
298 expect(result.isOk()).toBe(true);
299 const response = result.unwrap();
300
301 expect(response.card).toBeDefined();
302 expect(response.card?.id).toBe(card.cardId.getStringValue());
303 expect(response.collections).toHaveLength(0);
304 });
305
306 it('should return empty result when user does not have URL card for the URL', async () => {
307 const testUrl = 'https://example.com/nonexistent-article';
308
309 // Execute the use case without creating any cards
310 const query = {
311 url: testUrl,
312 curatorId: curatorId.value,
313 };
314
315 const result = await useCase.execute(query);
316
317 // Verify the result
318 expect(result.isOk()).toBe(true);
319 const response = result.unwrap();
320
321 expect(response.card).toBeUndefined();
322 expect(response.collections).toBeUndefined();
323 });
324
325 it('should not return collections from other users even if they have the same URL', async () => {
326 const testUrl = 'https://example.com/shared-article';
327
328 // Create URL card for first user
329 const url = URL.create(testUrl).unwrap();
330 const card1 = new CardBuilder()
331 .withCuratorId(curatorId.value)
332 .withType(CardTypeEnum.URL)
333 .withUrl(url)
334 .build();
335
336 if (card1 instanceof Error) {
337 throw new Error(`Failed to create card1: ${card1.message}`);
338 }
339
340 const addToLibResult1 = card1.addToLibrary(curatorId);
341 if (addToLibResult1.isErr()) {
342 throw new Error(
343 `Failed to add card1 to library: ${addToLibResult1.error.message}`,
344 );
345 }
346
347 await cardRepository.save(card1);
348
349 // Create URL card for second user (different card, same URL)
350 const card2 = new CardBuilder()
351 .withCuratorId(otherCuratorId.value)
352 .withType(CardTypeEnum.URL)
353 .withUrl(url)
354 .build();
355
356 if (card2 instanceof Error) {
357 throw new Error(`Failed to create card2: ${card2.message}`);
358 }
359
360 const addToLibResult2 = card2.addToLibrary(otherCuratorId);
361 if (addToLibResult2.isErr()) {
362 throw new Error(
363 `Failed to add card2 to library: ${addToLibResult2.error.message}`,
364 );
365 }
366
367 await cardRepository.save(card2);
368
369 // Create collection for second user and add their card
370 const otherUserCollection = new CollectionBuilder()
371 .withAuthorId(otherCuratorId.value)
372 .withName('Other User Collection')
373 .build();
374
375 if (otherUserCollection instanceof Error) {
376 throw new Error(
377 `Failed to create other user collection: ${otherUserCollection.message}`,
378 );
379 }
380
381 const addToOtherCollectionResult = otherUserCollection.addCard(
382 card2.cardId,
383 otherCuratorId,
384 );
385 if (addToOtherCollectionResult.isErr()) {
386 throw new Error(
387 `Failed to add card2 to other collection: ${addToOtherCollectionResult.error.message}`,
388 );
389 }
390
391 await collectionRepository.save(otherUserCollection);
392
393 // Execute the use case for first user
394 const query = {
395 url: testUrl,
396 curatorId: curatorId.value,
397 };
398
399 const result = await useCase.execute(query);
400
401 // Verify the result - should only see first user's card, no collections
402 expect(result.isOk()).toBe(true);
403 const response = result.unwrap();
404
405 expect(response.card).toBeDefined();
406 expect(response.card?.id).toBe(card1.cardId.getStringValue());
407 expect(response.collections).toHaveLength(0); // No collections for first user
408 });
409
410 it('should only return collections owned by the requesting user', async () => {
411 const testUrl = 'https://example.com/multi-user-article';
412
413 // Create URL card for the user
414 const url = URL.create(testUrl).unwrap();
415 const card = new CardBuilder()
416 .withCuratorId(curatorId.value)
417 .withType(CardTypeEnum.URL)
418 .withUrl(url)
419 .build();
420
421 if (card instanceof Error) {
422 throw new Error(`Failed to create card: ${card.message}`);
423 }
424
425 const addToLibResult = card.addToLibrary(curatorId);
426 if (addToLibResult.isErr()) {
427 throw new Error(
428 `Failed to add card to library: ${addToLibResult.error.message}`,
429 );
430 }
431
432 await cardRepository.save(card);
433
434 // Create user's own collection
435 const userCollection = new CollectionBuilder()
436 .withAuthorId(curatorId.value)
437 .withName('My Collection')
438 .build();
439
440 if (userCollection instanceof Error) {
441 throw new Error(
442 `Failed to create user collection: ${userCollection.message}`,
443 );
444 }
445
446 const addToUserCollectionResult = userCollection.addCard(
447 card.cardId,
448 curatorId,
449 );
450 if (addToUserCollectionResult.isErr()) {
451 throw new Error(
452 `Failed to add card to user collection: ${addToUserCollectionResult.error.message}`,
453 );
454 }
455
456 await collectionRepository.save(userCollection);
457
458 // Create another user's collection (this should not appear in results)
459 const otherUserCollection = new CollectionBuilder()
460 .withAuthorId(otherCuratorId.value)
461 .withName('Other User Collection')
462 .build();
463
464 if (otherUserCollection instanceof Error) {
465 throw new Error(
466 `Failed to create other user collection: ${otherUserCollection.message}`,
467 );
468 }
469
470 // Note: We don't add the card to the other user's collection since they can't add
471 // another user's card to their collection in this domain model
472
473 await collectionRepository.save(otherUserCollection);
474
475 // Execute the use case
476 const query = {
477 url: testUrl,
478 curatorId: curatorId.value,
479 };
480
481 const result = await useCase.execute(query);
482
483 // Verify the result - should only see user's own collection
484 expect(result.isOk()).toBe(true);
485 const response = result.unwrap();
486
487 expect(response.card).toBeDefined();
488 expect(response.card?.id).toBe(card.cardId.getStringValue());
489 expect(response.collections).toHaveLength(1);
490 expect(response.collections?.[0]?.name).toBe('My Collection');
491 expect(response.collections?.[0]?.id).toBe(
492 userCollection.collectionId.getStringValue(),
493 );
494 });
495 });
496
497 describe('Validation', () => {
498 it('should fail with invalid URL', async () => {
499 const query = {
500 url: 'not-a-valid-url',
501 curatorId: curatorId.value,
502 };
503
504 const result = await useCase.execute(query);
505
506 expect(result.isErr()).toBe(true);
507 if (result.isErr()) {
508 expect(result.error.message).toContain('Invalid URL');
509 }
510 });
511
512 it('should fail with invalid curator ID', async () => {
513 const query = {
514 url: 'https://example.com/valid-url',
515 curatorId: 'invalid-curator-id',
516 };
517
518 const result = await useCase.execute(query);
519
520 expect(result.isErr()).toBe(true);
521 if (result.isErr()) {
522 expect(result.error.message).toContain('Invalid curator ID');
523 }
524 });
525 });
526
527 describe('Error handling', () => {
528 it('should handle repository errors gracefully', async () => {
529 // Create a mock repository that returns an error Result
530 const errorCardRepository: ICardRepository = {
531 findUsersUrlCardByUrl: jest
532 .fn()
533 .mockResolvedValue(err(new Error('Database error'))),
534 save: jest.fn(),
535 findById: jest.fn(),
536 delete: jest.fn(),
537 findUsersNoteCardByUrl: jest.fn(),
538 };
539
540 const errorUseCase = new GetUrlStatusForMyLibraryUseCase(
541 errorCardRepository,
542 cardQueryRepository,
543 collectionQueryRepository,
544 collectionRepository,
545 profileService,
546 eventPublisher,
547 );
548
549 const query = {
550 url: 'https://example.com/test-url',
551 curatorId: curatorId.value,
552 };
553
554 const result = await errorUseCase.execute(query);
555
556 expect(result.isErr()).toBe(true);
557 if (result.isErr()) {
558 expect(result.error.message).toContain('Database error');
559 }
560 });
561
562 it('should handle collection query repository errors gracefully', async () => {
563 const testUrl = 'https://example.com/error-test';
564
565 // Create a URL card
566 const url = URL.create(testUrl).unwrap();
567 const card = new CardBuilder()
568 .withCuratorId(curatorId.value)
569 .withType(CardTypeEnum.URL)
570 .withUrl(url)
571 .build();
572
573 if (card instanceof Error) {
574 throw new Error(`Failed to create card: ${card.message}`);
575 }
576
577 const addToLibResult = card.addToLibrary(curatorId);
578 if (addToLibResult.isErr()) {
579 throw new Error(
580 `Failed to add card to library: ${addToLibResult.error.message}`,
581 );
582 }
583
584 await cardRepository.save(card);
585
586 // Create a mock collection query repository that throws an error
587 const errorCollectionQueryRepository = {
588 findByCreator: jest.fn(),
589 getCollectionsContainingCardForUser: jest
590 .fn()
591 .mockRejectedValue(new Error('Collection query error')),
592 getCollectionsWithUrl: jest.fn(),
593 };
594
595 const errorUseCase = new GetUrlStatusForMyLibraryUseCase(
596 cardRepository,
597 cardQueryRepository,
598 errorCollectionQueryRepository,
599 collectionRepository,
600 profileService,
601 eventPublisher,
602 );
603
604 const query = {
605 url: testUrl,
606 curatorId: curatorId.value,
607 };
608
609 const result = await errorUseCase.execute(query);
610
611 expect(result.isErr()).toBe(true);
612 if (result.isErr()) {
613 expect(result.error.message).toContain('Collection query error');
614 }
615 });
616 });
617});