A social knowledge tool for researchers built on ATProto
1import { AddUrlToLibraryUseCase } from '../../application/useCases/commands/AddUrlToLibraryUseCase'; 2import { InMemoryCardRepository } from '../utils/InMemoryCardRepository'; 3import { InMemoryCollectionRepository } from '../utils/InMemoryCollectionRepository'; 4import { FakeCardPublisher } from '../utils/FakeCardPublisher'; 5import { FakeCollectionPublisher } from '../utils/FakeCollectionPublisher'; 6import { FakeMetadataService } from '../utils/FakeMetadataService'; 7import { CardLibraryService } from '../../domain/services/CardLibraryService'; 8import { CardCollectionService } from '../../domain/services/CardCollectionService'; 9import { CuratorId } from '../../domain/value-objects/CuratorId'; 10import { CollectionBuilder } from '../utils/builders/CollectionBuilder'; 11import { CardTypeEnum } from '../../domain/value-objects/CardType'; 12import { FakeEventPublisher } from '../utils/FakeEventPublisher'; 13import { CardAddedToLibraryEvent } from '../../domain/events/CardAddedToLibraryEvent'; 14import { CardAddedToCollectionEvent } from '../../domain/events/CardAddedToCollectionEvent'; 15import { EventNames } from 'src/shared/infrastructure/events/EventConfig'; 16 17describe('AddUrlToLibraryUseCase', () => { 18 let useCase: AddUrlToLibraryUseCase; 19 let cardRepository: InMemoryCardRepository; 20 let collectionRepository: InMemoryCollectionRepository; 21 let cardPublisher: FakeCardPublisher; 22 let collectionPublisher: FakeCollectionPublisher; 23 let metadataService: FakeMetadataService; 24 let cardLibraryService: CardLibraryService; 25 let cardCollectionService: CardCollectionService; 26 let eventPublisher: FakeEventPublisher; 27 let curatorId: CuratorId; 28 29 beforeEach(() => { 30 cardRepository = new InMemoryCardRepository(); 31 collectionRepository = new InMemoryCollectionRepository(); 32 cardPublisher = new FakeCardPublisher(); 33 collectionPublisher = new FakeCollectionPublisher(); 34 metadataService = new FakeMetadataService(); 35 eventPublisher = new FakeEventPublisher(); 36 37 cardLibraryService = new CardLibraryService( 38 cardRepository, 39 cardPublisher, 40 collectionRepository, 41 cardCollectionService, 42 ); 43 cardCollectionService = new CardCollectionService( 44 collectionRepository, 45 collectionPublisher, 46 ); 47 48 useCase = new AddUrlToLibraryUseCase( 49 cardRepository, 50 metadataService, 51 cardLibraryService, 52 cardCollectionService, 53 eventPublisher, 54 ); 55 56 curatorId = CuratorId.create('did:plc:testcurator').unwrap(); 57 }); 58 59 afterEach(() => { 60 cardRepository.clear(); 61 collectionRepository.clear(); 62 cardPublisher.clear(); 63 collectionPublisher.clear(); 64 metadataService.clear(); 65 eventPublisher.clear(); 66 }); 67 68 describe('Basic URL card creation', () => { 69 it('should create and add a URL card to library', async () => { 70 const request = { 71 url: 'https://example.com/article', 72 curatorId: curatorId.value, 73 }; 74 75 const result = await useCase.execute(request); 76 77 expect(result.isOk()).toBe(true); 78 const response = result.unwrap(); 79 expect(response.urlCardId).toBeDefined(); 80 expect(response.noteCardId).toBeUndefined(); 81 82 // Verify card was saved 83 const savedCards = cardRepository.getAllCards(); 84 expect(savedCards).toHaveLength(1); 85 expect(savedCards[0]!.content.type).toBe(CardTypeEnum.URL); 86 87 // Verify card was published to library 88 const publishedCards = cardPublisher.getPublishedCards(); 89 expect(publishedCards).toHaveLength(1); 90 91 // Verify CardAddedToLibraryEvent was published 92 const libraryEvents = eventPublisher.getPublishedEventsOfType( 93 EventNames.CARD_ADDED_TO_LIBRARY, 94 ) as CardAddedToLibraryEvent[]; 95 expect(libraryEvents).toHaveLength(1); 96 expect(libraryEvents[0]?.cardId.getStringValue()).toBe( 97 response.urlCardId, 98 ); 99 expect(libraryEvents[0]?.curatorId.equals(curatorId)).toBe(true); 100 }); 101 102 it('should create URL card with note when note is provided', async () => { 103 const request = { 104 url: 'https://example.com/article', 105 note: 'This is a great article about testing', 106 curatorId: curatorId.value, 107 }; 108 109 const result = await useCase.execute(request); 110 111 expect(result.isOk()).toBe(true); 112 const response = result.unwrap(); 113 expect(response.urlCardId).toBeDefined(); 114 expect(response.noteCardId).toBeDefined(); 115 116 // Verify both cards were saved 117 const savedCards = cardRepository.getAllCards(); 118 expect(savedCards).toHaveLength(2); 119 120 const urlCard = savedCards.find( 121 (card) => card.content.type === CardTypeEnum.URL, 122 ); 123 const noteCard = savedCards.find( 124 (card) => card.content.type === CardTypeEnum.NOTE, 125 ); 126 127 expect(urlCard).toBeDefined(); 128 expect(noteCard).toBeDefined(); 129 expect(noteCard?.parentCardId?.getStringValue()).toBe( 130 urlCard?.cardId.getStringValue(), 131 ); 132 133 // Verify both cards were published to library 134 const publishedCards = cardPublisher.getPublishedCards(); 135 expect(publishedCards).toHaveLength(2); 136 137 // Verify CardAddedToLibraryEvent was published for only URL card 138 const libraryEvents = eventPublisher.getPublishedEventsOfType( 139 EventNames.CARD_ADDED_TO_LIBRARY, 140 ) as CardAddedToLibraryEvent[]; 141 expect(libraryEvents).toHaveLength(1); 142 143 const urlCardEvent = libraryEvents.find( 144 (event) => 145 event.cardId.getStringValue() === urlCard?.cardId.getStringValue(), 146 ); 147 148 expect(urlCardEvent).toBeDefined(); 149 expect(urlCardEvent?.curatorId.equals(curatorId)).toBe(true); 150 }); 151 }); 152 153 describe('Existing URL card handling', () => { 154 it('should reuse existing URL card instead of creating new one', async () => { 155 const url = 'https://example.com/existing'; 156 157 // First request creates the URL card 158 const firstRequest = { 159 url, 160 curatorId: curatorId.value, 161 }; 162 163 const firstResult = await useCase.execute(firstRequest); 164 expect(firstResult.isOk()).toBe(true); 165 const firstResponse = firstResult.unwrap(); 166 167 // Second request should reuse the same URL card 168 const secondRequest = { 169 url, 170 note: 'Adding a note to existing URL', 171 curatorId: curatorId.value, 172 }; 173 174 const secondResult = await useCase.execute(secondRequest); 175 expect(secondResult.isOk()).toBe(true); 176 const secondResponse = secondResult.unwrap(); 177 178 // Should have same URL card ID 179 expect(secondResponse.urlCardId).toBe(firstResponse.urlCardId); 180 expect(secondResponse.noteCardId).toBeDefined(); 181 182 // Should have URL card + note card 183 const savedCards = cardRepository.getAllCards(); 184 expect(savedCards).toHaveLength(2); 185 186 const urlCards = savedCards.filter( 187 (card) => card.content.type === CardTypeEnum.URL, 188 ); 189 expect(urlCards).toHaveLength(1); // Only one URL card 190 }); 191 192 it('should create new URL card when another user has URL card with same URL', async () => { 193 const url = 'https://example.com/shared'; 194 const otherCuratorId = CuratorId.create('did:plc:othercurator').unwrap(); 195 196 // First user creates URL card 197 const firstRequest = { 198 url, 199 curatorId: otherCuratorId.value, 200 }; 201 202 const firstResult = await useCase.execute(firstRequest); 203 expect(firstResult.isOk()).toBe(true); 204 const firstResponse = firstResult.unwrap(); 205 206 // Second user (different curator) should create their own URL card 207 const secondRequest = { 208 url, 209 curatorId: curatorId.value, 210 }; 211 212 const secondResult = await useCase.execute(secondRequest); 213 expect(secondResult.isOk()).toBe(true); 214 const secondResponse = secondResult.unwrap(); 215 216 // Should have different URL card IDs 217 expect(secondResponse.urlCardId).not.toBe(firstResponse.urlCardId); 218 219 // Should have two separate URL cards 220 const savedCards = cardRepository.getAllCards(); 221 expect(savedCards).toHaveLength(2); 222 223 const urlCards = savedCards.filter( 224 (card) => card.content.type === CardTypeEnum.URL, 225 ); 226 expect(urlCards).toHaveLength(2); // Two separate URL cards 227 228 // Verify each card belongs to the correct curator 229 const firstUserCard = urlCards.find((card) => 230 card.props.curatorId.equals(otherCuratorId), 231 ); 232 const secondUserCard = urlCards.find((card) => 233 card.props.curatorId.equals(curatorId), 234 ); 235 236 expect(firstUserCard).toBeDefined(); 237 expect(secondUserCard).toBeDefined(); 238 }); 239 240 it('should create new URL card when no one has URL card with that URL yet', async () => { 241 const url = 'https://example.com/brand-new'; 242 243 // Verify no cards exist initially 244 expect(cardRepository.getAllCards()).toHaveLength(0); 245 246 const request = { 247 url, 248 curatorId: curatorId.value, 249 }; 250 251 const result = await useCase.execute(request); 252 253 expect(result.isOk()).toBe(true); 254 const response = result.unwrap(); 255 expect(response.urlCardId).toBeDefined(); 256 257 // Verify new URL card was created 258 const savedCards = cardRepository.getAllCards(); 259 expect(savedCards).toHaveLength(1); 260 261 const urlCard = savedCards[0]; 262 expect(urlCard?.content.type).toBe(CardTypeEnum.URL); 263 expect(urlCard?.props.curatorId.equals(curatorId)).toBe(true); 264 }); 265 }); 266 267 describe('Collection handling', () => { 268 it('should add URL card to specified collections', async () => { 269 // Create a test collection 270 const collection = new CollectionBuilder() 271 .withAuthorId(curatorId.value) 272 .withName('Test Collection') 273 .build(); 274 275 if (collection instanceof Error) { 276 throw new Error(`Failed to create collection: ${collection.message}`); 277 } 278 279 await collectionRepository.save(collection); 280 281 const request = { 282 url: 'https://example.com/article', 283 collectionIds: [collection.collectionId.getStringValue()], 284 curatorId: curatorId.value, 285 }; 286 287 const result = await useCase.execute(request); 288 289 expect(result.isOk()).toBe(true); 290 291 // Verify collection link was published 292 const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 293 collection.collectionId.getStringValue(), 294 ); 295 expect(publishedLinks).toHaveLength(1); 296 297 // Verify CardAddedToLibraryEvent was published 298 const libraryEvents = eventPublisher.getPublishedEventsOfType( 299 EventNames.CARD_ADDED_TO_LIBRARY, 300 ) as CardAddedToLibraryEvent[]; 301 expect(libraryEvents).toHaveLength(1); 302 expect(libraryEvents[0]?.curatorId.equals(curatorId)).toBe(true); 303 304 // Verify CardAddedToCollectionEvent was published 305 const collectionEvents = eventPublisher.getPublishedEventsOfType( 306 EventNames.CARD_ADDED_TO_COLLECTION, 307 ) as CardAddedToCollectionEvent[]; 308 expect(collectionEvents).toHaveLength(1); 309 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe( 310 collection.collectionId.getStringValue(), 311 ); 312 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true); 313 }); 314 315 it('should add URL card (not note card) to collections when note is provided', async () => { 316 // Create a test collection 317 const collection = new CollectionBuilder() 318 .withAuthorId(curatorId.value) 319 .withName('Test Collection') 320 .build(); 321 322 if (collection instanceof Error) { 323 throw new Error(`Failed to create collection: ${collection.message}`); 324 } 325 326 await collectionRepository.save(collection); 327 328 const request = { 329 url: 'https://example.com/article', 330 note: 'This is my note about the article', 331 collectionIds: [collection.collectionId.getStringValue()], 332 curatorId: curatorId.value, 333 }; 334 335 const result = await useCase.execute(request); 336 337 expect(result.isOk()).toBe(true); 338 const response = result.unwrap(); 339 340 // Verify both URL and note cards were created 341 expect(response.urlCardId).toBeDefined(); 342 expect(response.noteCardId).toBeDefined(); 343 344 // Verify both cards were saved 345 const savedCards = cardRepository.getAllCards(); 346 expect(savedCards).toHaveLength(2); 347 348 const urlCard = savedCards.find( 349 (card) => card.content.type === CardTypeEnum.URL, 350 ); 351 const noteCard = savedCards.find( 352 (card) => card.content.type === CardTypeEnum.NOTE, 353 ); 354 355 expect(urlCard).toBeDefined(); 356 expect(noteCard).toBeDefined(); 357 358 // Verify collection link was published for URL card only 359 const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 360 collection.collectionId.getStringValue(), 361 ); 362 expect(publishedLinks).toHaveLength(1); 363 364 // Verify the published link is for the URL card, not the note card 365 const publishedLink = publishedLinks[0]; 366 expect(publishedLink?.cardId).toBe(urlCard?.cardId.getStringValue()); 367 expect(publishedLink?.cardId).not.toBe(noteCard?.cardId.getStringValue()); 368 369 // Verify both cards are in the library 370 const publishedCards = cardPublisher.getPublishedCards(); 371 expect(publishedCards).toHaveLength(2); 372 373 // Verify CardAddedToLibraryEvent was published for both cards 374 const libraryEvents = eventPublisher.getPublishedEventsOfType( 375 EventNames.CARD_ADDED_TO_LIBRARY, 376 ) as CardAddedToLibraryEvent[]; 377 expect(libraryEvents).toHaveLength(1); 378 379 // Verify CardAddedToCollectionEvent was published for URL card only 380 const collectionEvents = eventPublisher.getPublishedEventsOfType( 381 EventNames.CARD_ADDED_TO_COLLECTION, 382 ) as CardAddedToCollectionEvent[]; 383 expect(collectionEvents).toHaveLength(1); 384 expect(collectionEvents[0]?.cardId.getStringValue()).toBe( 385 urlCard?.cardId.getStringValue(), 386 ); 387 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe( 388 collection.collectionId.getStringValue(), 389 ); 390 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true); 391 }); 392 393 it('should fail when collection does not exist', async () => { 394 const request = { 395 url: 'https://example.com/article', 396 collectionIds: ['non-existent-collection-id'], 397 curatorId: curatorId.value, 398 }; 399 400 const result = await useCase.execute(request); 401 402 expect(result.isErr()).toBe(true); 403 if (result.isErr()) { 404 expect(result.error.message).toContain('Collection not found'); 405 } 406 }); 407 }); 408 409 describe('Validation', () => { 410 it('should fail with invalid URL', async () => { 411 const request = { 412 url: 'not-a-valid-url', 413 curatorId: curatorId.value, 414 }; 415 416 const result = await useCase.execute(request); 417 418 expect(result.isErr()).toBe(true); 419 if (result.isErr()) { 420 expect(result.error.message).toContain('Invalid URL'); 421 } 422 }); 423 424 it('should fail with invalid curator ID', async () => { 425 const request = { 426 url: 'https://example.com/article', 427 curatorId: 'invalid-curator-id', 428 }; 429 430 const result = await useCase.execute(request); 431 432 expect(result.isErr()).toBe(true); 433 if (result.isErr()) { 434 expect(result.error.message).toContain('Invalid curator ID'); 435 } 436 }); 437 438 it('should fail with invalid collection ID', async () => { 439 const request = { 440 url: 'https://example.com/article', 441 collectionIds: ['invalid-collection-id'], 442 curatorId: curatorId.value, 443 }; 444 445 const result = await useCase.execute(request); 446 447 expect(result.isErr()).toBe(true); 448 if (result.isErr()) { 449 expect(result.error.message).toContain('Collection not found'); 450 } 451 }); 452 }); 453 454 describe('Metadata service integration', () => { 455 it('should handle metadata service failure gracefully', async () => { 456 // Configure metadata service to fail 457 metadataService.setShouldFail(true); 458 459 const request = { 460 url: 'https://example.com/article', 461 curatorId: curatorId.value, 462 }; 463 464 const result = await useCase.execute(request); 465 466 expect(result.isErr()).toBe(true); 467 if (result.isErr()) { 468 expect(result.error.message).toContain('Failed to fetch metadata'); 469 } 470 }); 471 }); 472});