A social knowledge tool for researchers built on ATProto
at main 524 lines 18 kB view raw
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 = InMemoryCardRepository.getInstance(); 31 collectionRepository = InMemoryCollectionRepository.getInstance(); 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 it('should update existing note card when URL already exists with a note', async () => { 267 const url = 'https://example.com/existing'; 268 269 // First request creates URL card with note 270 const firstRequest = { 271 url, 272 note: 'Original note', 273 curatorId: curatorId.value, 274 }; 275 276 const firstResult = await useCase.execute(firstRequest); 277 expect(firstResult.isOk()).toBe(true); 278 const firstResponse = firstResult.unwrap(); 279 expect(firstResponse.noteCardId).toBeDefined(); 280 281 // Get the original note card 282 const cardsAfterFirst = cardRepository.getAllCards(); 283 const originalNoteCard = cardsAfterFirst.find( 284 (card) => card.content.type === CardTypeEnum.NOTE, 285 ); 286 expect(originalNoteCard).toBeDefined(); 287 expect(originalNoteCard?.content.noteContent?.text).toBe('Original note'); 288 289 // Second request updates the note 290 const secondRequest = { 291 url, 292 note: 'Updated note', 293 curatorId: curatorId.value, 294 }; 295 296 const secondResult = await useCase.execute(secondRequest); 297 expect(secondResult.isOk()).toBe(true); 298 const secondResponse = secondResult.unwrap(); 299 300 // Should still have the same note card ID 301 expect(secondResponse.noteCardId).toBe(firstResponse.noteCardId); 302 303 // Should still have 2 cards (URL + Note) 304 const savedCards = cardRepository.getAllCards(); 305 expect(savedCards).toHaveLength(2); 306 307 // Verify note was updated 308 const updatedNoteCard = savedCards.find( 309 (card) => card.content.type === CardTypeEnum.NOTE, 310 ); 311 expect(updatedNoteCard).toBeDefined(); 312 expect(updatedNoteCard?.content.noteContent?.text).toBe('Updated note'); 313 expect(updatedNoteCard?.cardId.getStringValue()).toBe( 314 firstResponse.noteCardId, 315 ); 316 }); 317 }); 318 319 describe('Collection handling', () => { 320 it('should add URL card to specified collections', async () => { 321 // Create a test collection 322 const collection = new CollectionBuilder() 323 .withAuthorId(curatorId.value) 324 .withName('Test Collection') 325 .build(); 326 327 if (collection instanceof Error) { 328 throw new Error(`Failed to create collection: ${collection.message}`); 329 } 330 331 await collectionRepository.save(collection); 332 333 const request = { 334 url: 'https://example.com/article', 335 collectionIds: [collection.collectionId.getStringValue()], 336 curatorId: curatorId.value, 337 }; 338 339 const result = await useCase.execute(request); 340 341 expect(result.isOk()).toBe(true); 342 343 // Verify collection link was published 344 const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 345 collection.collectionId.getStringValue(), 346 ); 347 expect(publishedLinks).toHaveLength(1); 348 349 // Verify CardAddedToLibraryEvent was published 350 const libraryEvents = eventPublisher.getPublishedEventsOfType( 351 EventNames.CARD_ADDED_TO_LIBRARY, 352 ) as CardAddedToLibraryEvent[]; 353 expect(libraryEvents).toHaveLength(1); 354 expect(libraryEvents[0]?.curatorId.equals(curatorId)).toBe(true); 355 356 // Verify CardAddedToCollectionEvent was published 357 const collectionEvents = eventPublisher.getPublishedEventsOfType( 358 EventNames.CARD_ADDED_TO_COLLECTION, 359 ) as CardAddedToCollectionEvent[]; 360 expect(collectionEvents).toHaveLength(1); 361 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe( 362 collection.collectionId.getStringValue(), 363 ); 364 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true); 365 }); 366 367 it('should add URL card (not note card) to collections when note is provided', async () => { 368 // Create a test collection 369 const collection = new CollectionBuilder() 370 .withAuthorId(curatorId.value) 371 .withName('Test Collection') 372 .build(); 373 374 if (collection instanceof Error) { 375 throw new Error(`Failed to create collection: ${collection.message}`); 376 } 377 378 await collectionRepository.save(collection); 379 380 const request = { 381 url: 'https://example.com/article', 382 note: 'This is my note about the article', 383 collectionIds: [collection.collectionId.getStringValue()], 384 curatorId: curatorId.value, 385 }; 386 387 const result = await useCase.execute(request); 388 389 expect(result.isOk()).toBe(true); 390 const response = result.unwrap(); 391 392 // Verify both URL and note cards were created 393 expect(response.urlCardId).toBeDefined(); 394 expect(response.noteCardId).toBeDefined(); 395 396 // Verify both cards were saved 397 const savedCards = cardRepository.getAllCards(); 398 expect(savedCards).toHaveLength(2); 399 400 const urlCard = savedCards.find( 401 (card) => card.content.type === CardTypeEnum.URL, 402 ); 403 const noteCard = savedCards.find( 404 (card) => card.content.type === CardTypeEnum.NOTE, 405 ); 406 407 expect(urlCard).toBeDefined(); 408 expect(noteCard).toBeDefined(); 409 410 // Verify collection link was published for URL card only 411 const publishedLinks = collectionPublisher.getPublishedLinksForCollection( 412 collection.collectionId.getStringValue(), 413 ); 414 expect(publishedLinks).toHaveLength(1); 415 416 // Verify the published link is for the URL card, not the note card 417 const publishedLink = publishedLinks[0]; 418 expect(publishedLink?.cardId).toBe(urlCard?.cardId.getStringValue()); 419 expect(publishedLink?.cardId).not.toBe(noteCard?.cardId.getStringValue()); 420 421 // Verify both cards are in the library 422 const publishedCards = cardPublisher.getPublishedCards(); 423 expect(publishedCards).toHaveLength(2); 424 425 // Verify CardAddedToLibraryEvent was published for both cards 426 const libraryEvents = eventPublisher.getPublishedEventsOfType( 427 EventNames.CARD_ADDED_TO_LIBRARY, 428 ) as CardAddedToLibraryEvent[]; 429 expect(libraryEvents).toHaveLength(1); 430 431 // Verify CardAddedToCollectionEvent was published for URL card only 432 const collectionEvents = eventPublisher.getPublishedEventsOfType( 433 EventNames.CARD_ADDED_TO_COLLECTION, 434 ) as CardAddedToCollectionEvent[]; 435 expect(collectionEvents).toHaveLength(1); 436 expect(collectionEvents[0]?.cardId.getStringValue()).toBe( 437 urlCard?.cardId.getStringValue(), 438 ); 439 expect(collectionEvents[0]?.collectionId.getStringValue()).toBe( 440 collection.collectionId.getStringValue(), 441 ); 442 expect(collectionEvents[0]?.addedBy.equals(curatorId)).toBe(true); 443 }); 444 445 it('should fail when collection does not exist', async () => { 446 const request = { 447 url: 'https://example.com/article', 448 collectionIds: ['non-existent-collection-id'], 449 curatorId: curatorId.value, 450 }; 451 452 const result = await useCase.execute(request); 453 454 expect(result.isErr()).toBe(true); 455 if (result.isErr()) { 456 expect(result.error.message).toContain('Collection not found'); 457 } 458 }); 459 }); 460 461 describe('Validation', () => { 462 it('should fail with invalid URL', async () => { 463 const request = { 464 url: 'not-a-valid-url', 465 curatorId: curatorId.value, 466 }; 467 468 const result = await useCase.execute(request); 469 470 expect(result.isErr()).toBe(true); 471 if (result.isErr()) { 472 expect(result.error.message).toContain('Invalid URL'); 473 } 474 }); 475 476 it('should fail with invalid curator ID', async () => { 477 const request = { 478 url: 'https://example.com/article', 479 curatorId: 'invalid-curator-id', 480 }; 481 482 const result = await useCase.execute(request); 483 484 expect(result.isErr()).toBe(true); 485 if (result.isErr()) { 486 expect(result.error.message).toContain('Invalid curator ID'); 487 } 488 }); 489 490 it('should fail with invalid collection ID', async () => { 491 const request = { 492 url: 'https://example.com/article', 493 collectionIds: ['invalid-collection-id'], 494 curatorId: curatorId.value, 495 }; 496 497 const result = await useCase.execute(request); 498 499 expect(result.isErr()).toBe(true); 500 if (result.isErr()) { 501 expect(result.error.message).toContain('Collection not found'); 502 } 503 }); 504 }); 505 506 describe('Metadata service integration', () => { 507 it('should handle metadata service failure gracefully', async () => { 508 // Configure metadata service to fail 509 metadataService.setShouldFail(true); 510 511 const request = { 512 url: 'https://example.com/article', 513 curatorId: curatorId.value, 514 }; 515 516 const result = await useCase.execute(request); 517 518 expect(result.isErr()).toBe(true); 519 if (result.isErr()) { 520 expect(result.error.message).toContain('Failed to fetch metadata'); 521 } 522 }); 523 }); 524});