A social knowledge tool for researchers built on ATProto
1import { GetUrlCardViewUseCase } from '../../application/useCases/queries/GetUrlCardViewUseCase';
2import { InMemoryCardQueryRepository } from '../utils/InMemoryCardQueryRepository';
3import { InMemoryCardRepository } from '../utils/InMemoryCardRepository';
4import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository';
5import { FakeProfileService } from '../utils/FakeProfileService';
6import { CuratorId } from '../../domain/value-objects/CuratorId';
7import { Card } from '../../domain/Card';
8import { CardType, CardTypeEnum } from '../../domain/value-objects/CardType';
9import { CardContent } from '../../domain/value-objects/CardContent';
10import { UrlMetadata } from '../../domain/value-objects/UrlMetadata';
11import { URL } from '../../domain/value-objects/URL';
12import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID';
13import { Collection, CollectionAccessType } from '../../domain/Collection';
14
15describe('GetUrlCardViewUseCase', () => {
16 let useCase: GetUrlCardViewUseCase;
17 let cardQueryRepo: InMemoryCardQueryRepository;
18 let cardRepo: InMemoryCardRepository;
19 let collectionRepo: InMemoryCollectionRepository;
20 let profileService: FakeProfileService;
21 let curatorId: CuratorId;
22 let otherCuratorId: CuratorId;
23 let cardId: string;
24
25 beforeEach(() => {
26 cardRepo = InMemoryCardRepository.getInstance();
27 collectionRepo = InMemoryCollectionRepository.getInstance();
28 cardQueryRepo = new InMemoryCardQueryRepository(cardRepo, collectionRepo);
29 profileService = new FakeProfileService();
30 useCase = new GetUrlCardViewUseCase(
31 cardQueryRepo,
32 profileService,
33 collectionRepo,
34 );
35
36 curatorId = CuratorId.create('did:plc:testcurator').unwrap();
37 otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
38 cardId = new UniqueEntityID().toString();
39
40 // Set up profiles for the curators
41 profileService.addProfile({
42 id: curatorId.value,
43 name: 'Test Curator',
44 handle: 'testcurator',
45 avatarUrl: 'https://example.com/avatar1.jpg',
46 bio: 'Test curator bio',
47 });
48
49 profileService.addProfile({
50 id: otherCuratorId.value,
51 name: 'Other Curator',
52 handle: 'othercurator',
53 avatarUrl: 'https://example.com/avatar2.jpg',
54 bio: 'Other curator bio',
55 });
56 });
57
58 afterEach(() => {
59 cardRepo.clear();
60 collectionRepo.clear();
61 cardQueryRepo.clear();
62 profileService.clear();
63 });
64
65 describe('Basic functionality', () => {
66 it('should return URL card view with enriched library data', async () => {
67 // Create URL metadata
68 const urlMetadata = UrlMetadata.create({
69 url: 'https://example.com/article1',
70 title: 'Test Article',
71 description: 'Description of test article',
72 author: 'John Doe',
73 imageUrl: 'https://example.com/thumb1.jpg',
74 }).unwrap();
75
76 // Create URL and card content
77 const url = URL.create('https://example.com/article1').unwrap();
78 const cardType = CardType.create(CardTypeEnum.URL).unwrap();
79 const cardContent = CardContent.createUrlContent(
80 url,
81 urlMetadata,
82 ).unwrap();
83
84 // Create card with library memberships
85 const cardResult = Card.create(
86 {
87 curatorId: curatorId,
88 type: cardType,
89 content: cardContent,
90 url: url,
91 libraryMemberships: [
92 { curatorId: curatorId, addedAt: new Date('2023-01-01') },
93 ],
94 libraryCount: 1,
95 createdAt: new Date('2023-01-01'),
96 updatedAt: new Date('2023-01-01'),
97 },
98 new UniqueEntityID(cardId),
99 );
100
101 if (cardResult.isErr()) {
102 throw cardResult.error;
103 }
104
105 const card = cardResult.value;
106 await cardRepo.save(card);
107
108 // now create a note card that references this URL card
109 const noteCardResult = Card.create({
110 curatorId: curatorId,
111 type: CardType.create(CardTypeEnum.NOTE).unwrap(),
112 content: CardContent.createNoteContent(
113 'This is my note about the article',
114 ).unwrap(),
115 parentCardId: card.cardId,
116 url: url,
117 });
118
119 const noteCard = noteCardResult.unwrap();
120 await cardRepo.save(noteCard);
121
122 const collectionResult = Collection.create({
123 name: 'Reading List',
124 authorId: curatorId,
125 accessType: CollectionAccessType.CLOSED,
126 createdAt: new Date('2023-01-01'),
127 updatedAt: new Date('2023-01-01'),
128 collaboratorIds: [],
129 });
130 if (collectionResult.isErr()) {
131 throw collectionResult.error;
132 }
133 const collection = collectionResult.value;
134 collection.addCard(card.cardId, curatorId);
135 await collectionRepo.save(collection);
136
137 const query = {
138 cardId: cardId,
139 };
140
141 const result = await useCase.execute(query);
142
143 expect(result.isOk()).toBe(true);
144 const response = result.unwrap();
145
146 // Verify basic card data
147 expect(response.id).toBe(cardId);
148 expect(response.type).toBe(CardTypeEnum.URL);
149 expect(response.url).toBe('https://example.com/article1');
150 expect(response.cardContent.title).toBe('Test Article');
151 expect(response.cardContent.description).toBe(
152 'Description of test article',
153 );
154 expect(response.cardContent.author).toBe('John Doe');
155 expect(response.cardContent.thumbnailUrl).toBe(
156 'https://example.com/thumb1.jpg',
157 );
158 expect(response.libraryCount).toBe(1);
159
160 // Verify collections
161 expect(response.collections).toHaveLength(1);
162 expect(response.collections[0]?.name).toBe('Reading List');
163 expect(response.collections[0]?.author.id).toBe(curatorId.value);
164
165 // Verify note
166 expect(response.note).toBeDefined();
167 expect(response.note?.text).toBe('This is my note about the article');
168
169 // Verify enriched library data
170 expect(response.libraries).toHaveLength(1);
171
172 const testCuratorLib = response.libraries.find(
173 (lib) => lib.id === curatorId.value,
174 );
175 expect(testCuratorLib).toBeDefined();
176 expect(testCuratorLib?.name).toBe('Test Curator');
177 expect(testCuratorLib?.handle).toBe('testcurator');
178 expect(testCuratorLib?.avatarUrl).toBe('https://example.com/avatar1.jpg');
179 });
180
181 it('should return URL card view with no libraries', async () => {
182 // Create URL metadata
183 const urlMetadata = UrlMetadata.create({
184 url: 'https://example.com/lonely-article',
185 title: 'Lonely Article',
186 description: 'An article with no libraries',
187 }).unwrap();
188
189 // Create URL and card content
190 const url = URL.create('https://example.com/lonely-article').unwrap();
191 const cardType = CardType.create(CardTypeEnum.URL).unwrap();
192 const cardContent = CardContent.createUrlContent(
193 url,
194 urlMetadata,
195 ).unwrap();
196
197 // Create card with no library memberships
198 const cardResult = Card.create(
199 {
200 curatorId: curatorId,
201 type: cardType,
202 content: cardContent,
203 url: url,
204 libraryMemberships: [],
205 libraryCount: 0,
206 createdAt: new Date('2023-01-01'),
207 updatedAt: new Date('2023-01-01'),
208 },
209 new UniqueEntityID(cardId),
210 );
211
212 if (cardResult.isErr()) {
213 throw cardResult.error;
214 }
215
216 const card = cardResult.value;
217 await cardRepo.save(card);
218
219 const query = {
220 cardId: cardId,
221 };
222
223 const result = await useCase.execute(query);
224
225 expect(result.isOk()).toBe(true);
226 const response = result.unwrap();
227 expect(response.libraries).toHaveLength(0);
228 expect(response.collections).toHaveLength(0);
229 expect(response.note).toBeUndefined();
230 });
231
232 it('should return URL card view with minimal metadata', async () => {
233 // Create URL with minimal metadata
234 const url = URL.create('https://example.com/minimal').unwrap();
235 const cardType = CardType.create(CardTypeEnum.URL).unwrap();
236 const cardContent = CardContent.createUrlContent(url).unwrap();
237
238 // Create card with minimal data
239 const cardResult = Card.create(
240 {
241 curatorId: curatorId,
242 type: cardType,
243 content: cardContent,
244 url: url,
245 libraryMemberships: [{ curatorId: curatorId, addedAt: new Date() }],
246 libraryCount: 1,
247 createdAt: new Date(),
248 updatedAt: new Date(),
249 },
250 new UniqueEntityID(cardId),
251 );
252
253 if (cardResult.isErr()) {
254 throw cardResult.error;
255 }
256
257 const card = cardResult.value;
258 await cardRepo.save(card);
259
260 const query = {
261 cardId: cardId,
262 };
263
264 const result = await useCase.execute(query);
265
266 expect(result.isOk()).toBe(true);
267 const response = result.unwrap();
268 expect(response.cardContent.title).toBeUndefined();
269 expect(response.cardContent.description).toBeUndefined();
270 expect(response.cardContent.author).toBeUndefined();
271 expect(response.cardContent.thumbnailUrl).toBeUndefined();
272 expect(response.libraries).toHaveLength(1);
273 });
274
275 it('should handle profiles with minimal data', async () => {
276 // Add a curator with minimal profile data
277 const minimalCuratorId = CuratorId.create('did:plc:minimal').unwrap();
278 profileService.addProfile({
279 id: minimalCuratorId.value,
280 name: 'Minimal User',
281 handle: 'minimal',
282 // No avatarUrl or bio
283 });
284
285 // Create URL metadata
286 const urlMetadata = UrlMetadata.create({
287 url: 'https://example.com/test',
288 title: 'Test Article',
289 }).unwrap();
290
291 // Create URL and card content
292 const url = URL.create('https://example.com/test').unwrap();
293 const cardType = CardType.create(CardTypeEnum.URL).unwrap();
294 const cardContent = CardContent.createUrlContent(
295 url,
296 urlMetadata,
297 ).unwrap();
298
299 // Create card
300 const cardResult = Card.create(
301 {
302 curatorId: minimalCuratorId,
303 type: cardType,
304 content: cardContent,
305 url: url,
306 libraryMemberships: [
307 { curatorId: minimalCuratorId, addedAt: new Date() },
308 ],
309 libraryCount: 1,
310 createdAt: new Date(),
311 updatedAt: new Date(),
312 },
313 new UniqueEntityID(cardId),
314 );
315
316 if (cardResult.isErr()) {
317 throw cardResult.error;
318 }
319
320 const card = cardResult.value;
321 await cardRepo.save(card);
322
323 const query = {
324 cardId: cardId,
325 };
326
327 const result = await useCase.execute(query);
328
329 expect(result.isOk()).toBe(true);
330 const response = result.unwrap();
331 expect(response.libraries).toHaveLength(1);
332 expect(response.libraries[0]?.name).toBe('Minimal User');
333 expect(response.libraries[0]?.handle).toBe('minimal');
334 expect(response.libraries[0]?.avatarUrl).toBeUndefined();
335 });
336 });
337
338 describe('Error handling', () => {
339 it('should fail with invalid card ID', async () => {
340 const query = {
341 cardId: 'invalid-card-id',
342 };
343
344 const result = await useCase.execute(query);
345
346 expect(result.isErr()).toBe(true);
347 if (result.isErr()) {
348 expect(result.error.message).toContain('URL card not found');
349 }
350 });
351
352 it('should fail when card not found', async () => {
353 const nonExistentCardId = new UniqueEntityID().toString();
354
355 const query = {
356 cardId: nonExistentCardId,
357 };
358
359 const result = await useCase.execute(query);
360
361 expect(result.isErr()).toBe(true);
362 if (result.isErr()) {
363 expect(result.error.message).toContain('URL card not found');
364 }
365 });
366
367 it('should fail when profile service fails', async () => {
368 // Create URL metadata
369 const urlMetadata = UrlMetadata.create({
370 url: 'https://example.com/test',
371 title: 'Test Article',
372 }).unwrap();
373
374 // Create URL and card content
375 const url = URL.create('https://example.com/test').unwrap();
376 const cardType = CardType.create(CardTypeEnum.URL).unwrap();
377 const cardContent = CardContent.createUrlContent(
378 url,
379 urlMetadata,
380 ).unwrap();
381
382 // Create card
383 const cardResult = Card.create(
384 {
385 curatorId: curatorId,
386 type: cardType,
387 content: cardContent,
388 url: url,
389 libraryMemberships: [{ curatorId: curatorId, addedAt: new Date() }],
390 libraryCount: 1,
391 createdAt: new Date(),
392 updatedAt: new Date(),
393 },
394 new UniqueEntityID(cardId),
395 );
396
397 if (cardResult.isErr()) {
398 throw cardResult.error;
399 }
400
401 const card = cardResult.value;
402 await cardRepo.save(card);
403
404 // Make profile service fail
405 profileService.setShouldFail(true);
406
407 const query = {
408 cardId: cardId,
409 };
410
411 const result = await useCase.execute(query);
412
413 expect(result.isErr()).toBe(true);
414 if (result.isErr()) {
415 expect(result.error.message).toContain('Failed to fetch card author');
416 }
417 });
418
419 it('should fail when user profile not found', async () => {
420 const unknownUserId = 'did:plc:unknown';
421 const unknownCuratorId = CuratorId.create(unknownUserId).unwrap();
422
423 // Create URL metadata
424 const urlMetadata = UrlMetadata.create({
425 url: 'https://example.com/test',
426 title: 'Test Article',
427 }).unwrap();
428
429 // Create URL and card content
430 const url = URL.create('https://example.com/test').unwrap();
431 const cardType = CardType.create(CardTypeEnum.URL).unwrap();
432 const cardContent = CardContent.createUrlContent(
433 url,
434 urlMetadata,
435 ).unwrap();
436
437 // Create card with unknown user in library
438 const cardResult = Card.create(
439 {
440 curatorId: unknownCuratorId,
441 type: cardType,
442 content: cardContent,
443 url: url,
444 libraryMemberships: [
445 { curatorId: unknownCuratorId, addedAt: new Date() },
446 ],
447 libraryCount: 1,
448 createdAt: new Date(),
449 updatedAt: new Date(),
450 },
451 new UniqueEntityID(cardId),
452 );
453
454 if (cardResult.isErr()) {
455 throw cardResult.error;
456 }
457
458 const card = cardResult.value;
459 await cardRepo.save(card);
460
461 const query = {
462 cardId: cardId,
463 };
464
465 const result = await useCase.execute(query);
466
467 expect(result.isErr()).toBe(true);
468 if (result.isErr()) {
469 expect(result.error.message).toContain('Failed to fetch card author');
470 }
471 });
472
473 it('should handle repository errors gracefully', async () => {
474 // Create a mock repository that throws an error
475 const errorRepo = {
476 getUrlCardsOfUser: jest.fn(),
477 getCardsInCollection: jest.fn(),
478 getUrlCardView: jest
479 .fn()
480 .mockRejectedValue(new Error('Database connection failed')),
481 getUrlCardBasic: jest.fn(),
482 getLibrariesForCard: jest.fn(),
483 getLibrariesForUrl: jest.fn(),
484 getNoteCardsForUrl: jest.fn(),
485 };
486
487 const errorUseCase = new GetUrlCardViewUseCase(
488 errorRepo,
489 profileService,
490 collectionRepo,
491 );
492
493 const query = {
494 cardId: cardId,
495 };
496
497 const result = await errorUseCase.execute(query);
498
499 expect(result.isErr()).toBe(true);
500 if (result.isErr()) {
501 expect(result.error.message).toContain(
502 'Failed to retrieve URL card view',
503 );
504 expect(result.error.message).toContain('Database connection failed');
505 }
506 });
507 });
508
509 describe('Edge cases', () => {
510 it('should handle card with many libraries', async () => {
511 // Create multiple users
512 const userIds: string[] = [];
513 const curatorIds: CuratorId[] = [];
514 for (let i = 1; i <= 5; i++) {
515 const userId = `did:plc:user${i}`;
516 const curatorId = CuratorId.create(userId).unwrap();
517 userIds.push(userId);
518 curatorIds.push(curatorId);
519 profileService.addProfile({
520 id: userId,
521 name: `User ${i}`,
522 handle: `user${i}`,
523 avatarUrl: `https://example.com/avatar${i}.jpg`,
524 });
525 }
526
527 // Create URL metadata
528 const urlMetadata = UrlMetadata.create({
529 url: 'https://example.com/popular-article',
530 title: 'Very Popular Article',
531 }).unwrap();
532
533 // Create URL and card content
534 const url = URL.create('https://example.com/popular-article').unwrap();
535 const cardType = CardType.create(CardTypeEnum.URL).unwrap();
536 const cardContent = CardContent.createUrlContent(
537 url,
538 urlMetadata,
539 ).unwrap();
540
541 // Create card with single library membership (URL cards can only be in creator's library)
542 const cardResult = Card.create(
543 {
544 curatorId: curatorIds[0]!,
545 type: cardType,
546 content: cardContent,
547 url: url,
548 libraryMemberships: [
549 {
550 curatorId: curatorIds[0]!,
551 addedAt: new Date(),
552 },
553 ],
554 libraryCount: 1,
555 createdAt: new Date(),
556 updatedAt: new Date(),
557 },
558 new UniqueEntityID(cardId),
559 );
560
561 if (cardResult.isErr()) {
562 throw cardResult.error;
563 }
564
565 const card = cardResult.value;
566 await cardRepo.save(card);
567
568 const query = {
569 cardId: cardId,
570 };
571
572 const result = await useCase.execute(query);
573
574 expect(result.isOk()).toBe(true);
575 const response = result.unwrap();
576 expect(response.libraries).toHaveLength(1);
577
578 // Verify the creator is included with correct profile data
579 const creatorLib = response.libraries.find(
580 (lib) => lib.id === userIds[0],
581 );
582 expect(creatorLib).toBeDefined();
583 expect(creatorLib?.name).toBe('User 1');
584 expect(creatorLib?.handle).toBe('user1');
585 expect(creatorLib?.avatarUrl).toBe('https://example.com/avatar1.jpg');
586 });
587
588 it('should handle card with many collections', async () => {
589 // Create URL metadata
590 const urlMetadata = UrlMetadata.create({
591 url: 'https://example.com/well-organized',
592 title: 'Well Organized Article',
593 }).unwrap();
594
595 // Create URL and card content
596 const url = URL.create('https://example.com/well-organized').unwrap();
597 const cardType = CardType.create(CardTypeEnum.URL).unwrap();
598 const cardContent = CardContent.createUrlContent(
599 url,
600 urlMetadata,
601 ).unwrap();
602
603 // Create card
604 const cardResult = Card.create(
605 {
606 curatorId: curatorId,
607 type: cardType,
608 content: cardContent,
609 url: url,
610 libraryMemberships: [{ curatorId: curatorId, addedAt: new Date() }],
611 libraryCount: 1,
612 createdAt: new Date(),
613 updatedAt: new Date(),
614 },
615 new UniqueEntityID(cardId),
616 );
617
618 if (cardResult.isErr()) {
619 throw cardResult.error;
620 }
621
622 const card = cardResult.value;
623 await cardRepo.save(card);
624
625 // Create multiple collections and add the card to them
626 const collectionNames = ['Reading List', 'Favorites', 'Tech Articles'];
627
628 for (const collectionName of collectionNames) {
629 const collectionResult = Collection.create({
630 name: collectionName,
631 authorId: curatorId,
632 accessType: CollectionAccessType.CLOSED,
633 createdAt: new Date(),
634 updatedAt: new Date(),
635 collaboratorIds: [],
636 });
637
638 if (collectionResult.isErr()) {
639 throw collectionResult.error;
640 }
641
642 const collection = collectionResult.value;
643 collection.addCard(card.cardId, curatorId);
644 await collectionRepo.save(collection);
645 }
646
647 const query = {
648 cardId: cardId,
649 };
650
651 const result = await useCase.execute(query);
652
653 expect(result.isOk()).toBe(true);
654 const response = result.unwrap();
655 expect(response.collections).toHaveLength(3);
656
657 const collectionNamesInResponse = response.collections
658 .map((c) => c.name)
659 .sort();
660 expect(collectionNamesInResponse).toEqual([
661 'Favorites',
662 'Reading List',
663 'Tech Articles',
664 ]);
665
666 // Verify all collections have the correct author
667 response.collections.forEach((collection) => {
668 expect(collection.author.id).toBe(curatorId.value);
669 });
670 });
671
672 it('should handle empty card ID', async () => {
673 const query = {
674 cardId: '',
675 };
676
677 const result = await useCase.execute(query);
678
679 expect(result.isErr()).toBe(true);
680 if (result.isErr()) {
681 expect(result.error.message).toContain('URL card not found');
682 }
683 });
684 });
685});