import { GetUrlCardsUseCase } from '../../application/useCases/queries/GetUrlCardsUseCase'; import { InMemoryCardQueryRepository } from '../utils/InMemoryCardQueryRepository'; import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; import { FakeIdentityResolutionService } from '../utils/FakeIdentityResolutionService'; import { FakeProfileService } from '../utils/FakeProfileService'; import { CuratorId } from '../../domain/value-objects/CuratorId'; import { Card } from '../../domain/Card'; import { CardType, CardTypeEnum } from '../../domain/value-objects/CardType'; import { CardContent } from '../../domain/value-objects/CardContent'; import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; import { URL } from '../../domain/value-objects/URL'; import { CardSortField, SortOrder } from '../../domain/ICardQueryRepository'; import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; describe('GetUrlCardsUseCase', () => { let useCase: GetUrlCardsUseCase; let cardQueryRepo: InMemoryCardQueryRepository; let cardRepo: InMemoryCardRepository; let collectionRepo: InMemoryCollectionRepository; let identityResolutionService: FakeIdentityResolutionService; let profileService: FakeProfileService; let curatorId: CuratorId; beforeEach(() => { cardRepo = InMemoryCardRepository.getInstance(); collectionRepo = InMemoryCollectionRepository.getInstance(); cardQueryRepo = new InMemoryCardQueryRepository(cardRepo, collectionRepo); identityResolutionService = new FakeIdentityResolutionService(); profileService = new FakeProfileService(); useCase = new GetUrlCardsUseCase( cardQueryRepo, identityResolutionService, profileService, ); curatorId = CuratorId.create('did:plc:testcurator').unwrap(); // Add the test curator profile to the profile service profileService.addProfile({ id: curatorId.value, name: 'Test Curator', handle: 'testcurator.bsky.social', avatarUrl: 'https://example.com/avatar.jpg', bio: 'Test curator bio', }); }); afterEach(() => { cardRepo.clear(); collectionRepo.clear(); cardQueryRepo.clear(); identityResolutionService.clear(); profileService.clear(); }); describe('Basic functionality', () => { it('should return empty cards list when user has no URL cards', async () => { const query = { userId: curatorId.value, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(0); expect(response.pagination.totalCount).toBe(0); expect(response.pagination.currentPage).toBe(1); expect(response.pagination.hasMore).toBe(false); }); it("should return user's URL cards", async () => { // Create first URL card const urlMetadata1 = UrlMetadata.create({ url: 'https://example.com/article1', title: 'First Article', description: 'Description of first article', author: 'John Doe', imageUrl: 'https://example.com/thumb1.jpg', }).unwrap(); const url1 = URL.create('https://example.com/article1').unwrap(); const cardType1 = CardType.create(CardTypeEnum.URL).unwrap(); const cardContent1 = CardContent.createUrlContent( url1, urlMetadata1, ).unwrap(); const cardResult1 = Card.create( { curatorId: curatorId, type: cardType1, content: cardContent1, url: url1, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date('2023-01-01') }, ], libraryCount: 1, createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), }, new UniqueEntityID(), ); if (cardResult1.isErr()) { throw cardResult1.error; } // Create second URL card const urlMetadata2 = UrlMetadata.create({ url: 'https://example.com/article2', title: 'Second Article', description: 'Description of second article', author: 'Jane Smith', }).unwrap(); const url2 = URL.create('https://example.com/article2').unwrap(); const cardType2 = CardType.create(CardTypeEnum.URL).unwrap(); const cardContent2 = CardContent.createUrlContent( url2, urlMetadata2, ).unwrap(); const cardResult2 = Card.create( { curatorId: curatorId, type: cardType2, content: cardContent2, url: url2, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date('2023-01-02') }, ], libraryCount: 1, createdAt: new Date('2023-01-02'), updatedAt: new Date('2023-01-02'), }, new UniqueEntityID(), ); if (cardResult2.isErr()) { throw cardResult2.error; } await cardRepo.save(cardResult1.value); await cardRepo.save(cardResult2.value); const query = { userId: curatorId.value, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(2); expect(response.pagination.totalCount).toBe(2); // Verify card data const firstCard = response.cards.find( (card: any) => card.url === 'https://example.com/article1', ); const secondCard = response.cards.find( (card: any) => card.url === 'https://example.com/article2', ); expect(firstCard).toBeDefined(); expect(firstCard?.cardContent.title).toBe('First Article'); expect(firstCard?.cardContent.author).toBe('John Doe'); expect(firstCard?.libraryCount).toBe(1); expect(firstCard?.author.id).toBe(curatorId.value); expect(firstCard?.author.name).toBe('Test Curator'); expect(firstCard?.author.handle).toBe('testcurator.bsky.social'); expect(secondCard).toBeDefined(); expect(secondCard?.cardContent.title).toBe('Second Article'); expect(secondCard?.libraryCount).toBe(1); expect(secondCard?.author.id).toBe(curatorId.value); expect(secondCard?.author.name).toBe('Test Curator'); }); it('should include notes in URL cards (collections no longer included)', async () => { // Create URL metadata const urlMetadata = UrlMetadata.create({ url: 'https://example.com/article-with-extras', title: 'Article with Collections and Note', description: 'An article with associated data', }).unwrap(); // Create URL and card content const url = URL.create( 'https://example.com/article-with-extras', ).unwrap(); const cardType = CardType.create(CardTypeEnum.URL).unwrap(); const cardContent = CardContent.createUrlContent( url, urlMetadata, ).unwrap(); // Create card const cardResult = Card.create( { curatorId: curatorId, type: cardType, content: cardContent, url: url, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date('2023-01-01') }, ], libraryCount: 1, createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), }, new UniqueEntityID(), ); if (cardResult.isErr()) { throw cardResult.error; } await cardRepo.save(cardResult.value); // Note: Collection and note testing would require creating actual Collection and Note domain objects // and saving them to their respective repositories. This is simplified for now. const query = { userId: curatorId.value, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(1); const card = response.cards[0]!; expect(card.note).toBeUndefined(); // No note created in this simplified test expect(card.author.id).toBe(curatorId.value); expect(card.author.name).toBe('Test Curator'); }); it('should only return URL cards for the specified user', async () => { const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); // Add the other curator profile to the profile service profileService.addProfile({ id: otherCuratorId.value, name: 'Other Curator', handle: 'othercurator.bsky.social', avatarUrl: 'https://example.com/other-avatar.jpg', bio: 'Other curator bio', }); // Create my card const myUrlMetadata = UrlMetadata.create({ url: 'https://example.com/my-article', title: 'My Article', }).unwrap(); const myUrl = URL.create('https://example.com/my-article').unwrap(); const myCardType = CardType.create(CardTypeEnum.URL).unwrap(); const myCardContent = CardContent.createUrlContent( myUrl, myUrlMetadata, ).unwrap(); const myCardResult = Card.create( { curatorId: curatorId, type: myCardType, content: myCardContent, url: myUrl, libraryMemberships: [{ curatorId: curatorId, addedAt: new Date() }], libraryCount: 1, createdAt: new Date(), updatedAt: new Date(), }, new UniqueEntityID(), ); if (myCardResult.isErr()) { throw myCardResult.error; } // Create other user's card const otherUrlMetadata = UrlMetadata.create({ url: 'https://example.com/other-article', title: 'Other Article', }).unwrap(); const otherUrl = URL.create('https://example.com/other-article').unwrap(); const otherCardType = CardType.create(CardTypeEnum.URL).unwrap(); const otherCardContent = CardContent.createUrlContent( otherUrl, otherUrlMetadata, ).unwrap(); const otherCardResult = Card.create( { curatorId: otherCuratorId, type: otherCardType, content: otherCardContent, url: otherUrl, libraryMemberships: [ { curatorId: otherCuratorId, addedAt: new Date() }, ], libraryCount: 1, createdAt: new Date(), updatedAt: new Date(), }, new UniqueEntityID(), ); if (otherCardResult.isErr()) { throw otherCardResult.error; } await cardRepo.save(myCardResult.value); await cardRepo.save(otherCardResult.value); const query = { userId: curatorId.value, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(1); expect(response.cards[0]?.cardContent.title).toBe('My Article'); expect(response.cards[0]?.author.id).toBe(curatorId.value); expect(response.cards[0]?.author.name).toBe('Test Curator'); }); }); describe('Pagination', () => { beforeEach(async () => { // Create multiple URL cards for pagination testing for (let i = 1; i <= 5; i++) { const urlMetadata = UrlMetadata.create({ url: `https://example.com/article${i}`, title: `Article ${i}`, description: `Description of article ${i}`, }).unwrap(); const url = URL.create(`https://example.com/article${i}`).unwrap(); const cardType = CardType.create(CardTypeEnum.URL).unwrap(); const cardContent = CardContent.createUrlContent( url, urlMetadata, ).unwrap(); const cardResult = Card.create( { curatorId: curatorId, type: cardType, content: cardContent, url: url, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date(`2023-01-${i.toString().padStart(2, '0')}`), }, ], libraryCount: 1, createdAt: new Date(`2023-01-${i.toString().padStart(2, '0')}`), updatedAt: new Date(`2023-01-${i.toString().padStart(2, '0')}`), }, new UniqueEntityID(), ); if (cardResult.isErr()) { throw cardResult.error; } await cardRepo.save(cardResult.value); } }); it('should handle pagination correctly', async () => { const query = { userId: curatorId.value, page: 1, limit: 2, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(2); expect(response.pagination.currentPage).toBe(1); expect(response.pagination.totalPages).toBe(3); expect(response.pagination.totalCount).toBe(5); expect(response.pagination.hasMore).toBe(true); expect(response.pagination.limit).toBe(2); }); it('should handle second page correctly', async () => { const query = { userId: curatorId.value, page: 2, limit: 2, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(2); expect(response.pagination.currentPage).toBe(2); expect(response.pagination.hasMore).toBe(true); }); it('should handle last page correctly', async () => { const query = { userId: curatorId.value, page: 3, limit: 2, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(1); expect(response.pagination.currentPage).toBe(3); expect(response.pagination.hasMore).toBe(false); }); it('should cap limit at 100', async () => { const query = { userId: curatorId.value, limit: 150, // Should be capped at 100 }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.pagination.limit).toBe(100); }); }); describe('Sorting', () => { beforeEach(async () => { // Create URL cards with different properties for sorting const now = new Date(); // Create Alpha card const alphaMetadata = UrlMetadata.create({ url: 'https://example.com/alpha', title: 'Alpha Article', }).unwrap(); const alphaUrl = URL.create('https://example.com/alpha').unwrap(); const alphaCardType = CardType.create(CardTypeEnum.URL).unwrap(); const alphaCardContent = CardContent.createUrlContent( alphaUrl, alphaMetadata, ).unwrap(); const alphaCardResult = Card.create( { curatorId: curatorId, type: alphaCardType, content: alphaCardContent, url: alphaUrl, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date(now.getTime() - 2000) }, ], libraryCount: 1, createdAt: new Date(now.getTime() - 2000), updatedAt: new Date(now.getTime() - 1000), }, new UniqueEntityID(), ); if (alphaCardResult.isErr()) { throw alphaCardResult.error; } // Create Beta card const betaMetadata = UrlMetadata.create({ url: 'https://example.com/beta', title: 'Beta Article', }).unwrap(); const betaUrl = URL.create('https://example.com/beta').unwrap(); const betaCardType = CardType.create(CardTypeEnum.URL).unwrap(); const betaCardContent = CardContent.createUrlContent( betaUrl, betaMetadata, ).unwrap(); const betaCardResult = Card.create( { curatorId: curatorId, type: betaCardType, content: betaCardContent, url: betaUrl, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date(now.getTime() - 1000) }, ], libraryCount: 1, createdAt: new Date(now.getTime() - 1000), updatedAt: new Date(now.getTime() - 2000), }, new UniqueEntityID(), ); if (betaCardResult.isErr()) { throw betaCardResult.error; } // Create Gamma card const gammaMetadata = UrlMetadata.create({ url: 'https://example.com/gamma', title: 'Gamma Article', }).unwrap(); const gammaUrl = URL.create('https://example.com/gamma').unwrap(); const gammaCardType = CardType.create(CardTypeEnum.URL).unwrap(); const gammaCardContent = CardContent.createUrlContent( gammaUrl, gammaMetadata, ).unwrap(); const gammaCardResult = Card.create( { curatorId: curatorId, type: gammaCardType, content: gammaCardContent, url: gammaUrl, libraryMemberships: [ { curatorId: curatorId, addedAt: new Date(now.getTime()) }, ], libraryCount: 1, createdAt: new Date(now.getTime()), updatedAt: new Date(now.getTime()), }, new UniqueEntityID(), ); if (gammaCardResult.isErr()) { throw gammaCardResult.error; } await cardRepo.save(alphaCardResult.value); await cardRepo.save(betaCardResult.value); await cardRepo.save(gammaCardResult.value); }); it('should sort by library count descending', async () => { const query = { userId: curatorId.value, sortBy: CardSortField.LIBRARY_COUNT, sortOrder: SortOrder.DESC, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(3); // All URL cards now have libraryCount of 1, so order will be by secondary sort (likely creation time) expect(response.cards[0]?.libraryCount).toBe(1); // gamma (newest) expect(response.cards[1]?.libraryCount).toBe(1); // beta expect(response.cards[2]?.libraryCount).toBe(1); // alpha (oldest) expect(response.sorting.sortBy).toBe(CardSortField.LIBRARY_COUNT); expect(response.sorting.sortOrder).toBe(SortOrder.DESC); }); it('should sort by library count ascending', async () => { const query = { userId: curatorId.value, sortBy: CardSortField.LIBRARY_COUNT, sortOrder: SortOrder.ASC, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); // All URL cards now have libraryCount of 1, so order will be by secondary sort (likely creation time) expect(response.cards[0]?.libraryCount).toBe(1); // alpha (oldest) expect(response.cards[1]?.libraryCount).toBe(1); // beta expect(response.cards[2]?.libraryCount).toBe(1); // gamma (newest) }); it('should sort by updated date descending', async () => { const query = { userId: curatorId.value, sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards[0]?.cardContent.title).toBe('Gamma Article'); // most recent expect(response.cards[1]?.cardContent.title).toBe('Alpha Article'); expect(response.cards[2]?.cardContent.title).toBe('Beta Article'); // oldest // Verify all cards have author info response.cards.forEach((card) => { expect(card.author.id).toBe(curatorId.value); expect(card.author.name).toBe('Test Curator'); }); }); it('should sort by created date ascending', async () => { const query = { userId: curatorId.value, sortBy: CardSortField.CREATED_AT, sortOrder: SortOrder.ASC, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards[0]?.cardContent.title).toBe('Alpha Article'); // oldest expect(response.cards[1]?.cardContent.title).toBe('Beta Article'); expect(response.cards[2]?.cardContent.title).toBe('Gamma Article'); // newest // Verify all cards have author info response.cards.forEach((card) => { expect(card.author.id).toBe(curatorId.value); expect(card.author.name).toBe('Test Curator'); }); }); it('should use default sorting when not specified', async () => { const query = { userId: curatorId.value, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.sorting.sortBy).toBe(CardSortField.UPDATED_AT); expect(response.sorting.sortOrder).toBe(SortOrder.DESC); }); }); describe('Error handling', () => { it('should fail with invalid user ID', async () => { const query = { userId: 'invalid-user-id', }; const result = await useCase.execute(query); expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain('Invalid user identifier'); } }); it('should handle repository errors gracefully', async () => { // Create a mock repository that throws an error const errorRepo = { getUrlCardsOfUser: jest .fn() .mockRejectedValue(new Error('Database connection failed')), getCardsInCollection: jest .fn() .mockRejectedValue(new Error('Database connection failed')), getUrlCardView: jest .fn() .mockRejectedValue(new Error('Database connection failed')), getUrlCardBasic: jest.fn(), getLibrariesForCard: jest.fn(), getLibrariesForUrl: jest.fn(), getNoteCardsForUrl: jest.fn(), getBatchUrlCardViews: jest.fn(), getBatchUrlLibraryInfo: jest.fn(), searchUrls: jest.fn(), getUrlAggregateStats: jest.fn(), getProfileCardStats: jest.fn(), }; const errorUseCase = new GetUrlCardsUseCase( errorRepo, identityResolutionService, profileService, ); const query = { userId: curatorId.value, }; const result = await errorUseCase.execute(query); expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain('Failed to retrieve URL cards'); expect(result.error.message).toContain('Database connection failed'); } }); }); describe('Edge cases', () => { it('should handle URL cards with minimal metadata', async () => { // Create URL with minimal metadata const url = URL.create('https://example.com/minimal').unwrap(); const cardType = CardType.create(CardTypeEnum.URL).unwrap(); const cardContent = CardContent.createUrlContent(url).unwrap(); const cardResult = Card.create( { curatorId: curatorId, type: cardType, content: cardContent, url: url, libraryMemberships: [{ curatorId: curatorId, addedAt: new Date() }], libraryCount: 1, createdAt: new Date(), updatedAt: new Date(), }, new UniqueEntityID(), ); if (cardResult.isErr()) { throw cardResult.error; } await cardRepo.save(cardResult.value); const query = { userId: curatorId.value, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(1); expect(response.cards[0]?.cardContent.title).toBeUndefined(); expect(response.cards[0]?.cardContent.description).toBeUndefined(); expect(response.cards[0]?.cardContent.author).toBeUndefined(); expect(response.cards[0]?.cardContent.imageUrl).toBeUndefined(); expect(response.cards[0]?.author.id).toBe(curatorId.value); expect(response.cards[0]?.author.name).toBe('Test Curator'); }); it('should handle cards without notes', async () => { // Create URL metadata const urlMetadata = UrlMetadata.create({ url: 'https://example.com/no-collections', title: 'Article with No Collections', }).unwrap(); const url = URL.create('https://example.com/no-collections').unwrap(); const cardType = CardType.create(CardTypeEnum.URL).unwrap(); const cardContent = CardContent.createUrlContent( url, urlMetadata, ).unwrap(); const cardResult = Card.create( { curatorId: curatorId, type: cardType, content: cardContent, url: url, libraryMemberships: [{ curatorId: curatorId, addedAt: new Date() }], libraryCount: 1, createdAt: new Date(), updatedAt: new Date(), }, new UniqueEntityID(), ); if (cardResult.isErr()) { throw cardResult.error; } await cardRepo.save(cardResult.value); const query = { userId: curatorId.value, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.cards).toHaveLength(1); // Collections are no longer included in the URL cards response expect(response.cards[0]?.author.id).toBe(curatorId.value); expect(response.cards[0]?.author.name).toBe('Test Curator'); }); }); });