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