import { GetCollectionPageUseCase } from '../../application/useCases/queries/GetCollectionPageUseCase'; import { InMemoryCardQueryRepository } from '../utils/InMemoryCardQueryRepository'; import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; import { FakeProfileService } from '../utils/FakeProfileService'; import { CuratorId } from '../../domain/value-objects/CuratorId'; import { CollectionId } from '../../domain/value-objects/CollectionId'; import { Collection, CollectionAccessType } from '../../domain/Collection'; 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'; import { ICollectionRepository } from '../../domain/ICollectionRepository'; describe('GetCollectionPageUseCase', () => { let useCase: GetCollectionPageUseCase; let collectionRepo: InMemoryCollectionRepository; let cardRepo: InMemoryCardRepository; let cardQueryRepo: InMemoryCardQueryRepository; let profileService: FakeProfileService; let curatorId: CuratorId; let collectionId: CollectionId; beforeEach(() => { collectionRepo = new InMemoryCollectionRepository(); cardRepo = new InMemoryCardRepository(); cardQueryRepo = new InMemoryCardQueryRepository(cardRepo, collectionRepo); profileService = new FakeProfileService(); useCase = new GetCollectionPageUseCase( collectionRepo, cardQueryRepo, profileService, ); curatorId = CuratorId.create('did:plc:testcurator').unwrap(); collectionId = CollectionId.create(new UniqueEntityID()).unwrap(); // Set up profile for the curator profileService.addProfile({ id: curatorId.value, name: 'Test Curator', handle: 'testcurator', avatarUrl: 'https://example.com/avatar.jpg', bio: 'Test curator bio', }); }); afterEach(() => { collectionRepo.clear(); cardRepo.clear(); cardQueryRepo.clear(); profileService.clear(); }); describe('Basic functionality', () => { it('should return collection page with empty cards when collection has no cards', async () => { // Create collection const collection = Collection.create( { authorId: curatorId, name: 'Empty Collection', description: 'A collection with no cards', accessType: CollectionAccessType.OPEN, collaboratorIds: [], createdAt: new Date(), updatedAt: new Date(), }, collectionId.getValue(), ).unwrap(); await collectionRepo.save(collection); const query = { collectionId: collectionId.getStringValue(), }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.id).toBe(collectionId.getStringValue()); expect(response.name).toBe('Empty Collection'); expect(response.description).toBe('A collection with no cards'); expect(response.author.id).toBe(curatorId.value); expect(response.author.name).toBe('Test Curator'); expect(response.author.handle).toBe('testcurator'); expect(response.author.avatarUrl).toBe('https://example.com/avatar.jpg'); expect(response.urlCards).toHaveLength(0); expect(response.pagination.totalCount).toBe(0); expect(response.pagination.currentPage).toBe(1); expect(response.pagination.hasMore).toBe(false); }); it('should return collection page with URL cards', async () => { // Create collection const collection = Collection.create( { authorId: curatorId, name: 'Test Collection', accessType: CollectionAccessType.OPEN, collaboratorIds: [], createdAt: new Date(), updatedAt: new Date(), }, collectionId.getValue(), ).unwrap(); // 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 card1Result = Card.create({ curatorId: curatorId, type: cardType1, content: cardContent1, url: url1, libraryMemberships: [], libraryCount: 0, createdAt: new Date(), updatedAt: new Date(), }); if (card1Result.isErr()) { throw card1Result.error; } const card1 = card1Result.value; await cardRepo.save(card1); // 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 card2Result = Card.create({ curatorId: curatorId, type: cardType2, content: cardContent2, url: url2, libraryMemberships: [], libraryCount: 0, createdAt: new Date(), updatedAt: new Date(), }); if (card2Result.isErr()) { throw card2Result.error; } const card2 = card2Result.value; await cardRepo.save(card2); // Add cards to collection collection.addCard(card1.cardId, curatorId); collection.addCard(card2.cardId, curatorId); await collectionRepo.save(collection); const query = { collectionId: collectionId.getStringValue(), }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCards).toHaveLength(2); expect(response.pagination.totalCount).toBe(2); // Verify card data const firstCard = response.urlCards.find( (card) => card.url === 'https://example.com/article1', ); const secondCard = response.urlCards.find( (card) => card.url === 'https://example.com/article2', ); expect(firstCard).toBeDefined(); expect(firstCard?.cardContent.title).toBe('First Article'); expect(firstCard?.cardContent.author).toBe('John Doe'); expect(secondCard).toBeDefined(); expect(secondCard?.cardContent.title).toBe('Second Article'); }); it('should include notes in URL cards', async () => { // Create collection const collection = Collection.create( { authorId: curatorId, name: 'Collection with Notes', accessType: CollectionAccessType.OPEN, collaboratorIds: [], createdAt: new Date(), updatedAt: new Date(), }, collectionId.getValue(), ).unwrap(); // Create URL card const urlMetadata = UrlMetadata.create({ url: 'https://example.com/article-with-note', title: 'Article with Note', description: 'An article with an associated note', }).unwrap(); const url = URL.create('https://example.com/article-with-note').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: [], libraryCount: 0, createdAt: new Date(), updatedAt: new Date(), }); if (cardResult.isErr()) { throw cardResult.error; } const card = cardResult.value; await cardRepo.save(card); // Create a note card that references the same URL const noteCardResult = Card.create({ curatorId: curatorId, type: CardType.create(CardTypeEnum.NOTE).unwrap(), content: CardContent.createNoteContent( 'This is my note about the article', ).unwrap(), parentCardId: card.cardId, url: url, libraryMemberships: [], libraryCount: 0, createdAt: new Date(), updatedAt: new Date(), }); if (noteCardResult.isErr()) { throw noteCardResult.error; } const noteCard = noteCardResult.value; await cardRepo.save(noteCard); // Add card to collection collection.addCard(card.cardId, curatorId); await collectionRepo.save(collection); const query = { collectionId: collectionId.getStringValue(), }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCards).toHaveLength(1); const responseCard = response.urlCards[0]!; expect(responseCard.cardContent.title).toBe('Article with Note'); expect(responseCard.note).toBeDefined(); expect(responseCard.note?.text).toBe('This is my note about the article'); }); it('should handle collection without description', async () => { // Create collection without description const collection = Collection.create( { authorId: curatorId, name: 'No Description Collection', accessType: CollectionAccessType.OPEN, collaboratorIds: [], createdAt: new Date(), updatedAt: new Date(), }, collectionId.getValue(), ).unwrap(); await collectionRepo.save(collection); const query = { collectionId: collectionId.getStringValue(), }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.description).toBeUndefined(); }); }); describe('Pagination', () => { beforeEach(async () => { // Create collection const collection = Collection.create( { authorId: curatorId, name: 'Large Collection', accessType: CollectionAccessType.OPEN, collaboratorIds: [], createdAt: new Date(), updatedAt: new Date(), }, collectionId.getValue(), ).unwrap(); await collectionRepo.save(collection); // 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: [], libraryCount: 0, createdAt: new Date(), updatedAt: new Date(), }); if (cardResult.isErr()) { throw cardResult.error; } const card = cardResult.value; await cardRepo.save(card); // Add card to collection collection.addCard(card.cardId, curatorId); } await collectionRepo.save(collection); }); it('should handle pagination correctly', async () => { const query = { collectionId: collectionId.getStringValue(), page: 1, limit: 2, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCards).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 = { collectionId: collectionId.getStringValue(), page: 2, limit: 2, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCards).toHaveLength(2); expect(response.pagination.currentPage).toBe(2); expect(response.pagination.hasMore).toBe(true); }); it('should handle last page correctly', async () => { const query = { collectionId: collectionId.getStringValue(), page: 3, limit: 2, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCards).toHaveLength(1); expect(response.pagination.currentPage).toBe(3); expect(response.pagination.hasMore).toBe(false); }); it('should cap limit at 100', async () => { const query = { collectionId: collectionId.getStringValue(), 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 collection const collection = Collection.create( { authorId: curatorId, name: 'Sortable Collection', accessType: CollectionAccessType.OPEN, collaboratorIds: [], createdAt: new Date(), updatedAt: new Date(), }, collectionId.getValue(), ).unwrap(); await collectionRepo.save(collection); // Create URL cards with different properties for sorting const now = new Date(); // Create Alpha card (oldest created, middle updated) 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() - 5000) }, ], libraryCount: 1, createdAt: new Date(now.getTime() - 3000), // oldest updatedAt: new Date(now.getTime() - 1000), // middle }); if (alphaCardResult.isErr()) { throw alphaCardResult.error; } await cardRepo.save(alphaCardResult.value); // Create Beta card (middle created, oldest updated) 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() - 5000) }, ], libraryCount: 1, createdAt: new Date(now.getTime() - 2000), // middle updatedAt: new Date(now.getTime() - 3000), // oldest }); if (betaCardResult.isErr()) { throw betaCardResult.error; } await cardRepo.save(betaCardResult.value); // Create Gamma card (newest created, newest updated) 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() - 3000) }, ], libraryCount: 1, createdAt: new Date(now.getTime() - 1000), // newest updatedAt: new Date(now.getTime()), // newest }); if (gammaCardResult.isErr()) { throw gammaCardResult.error; } await cardRepo.save(gammaCardResult.value); // Add all cards to collection collection.addCard(alphaCardResult.value.cardId, curatorId); collection.addCard(betaCardResult.value.cardId, curatorId); collection.addCard(gammaCardResult.value.cardId, curatorId); await collectionRepo.save(collection); }); it('should sort by updated date descending', async () => { const query = { collectionId: collectionId.getStringValue(), sortBy: CardSortField.UPDATED_AT, sortOrder: SortOrder.DESC, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCards[0]?.cardContent.title).toBe('Gamma Article'); // newest updated expect(response.urlCards[1]?.cardContent.title).toBe('Alpha Article'); // middle updated expect(response.urlCards[2]?.cardContent.title).toBe('Beta Article'); // oldest updated }); it('should sort by created date ascending', async () => { const query = { collectionId: collectionId.getStringValue(), sortBy: CardSortField.CREATED_AT, sortOrder: SortOrder.ASC, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCards[0]?.cardContent.title).toBe('Alpha Article'); // oldest created expect(response.urlCards[1]?.cardContent.title).toBe('Beta Article'); // middle created expect(response.urlCards[2]?.cardContent.title).toBe('Gamma Article'); // newest created }); it('should sort by library count (all URL cards have same count)', async () => { const query = { collectionId: collectionId.getStringValue(), sortBy: CardSortField.LIBRARY_COUNT, sortOrder: SortOrder.DESC, }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCards).toHaveLength(3); // All URL cards have library count of 1 (only creator's library) expect(response.urlCards[0]?.libraryCount).toBe(1); expect(response.urlCards[1]?.libraryCount).toBe(1); expect(response.urlCards[2]?.libraryCount).toBe(1); expect(response.sorting.sortBy).toBe(CardSortField.LIBRARY_COUNT); expect(response.sorting.sortOrder).toBe(SortOrder.DESC); }); it('should use default sorting when not specified', async () => { const query = { collectionId: collectionId.getStringValue(), }; 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 collection ID', async () => { const query = { collectionId: 'invalid-collection-id', }; const result = await useCase.execute(query); expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain('Collection not found'); } }); it('should fail when collection not found', async () => { const nonExistentCollectionId = CollectionId.create( new UniqueEntityID(), ).unwrap(); const query = { collectionId: nonExistentCollectionId.getStringValue(), }; const result = await useCase.execute(query); expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain('Collection not found'); } }); it('should fail when author profile not found', async () => { // Create collection with author that has no profile const unknownCuratorId = CuratorId.create( 'did:plc:unknowncurator', ).unwrap(); const collection = Collection.create( { authorId: unknownCuratorId, name: 'Orphaned Collection', accessType: CollectionAccessType.OPEN, collaboratorIds: [], createdAt: new Date(), updatedAt: new Date(), }, collectionId.getValue(), ).unwrap(); await collectionRepo.save(collection); const query = { collectionId: collectionId.getStringValue(), }; const result = await useCase.execute(query); expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain( 'Failed to fetch author profile', ); } }); it('should handle repository errors gracefully', async () => { // Create a mock collection repository that throws an error const errorCollectionRepo: ICollectionRepository = { findById: jest .fn() .mockRejectedValue(new Error('Database connection failed')), save: jest.fn(), delete: jest.fn(), findByCuratorId: jest.fn(), findByCardId: jest.fn(), findByCuratorIdContainingCard: jest.fn(), }; const errorUseCase = new GetCollectionPageUseCase( errorCollectionRepo, cardQueryRepo, profileService, ); const query = { collectionId: collectionId.getStringValue(), }; const result = await errorUseCase.execute(query); expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain( 'Failed to retrieve collection page', ); expect(result.error.message).toContain('Database connection failed'); } }); it('should handle card query repository errors gracefully', async () => { // Create collection const collection = Collection.create( { authorId: curatorId, name: 'Test Collection', accessType: CollectionAccessType.OPEN, collaboratorIds: [], createdAt: new Date(), updatedAt: new Date(), }, collectionId.getValue(), ).unwrap(); await collectionRepo.save(collection); // Create a mock card query repository that throws an error const errorCardQueryRepo = { getUrlCardsOfUser: jest.fn(), getCardsInCollection: jest .fn() .mockRejectedValue(new Error('Query failed')), getUrlCardView: jest.fn(), getLibrariesForCard: jest.fn(), getLibrariesForUrl: jest.fn(), getNoteCardsForUrl: jest.fn(), }; const errorUseCase = new GetCollectionPageUseCase( collectionRepo, errorCardQueryRepo, profileService, ); const query = { collectionId: collectionId.getStringValue(), }; const result = await errorUseCase.execute(query); expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain( 'Failed to retrieve collection page', ); expect(result.error.message).toContain('Query failed'); } }); }); describe('Edge cases', () => { it('should handle URL cards with minimal metadata', async () => { // Create collection const collection = Collection.create( { authorId: curatorId, name: 'Minimal Collection', accessType: CollectionAccessType.OPEN, collaboratorIds: [], createdAt: new Date(), updatedAt: new Date(), }, collectionId.getValue(), ).unwrap(); await collectionRepo.save(collection); // Create URL card 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(), }); if (cardResult.isErr()) { throw cardResult.error; } const card = cardResult.value; await cardRepo.save(card); // Add card to collection collection.addCard(card.cardId, curatorId); await collectionRepo.save(collection); const query = { collectionId: collectionId.getStringValue(), }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCards).toHaveLength(1); expect(response.urlCards[0]?.cardContent.title).toBeUndefined(); expect(response.urlCards[0]?.cardContent.description).toBeUndefined(); expect(response.urlCards[0]?.cardContent.author).toBeUndefined(); expect(response.urlCards[0]?.cardContent.thumbnailUrl).toBeUndefined(); }); it('should handle author profile with minimal data', async () => { // Create curator with minimal profile const minimalCuratorId = CuratorId.create( 'did:plc:minimalcurator', ).unwrap(); profileService.addProfile({ id: minimalCuratorId.value, name: 'Minimal Curator', handle: 'minimal', // No avatarUrl or bio }); const collection = Collection.create( { authorId: minimalCuratorId, name: 'Minimal Author Collection', accessType: CollectionAccessType.OPEN, collaboratorIds: [], createdAt: new Date(), updatedAt: new Date(), }, collectionId.getValue(), ).unwrap(); await collectionRepo.save(collection); const query = { collectionId: collectionId.getStringValue(), }; const result = await useCase.execute(query); expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.author.name).toBe('Minimal Curator'); expect(response.author.handle).toBe('minimal'); expect(response.author.avatarUrl).toBeUndefined(); }); }); });