A social knowledge tool for researchers built on ATProto
at main 685 lines 21 kB view raw
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});