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});