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