Something went wrong. Try again.
A social knowledge tool for research trails, built on ATProto semble.so
research-tool social-network tools-for-thought atproto
Something went wrong. Try again.
TypeScript
at development
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473import { AddUrlToLibraryUseCase } from '../../application/useCases/commands/AddUrlToLibraryUseCase';import { InMemoryCardRepository } from '../utils/InMemoryCardRepository';import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository';import { FakeCardPublisher } from '../utils/FakeCardPublisher';import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher';import { FakeMetadataService } from '../utils/FakeMetadataService';import { CardLibraryService } from '../../domain/services/CardLibraryService';import { CardCollectionService } from '../../domain/services/CardCollectionService';import { CuratorId } from '../../domain/value-objects/CuratorId';import { CollectionBuilder } from '../utils/builders/CollectionBuilder';import { CardTypeEnum } from '../../domain/value-objects/CardType';import { FakeEventPublisher } from '../utils/FakeEventPublisher';import { CardAddedToLibraryEvent } from '../../domain/events/CardAddedToLibraryEvent';import { CardAddedToCollectionEvent } from '../../domain/events/CardAddedToCollectionEvent';import { EventNames } from 'src/shared/infrastructure/events/EventConfig';
describe('AddUrlToLibraryUseCase', () => { let useCase: AddUrlToLibraryUseCase; let cardRepository: InMemoryCardRepository; let collectionRepository: InMemoryCollectionRepository; let cardPublisher: FakeCardPublisher; let collectionPublisher: FakeCollectionPublisher; let metadataService: FakeMetadataService; let cardLibraryService: CardLibraryService; let cardCollectionService: CardCollectionService; let eventPublisher: FakeEventPublisher; let curatorId: CuratorId;
beforeEach(() => { cardRepository = new InMemoryCardRepository(); collectionRepository = new InMemoryCollectionRepository(); cardPublisher = new FakeCardPublisher(); collectionPublisher = new FakeCollectionPublisher(); metadataService = new FakeMetadataService(); eventPublisher = new FakeEventPublisher();
cardLibraryService = new CardLibraryService( cardRepository, cardPublisher, collectionRepository, cardCollectionService, ); cardCollectionService = new CardCollectionService( collectionRepository, collectionPublisher, );
useCase = new AddUrlToLibraryUseCase( cardRepository, metadataService, cardLibraryService, cardCollectionService, eventPublisher, );
curatorId = CuratorId.create('did:plc:testcurator').unwrap(); });
afterEach(() => { cardRepository.clear(); collectionRepository.clear(); cardPublisher.clear(); collectionPublisher.clear(); metadataService.clear(); eventPublisher.clear(); });
describe('Basic URL card creation', () => { it('should create and add a URL card to library', async () => { const request = { url: 'https://example.com/article', curatorId: curatorId.value, };
const result = await useCase.execute(request);
expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCardId).toBeDefined(); expect(response.noteCardId).toBeUndefined();
// Verify card was saved const savedCards = cardRepository.getAllCards(); expect(savedCards).toHaveLength(1); expect(savedCards[0]!.content.type).toBe(CardTypeEnum.URL);
// Verify card was published to library const publishedCards = cardPublisher.getPublishedCards(); expect(publishedCards).toHaveLength(1);
// Verify CardAddedToLibraryEvent was published const libraryEvents = eventPublisher.getPublishedEventsOfType( EventNames.CARD_ADDED_TO_LIBRARY, ) as CardAddedToLibraryEvent[]; expect(libraryEvents).toHaveLength(1); expect(libraryEvents[0]?.cardId.getStringValue()).toBe( response.urlCardId, ); expect(libraryEvents[0]?.curatorId.equals(curatorId)).toBe(true); });
it('should create URL card with note when note is provided', async () => { const request = { url: 'https://example.com/article', note: 'This is a great article about testing', curatorId: curatorId.value, };
const result = await useCase.execute(request);
expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCardId).toBeDefined(); expect(response.noteCardId).toBeDefined();
// Verify both cards were saved const savedCards = cardRepository.getAllCards(); expect(savedCards).toHaveLength(2);
const urlCard = savedCards.find( (card) => card.content.type === CardTypeEnum.URL, ); const noteCard = savedCards.find( (card) => card.content.type === CardTypeEnum.NOTE, );
expect(urlCard).toBeDefined(); expect(noteCard).toBeDefined(); expect(noteCard?.parentCardId?.getStringValue()).toBe( urlCard?.cardId.getStringValue(), );
// Verify both cards were published to library const publishedCards = cardPublisher.getPublishedCards(); expect(publishedCards).toHaveLength(2);
// Verify CardAddedToLibraryEvent was published for only URL card const libraryEvents = eventPublisher.getPublishedEventsOfType( EventNames.CARD_ADDED_TO_LIBRARY, ) as CardAddedToLibraryEvent[]; expect(libraryEvents).toHaveLength(1);
const urlCardEvent = libraryEvents.find( (event) => event.cardId.getStringValue() === urlCard?.cardId.getStringValue(), );
expect(urlCardEvent).toBeDefined(); expect(urlCardEvent?.curatorId.equals(curatorId)).toBe(true); }); });
describe('Existing URL card handling', () => { it('should reuse existing URL card instead of creating new one', async () => { const url = 'https://example.com/existing';
// First request creates the URL card const firstRequest = { url, curatorId: curatorId.value, };
const firstResult = await useCase.execute(firstRequest); expect(firstResult.isOk()).toBe(true); const firstResponse = firstResult.unwrap();
// Second request should reuse the same URL card const secondRequest = { url, note: 'Adding a note to existing URL', curatorId: curatorId.value, };
const secondResult = await useCase.execute(secondRequest); expect(secondResult.isOk()).toBe(true); const secondResponse = secondResult.unwrap();
// Should have same URL card ID expect(secondResponse.urlCardId).toBe(firstResponse.urlCardId); expect(secondResponse.noteCardId).toBeDefined();
// Should have URL card + note card const savedCards = cardRepository.getAllCards(); expect(savedCards).toHaveLength(2);
const urlCards = savedCards.filter( (card) => card.content.type === CardTypeEnum.URL, ); expect(urlCards).toHaveLength(1); // Only one URL card });
it('should create new URL card when another user has URL card with same URL', async () => { const url = 'https://example.com/shared'; const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap();
// First user creates URL card const firstRequest = { url, curatorId: otherCuratorId.value, };
const firstResult = await useCase.execute(firstRequest); expect(firstResult.isOk()).toBe(true); const firstResponse = firstResult.unwrap();
// Second user (different curator) should create their own URL card const secondRequest = { url, curatorId: curatorId.value, };
const secondResult = await useCase.execute(secondRequest); expect(secondResult.isOk()).toBe(true); const secondResponse = secondResult.unwrap();
// Should have different URL card IDs expect(secondResponse.urlCardId).not.toBe(firstResponse.urlCardId);
// Should have two separate URL cards const savedCards = cardRepository.getAllCards(); expect(savedCards).toHaveLength(2);
const urlCards = savedCards.filter( (card) => card.content.type === CardTypeEnum.URL, ); expect(urlCards).toHaveLength(2); // Two separate URL cards
// Verify each card belongs to the correct curator const firstUserCard = urlCards.find((card) => card.props.curatorId.equals(otherCuratorId), ); const secondUserCard = urlCards.find((card) => card.props.curatorId.equals(curatorId), );
expect(firstUserCard).toBeDefined(); expect(secondUserCard).toBeDefined(); });
it('should create new URL card when no one has URL card with that URL yet', async () => { const url = 'https://example.com/brand-new';
// Verify no cards exist initially expect(cardRepository.getAllCards()).toHaveLength(0);
const request = { url, curatorId: curatorId.value, };
const result = await useCase.execute(request);
expect(result.isOk()).toBe(true); const response = result.unwrap(); expect(response.urlCardId).toBeDefined();
// Verify new URL card was created const savedCards = cardRepository.getAllCards(); expect(savedCards).toHaveLength(1);
const urlCard = savedCards[0]; expect(urlCard?.content.type).toBe(CardTypeEnum.URL); expect(urlCard?.props.curatorId.equals(curatorId)).toBe(true); }); });
describe('Collection handling', () => { it('should add URL card to specified collections', async () => { // Create a test collection const collection = new CollectionBuilder() .withAuthorId(curatorId.value) .withName('Test Collection') .build();
if (collection instanceof Error) { throw new Error(`Failed to create collection: ${collection.message}`); }
await collectionRepository.save(collection);
const request = { url: 'https://example.com/article', collectionIds: [collection.collectionId.getStringValue()], curatorId: curatorId.value, };
const result = await useCase.execute(request);
expect(result.isOk()).toBe(true);
// Verify collection link was published const publishedLinks = collectionPublisher.getPublishedLinksForCollection( collection.collectionId.getStringValue(), ); expect(publishedLinks).toHaveLength(1);
// Verify CardAddedToLibraryEvent was published const libraryEvents = eventPublisher.getPublishedEventsOfType( EventNames.CARD_ADDED_TO_LIBRARY, ) as CardAddedToLibraryEvent[]; expect(libraryEvents).toHaveLength(1); expect(libraryEvents[0]?.curatorId.equals(curatorId)).toBe(true);
// Verify CardAddedToCollectionEvent was published const collectionEvents = eventPublisher.getPublishedEventsOfType( EventNames.CARD_ADDED_TO_COLLECTION, ) as CardAddedToCollectionEvent[]; expect(collectionEvents).toHaveLength(1); expect(collectionEvents[0]?.collectionId.getStringValue()).toBe( collection.collectionId.getStringValue(), ); expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true); });
it('should add URL card (not note card) to collections when note is provided', async () => { // Create a test collection const collection = new CollectionBuilder() .withAuthorId(curatorId.value) .withName('Test Collection') .build();
if (collection instanceof Error) { throw new Error(`Failed to create collection: ${collection.message}`); }
await collectionRepository.save(collection);
const request = { url: 'https://example.com/article', note: 'This is my note about the article', collectionIds: [collection.collectionId.getStringValue()], curatorId: curatorId.value, };
const result = await useCase.execute(request);
expect(result.isOk()).toBe(true); const response = result.unwrap();
// Verify both URL and note cards were created expect(response.urlCardId).toBeDefined(); expect(response.noteCardId).toBeDefined();
// Verify both cards were saved const savedCards = cardRepository.getAllCards(); expect(savedCards).toHaveLength(2);
const urlCard = savedCards.find( (card) => card.content.type === CardTypeEnum.URL, ); const noteCard = savedCards.find( (card) => card.content.type === CardTypeEnum.NOTE, );
expect(urlCard).toBeDefined(); expect(noteCard).toBeDefined();
// Verify collection link was published for URL card only const publishedLinks = collectionPublisher.getPublishedLinksForCollection( collection.collectionId.getStringValue(), ); expect(publishedLinks).toHaveLength(1);
// Verify the published link is for the URL card, not the note card const publishedLink = publishedLinks[0]; expect(publishedLink?.cardId).toBe(urlCard?.cardId.getStringValue()); expect(publishedLink?.cardId).not.toBe(noteCard?.cardId.getStringValue());
// Verify both cards are in the library const publishedCards = cardPublisher.getPublishedCards(); expect(publishedCards).toHaveLength(2);
// Verify CardAddedToLibraryEvent was published for both cards const libraryEvents = eventPublisher.getPublishedEventsOfType( EventNames.CARD_ADDED_TO_LIBRARY, ) as CardAddedToLibraryEvent[]; expect(libraryEvents).toHaveLength(1);
// Verify CardAddedToCollectionEvent was published for URL card only const collectionEvents = eventPublisher.getPublishedEventsOfType( EventNames.CARD_ADDED_TO_COLLECTION, ) as CardAddedToCollectionEvent[]; expect(collectionEvents).toHaveLength(1); expect(collectionEvents[0]?.cardId.getStringValue()).toBe( urlCard?.cardId.getStringValue(), ); expect(collectionEvents[0]?.collectionId.getStringValue()).toBe( collection.collectionId.getStringValue(), ); expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true); });
it('should fail when collection does not exist', async () => { const request = { url: 'https://example.com/article', collectionIds: ['non-existent-collection-id'], curatorId: curatorId.value, };
const result = await useCase.execute(request);
expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain('Collection not found'); } }); });
describe('Validation', () => { it('should fail with invalid URL', async () => { const request = { url: 'not-a-valid-url', curatorId: curatorId.value, };
const result = await useCase.execute(request);
expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain('Invalid URL'); } });
it('should fail with invalid curator ID', async () => { const request = { url: 'https://example.com/article', curatorId: 'invalid-curator-id', };
const result = await useCase.execute(request);
expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain('Invalid curator ID'); } });
it('should fail with invalid collection ID', async () => { const request = { url: 'https://example.com/article', collectionIds: ['invalid-collection-id'], curatorId: curatorId.value, };
const result = await useCase.execute(request);
expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain('Collection not found'); } }); });
describe('Metadata service integration', () => { it('should handle metadata service failure gracefully', async () => { // Configure metadata service to fail metadataService.setShouldFail(true);
const request = { url: 'https://example.com/article', curatorId: curatorId.value, };
const result = await useCase.execute(request);
expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain('Failed to fetch metadata'); } }); });});