diff --git a/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts new file mode 100644 index 00000000..98c89487 --- /dev/null +++ b/src/modules/cards/tests/application/GetUrlStatusForMyLibraryUseCase.test.ts @@ -0,0 +1,499 @@ +import { GetUrlStatusForMyLibraryUseCase } from '../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; +import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; +import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; +import { InMemoryCollectionQueryRepository } from '../utils/InMemoryCollectionQueryRepository'; +import { FakeCardPublisher } from '../utils/FakeCardPublisher'; +import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; +import { FakeEventPublisher } from '../utils/FakeEventPublisher'; +import { CuratorId } from '../../domain/value-objects/CuratorId'; +import { CardBuilder } from '../utils/builders/CardBuilder'; +import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; +import { CardTypeEnum } from '../../domain/value-objects/CardType'; +import { PublishedRecordId } from '../../domain/value-objects/PublishedRecordId'; + +describe('GetUrlStatusForMyLibraryUseCase', () => { + let useCase: GetUrlStatusForMyLibraryUseCase; + let cardRepository: InMemoryCardRepository; + let collectionRepository: InMemoryCollectionRepository; + let collectionQueryRepository: InMemoryCollectionQueryRepository; + let cardPublisher: FakeCardPublisher; + let collectionPublisher: FakeCollectionPublisher; + let eventPublisher: FakeEventPublisher; + let curatorId: CuratorId; + let otherCuratorId: CuratorId; + + beforeEach(() => { + cardRepository = new InMemoryCardRepository(); + collectionRepository = new InMemoryCollectionRepository(); + collectionQueryRepository = new InMemoryCollectionQueryRepository(collectionRepository); + cardPublisher = new FakeCardPublisher(); + collectionPublisher = new FakeCollectionPublisher(); + eventPublisher = new FakeEventPublisher(); + + useCase = new GetUrlStatusForMyLibraryUseCase( + cardRepository, + collectionQueryRepository, + eventPublisher, + ); + + curatorId = CuratorId.create('did:plc:testcurator').unwrap(); + otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); + }); + + afterEach(() => { + cardRepository.clear(); + collectionRepository.clear(); + collectionQueryRepository.clear(); + cardPublisher.clear(); + collectionPublisher.clear(); + eventPublisher.clear(); + }); + + describe('URL card in collections', () => { + it('should return card ID and collections when user has URL card in multiple collections', async () => { + const testUrl = 'https://example.com/test-article'; + + // Create a URL card + const card = new CardBuilder() + .withCuratorId(curatorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card instanceof Error) { + throw new Error(`Failed to create card: ${card.message}`); + } + + // Add card to library + const addToLibResult = card.addToLibrary(curatorId); + if (addToLibResult.isErr()) { + throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + } + + await cardRepository.save(card); + + // Publish the card to simulate it being published + cardPublisher.publishCard(card); + + // Create first collection + const collection1 = new CollectionBuilder() + .withAuthorId(curatorId.value) + .withName('Tech Articles') + .withDescription('Collection of technology articles') + .build(); + + if (collection1 instanceof Error) { + throw new Error(`Failed to create collection1: ${collection1.message}`); + } + + // Create second collection + const collection2 = new CollectionBuilder() + .withAuthorId(curatorId.value) + .withName('Reading List') + .withDescription('My personal reading list') + .build(); + + if (collection2 instanceof Error) { + throw new Error(`Failed to create collection2: ${collection2.message}`); + } + + // Add card to both collections + const addToCollection1Result = collection1.addCard(card.cardId, curatorId); + if (addToCollection1Result.isErr()) { + throw new Error(`Failed to add card to collection1: ${addToCollection1Result.error.message}`); + } + + const addToCollection2Result = collection2.addCard(card.cardId, curatorId); + if (addToCollection2Result.isErr()) { + throw new Error(`Failed to add card to collection2: ${addToCollection2Result.error.message}`); + } + + // Mark collections as published + const collection1PublishedRecordId = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1', + cid: 'bafyreicollection1cid', + }).unwrap(); + + const collection2PublishedRecordId = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2', + cid: 'bafyreicollection2cid', + }).unwrap(); + + collection1.markAsPublished(collection1PublishedRecordId); + collection2.markAsPublished(collection2PublishedRecordId); + + // Mark card links as published in collections + const cardLinkPublishedRecordId1 = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection1/link1', + cid: 'bafyreilink1cid', + }).unwrap(); + + const cardLinkPublishedRecordId2 = PublishedRecordId.create({ + uri: 'at://did:plc:testcurator/network.cosmik.collection/collection2/link2', + cid: 'bafyreilink2cid', + }).unwrap(); + + collection1.markCardLinkAsPublished(card.cardId, cardLinkPublishedRecordId1); + collection2.markCardLinkAsPublished(card.cardId, cardLinkPublishedRecordId2); + + // Save collections + await collectionRepository.save(collection1); + await collectionRepository.save(collection2); + + // Publish collections and links + collectionPublisher.publishCollection(collection1); + collectionPublisher.publishCollection(collection2); + collectionPublisher.publishCardLink(collection1.collectionId.getStringValue(), { + cardId: card.cardId.getStringValue(), + publishedRecordId: cardLinkPublishedRecordId1, + }); + collectionPublisher.publishCardLink(collection2.collectionId.getStringValue(), { + cardId: card.cardId.getStringValue(), + publishedRecordId: cardLinkPublishedRecordId2, + }); + + // Execute the use case + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + // Verify the result + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.collections).toHaveLength(2); + + // Verify collection details + const techArticlesCollection = response.collections?.find(c => c.name === 'Tech Articles'); + const readingListCollection = response.collections?.find(c => c.name === 'Reading List'); + + expect(techArticlesCollection).toBeDefined(); + expect(techArticlesCollection?.id).toBe(collection1.collectionId.getStringValue()); + expect(techArticlesCollection?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/collection1'); + expect(techArticlesCollection?.name).toBe('Tech Articles'); + expect(techArticlesCollection?.description).toBe('Collection of technology articles'); + + expect(readingListCollection).toBeDefined(); + expect(readingListCollection?.id).toBe(collection2.collectionId.getStringValue()); + expect(readingListCollection?.uri).toBe('at://did:plc:testcurator/network.cosmik.collection/collection2'); + expect(readingListCollection?.name).toBe('Reading List'); + expect(readingListCollection?.description).toBe('My personal reading list'); + }); + + it('should return card ID and empty collections when user has URL card but not in any collections', async () => { + const testUrl = 'https://example.com/standalone-article'; + + // Create a URL card + const card = new CardBuilder() + .withCuratorId(curatorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card instanceof Error) { + throw new Error(`Failed to create card: ${card.message}`); + } + + // Add card to library + const addToLibResult = card.addToLibrary(curatorId); + if (addToLibResult.isErr()) { + throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + } + + await cardRepository.save(card); + + // Publish the card + cardPublisher.publishCard(card); + + // Execute the use case + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + // Verify the result + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.collections).toHaveLength(0); + }); + + it('should return empty result when user does not have URL card for the URL', async () => { + const testUrl = 'https://example.com/nonexistent-article'; + + // Execute the use case without creating any cards + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + // Verify the result + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.cardId).toBeUndefined(); + expect(response.collections).toBeUndefined(); + }); + + it('should not return collections from other users even if they have the same URL', async () => { + const testUrl = 'https://example.com/shared-article'; + + // Create URL card for first user + const card1 = new CardBuilder() + .withCuratorId(curatorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card1 instanceof Error) { + throw new Error(`Failed to create card1: ${card1.message}`); + } + + const addToLibResult1 = card1.addToLibrary(curatorId); + if (addToLibResult1.isErr()) { + throw new Error(`Failed to add card1 to library: ${addToLibResult1.error.message}`); + } + + await cardRepository.save(card1); + + // Create URL card for second user (different card, same URL) + const card2 = new CardBuilder() + .withCuratorId(otherCuratorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card2 instanceof Error) { + throw new Error(`Failed to create card2: ${card2.message}`); + } + + const addToLibResult2 = card2.addToLibrary(otherCuratorId); + if (addToLibResult2.isErr()) { + throw new Error(`Failed to add card2 to library: ${addToLibResult2.error.message}`); + } + + await cardRepository.save(card2); + + // Create collection for second user and add their card + const otherUserCollection = new CollectionBuilder() + .withAuthorId(otherCuratorId.value) + .withName('Other User Collection') + .build(); + + if (otherUserCollection instanceof Error) { + throw new Error(`Failed to create other user collection: ${otherUserCollection.message}`); + } + + const addToOtherCollectionResult = otherUserCollection.addCard(card2.cardId, otherCuratorId); + if (addToOtherCollectionResult.isErr()) { + throw new Error(`Failed to add card2 to other collection: ${addToOtherCollectionResult.error.message}`); + } + + await collectionRepository.save(otherUserCollection); + + // Execute the use case for first user + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + // Verify the result - should only see first user's card, no collections + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.cardId).toBe(card1.cardId.getStringValue()); + expect(response.collections).toHaveLength(0); // No collections for first user + }); + + it('should only return collections owned by the requesting user', async () => { + const testUrl = 'https://example.com/multi-user-article'; + + // Create URL card for the user + const card = new CardBuilder() + .withCuratorId(curatorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card instanceof Error) { + throw new Error(`Failed to create card: ${card.message}`); + } + + const addToLibResult = card.addToLibrary(curatorId); + if (addToLibResult.isErr()) { + throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + } + + await cardRepository.save(card); + + // Create user's own collection + const userCollection = new CollectionBuilder() + .withAuthorId(curatorId.value) + .withName('My Collection') + .build(); + + if (userCollection instanceof Error) { + throw new Error(`Failed to create user collection: ${userCollection.message}`); + } + + const addToUserCollectionResult = userCollection.addCard(card.cardId, curatorId); + if (addToUserCollectionResult.isErr()) { + throw new Error(`Failed to add card to user collection: ${addToUserCollectionResult.error.message}`); + } + + await collectionRepository.save(userCollection); + + // Create another user's collection (this should not appear in results) + const otherUserCollection = new CollectionBuilder() + .withAuthorId(otherCuratorId.value) + .withName('Other User Collection') + .build(); + + if (otherUserCollection instanceof Error) { + throw new Error(`Failed to create other user collection: ${otherUserCollection.message}`); + } + + // Note: We don't add the card to the other user's collection since they can't add + // another user's card to their collection in this domain model + + await collectionRepository.save(otherUserCollection); + + // Execute the use case + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + // Verify the result - should only see user's own collection + expect(result.isOk()).toBe(true); + const response = result.unwrap(); + + expect(response.cardId).toBe(card.cardId.getStringValue()); + expect(response.collections).toHaveLength(1); + expect(response.collections?.[0]?.name).toBe('My Collection'); + expect(response.collections?.[0]?.id).toBe(userCollection.collectionId.getStringValue()); + }); + }); + + describe('Validation', () => { + it('should fail with invalid URL', async () => { + const query = { + url: 'not-a-valid-url', + curatorId: curatorId.value, + }; + + const result = await useCase.execute(query); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.message).toContain('Invalid URL'); + } + }); + + it('should fail with invalid curator ID', async () => { + const query = { + url: 'https://example.com/valid-url', + curatorId: 'invalid-curator-id', + }; + + const result = await useCase.execute(query); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.message).toContain('Invalid curator ID'); + } + }); + }); + + describe('Error handling', () => { + it('should handle repository errors gracefully', async () => { + // Create a mock repository that throws an error + const errorCardRepository = { + findUsersUrlCardByUrl: jest.fn().mockRejectedValue(new Error('Database error')), + save: jest.fn(), + findById: jest.fn(), + delete: jest.fn(), + findByUrl: jest.fn(), + findByCuratorId: jest.fn(), + findByParentCardId: jest.fn(), + }; + + const errorUseCase = new GetUrlStatusForMyLibraryUseCase( + errorCardRepository, + collectionQueryRepository, + eventPublisher, + ); + + const query = { + url: 'https://example.com/test-url', + curatorId: curatorId.value, + }; + + const result = await errorUseCase.execute(query); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.message).toContain('Database error'); + } + }); + + it('should handle collection query repository errors gracefully', async () => { + const testUrl = 'https://example.com/error-test'; + + // Create a URL card + const card = new CardBuilder() + .withCuratorId(curatorId.value) + .withType(CardTypeEnum.URL) + .withUrl(testUrl) + .build(); + + if (card instanceof Error) { + throw new Error(`Failed to create card: ${card.message}`); + } + + const addToLibResult = card.addToLibrary(curatorId); + if (addToLibResult.isErr()) { + throw new Error(`Failed to add card to library: ${addToLibResult.error.message}`); + } + + await cardRepository.save(card); + + // Create a mock collection query repository that throws an error + const errorCollectionQueryRepository = { + findByCreator: jest.fn(), + getCollectionsContainingCardForUser: jest.fn().mockRejectedValue(new Error('Collection query error')), + }; + + const errorUseCase = new GetUrlStatusForMyLibraryUseCase( + cardRepository, + errorCollectionQueryRepository, + eventPublisher, + ); + + const query = { + url: testUrl, + curatorId: curatorId.value, + }; + + const result = await errorUseCase.execute(query); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.message).toContain('Collection query error'); + } + }); + }); +});