This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / modules / atproto / tests / application / ProcessFirehoseEventUseCase.test.ts
20 kB 540 lines
1import { ProcessFirehoseEventUseCase } from '../../application/useCases/ProcessFirehoseEventUseCase'; 2import { InMemoryFirehoseEventDuplicationService } from '../utils/InMemoryFirehoseEventDuplicationService'; 3import { ProcessCardFirehoseEventUseCase } from '../../application/useCases/ProcessCardFirehoseEventUseCase'; 4import { ProcessCollectionFirehoseEventUseCase } from '../../application/useCases/ProcessCollectionFirehoseEventUseCase'; 5import { ProcessCollectionLinkFirehoseEventUseCase } from '../../application/useCases/ProcessCollectionLinkFirehoseEventUseCase'; 6import { ProcessMarginBookmarkFirehoseEventUseCase } from '../../application/useCases/ProcessMarginBookmarkFirehoseEventUseCase'; 7import { ProcessMarginCollectionFirehoseEventUseCase } from '../../application/useCases/ProcessMarginCollectionFirehoseEventUseCase'; 8import { ProcessMarginCollectionItemFirehoseEventUseCase } from '../../application/useCases/ProcessMarginCollectionItemFirehoseEventUseCase'; 9import { ProcessCollectionLinkRemovalFirehoseEventUseCase } from '../../application/useCases/ProcessCollectionLinkRemovalFirehoseEventUseCase'; 10import { EnvironmentConfigService } from '../../../../shared/infrastructure/config/EnvironmentConfigService'; 11import { InMemoryAtUriResolutionService } from '../../../cards/tests/utils/InMemoryAtUriResolutionService'; 12import { AddUrlToLibraryUseCase } from '../../../cards/application/useCases/commands/AddUrlToLibraryUseCase'; 13import { UpdateUrlCardAssociationsUseCase } from '../../../cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase'; 14import { RemoveCardFromLibraryUseCase } from '../../../cards/application/useCases/commands/RemoveCardFromLibraryUseCase'; 15import { CreateCollectionUseCase } from '../../../cards/application/useCases/commands/CreateCollectionUseCase'; 16import { UpdateCollectionUseCase } from '../../../cards/application/useCases/commands/UpdateCollectionUseCase'; 17import { DeleteCollectionUseCase } from '../../../cards/application/useCases/commands/DeleteCollectionUseCase'; 18import { InMemoryCardRepository } from '../../../cards/tests/utils/InMemoryCardRepository'; 19import { InMemoryCollectionRepository } from '../../../cards/tests/utils/InMemoryCollectionRepository'; 20import { FakeCardPublisher } from '../../../cards/tests/utils/FakeCardPublisher'; 21import { FakeCollectionPublisher } from '../../../cards/tests/utils/FakeCollectionPublisher'; 22import { FakeMetadataService } from '../../../cards/tests/utils/FakeMetadataService'; 23import { FakeEventPublisher } from '../../../cards/tests/utils/FakeEventPublisher'; 24import { CardLibraryService } from '../../../cards/domain/services/CardLibraryService'; 25import { CardCollectionService } from '../../../cards/domain/services/CardCollectionService'; 26import { Record as CardRecord } from '../../infrastructure/lexicon/types/network/cosmik/card'; 27import { Record as CollectionRecord } from '../../infrastructure/lexicon/types/network/cosmik/collection'; 28import { Record as CollectionLinkRecord } from '../../infrastructure/lexicon/types/network/cosmik/collectionLink'; 29 30describe('ProcessFirehoseEventUseCase', () => { 31 let useCase: ProcessFirehoseEventUseCase; 32 let duplicationService: InMemoryFirehoseEventDuplicationService; 33 let configService: EnvironmentConfigService; 34 let processCardFirehoseEventUseCase: ProcessCardFirehoseEventUseCase; 35 let processCollectionFirehoseEventUseCase: ProcessCollectionFirehoseEventUseCase; 36 let processCollectionLinkFirehoseEventUseCase: ProcessCollectionLinkFirehoseEventUseCase; 37 let processMarginBookmarkFirehoseEventUseCase: ProcessMarginBookmarkFirehoseEventUseCase; 38 let processMarginCollectionFirehoseEventUseCase: ProcessMarginCollectionFirehoseEventUseCase; 39 let processMarginCollectionItemFirehoseEventUseCase: ProcessMarginCollectionItemFirehoseEventUseCase; 40 let processCollectionLinkRemovalFirehoseEventUseCase: ProcessCollectionLinkRemovalFirehoseEventUseCase; 41 42 // Dependencies for real use cases 43 let atUriResolutionService: InMemoryAtUriResolutionService; 44 let cardRepository: InMemoryCardRepository; 45 let collectionRepository: InMemoryCollectionRepository; 46 let cardPublisher: FakeCardPublisher; 47 let collectionPublisher: FakeCollectionPublisher; 48 let metadataService: FakeMetadataService; 49 let eventPublisher: FakeEventPublisher; 50 let cardLibraryService: CardLibraryService; 51 let cardCollectionService: CardCollectionService; 52 let addUrlToLibraryUseCase: AddUrlToLibraryUseCase; 53 let updateUrlCardAssociationsUseCase: UpdateUrlCardAssociationsUseCase; 54 let removeCardFromLibraryUseCase: RemoveCardFromLibraryUseCase; 55 let createCollectionUseCase: CreateCollectionUseCase; 56 let updateCollectionUseCase: UpdateCollectionUseCase; 57 let deleteCollectionUseCase: DeleteCollectionUseCase; 58 59 beforeEach(() => { 60 duplicationService = new InMemoryFirehoseEventDuplicationService(); 61 configService = new EnvironmentConfigService(); 62 63 // Set up all the real dependencies 64 cardRepository = InMemoryCardRepository.getInstance(); 65 collectionRepository = InMemoryCollectionRepository.getInstance(); 66 cardPublisher = new FakeCardPublisher(); 67 collectionPublisher = new FakeCollectionPublisher(); 68 metadataService = new FakeMetadataService(); 69 eventPublisher = new FakeEventPublisher(); 70 71 cardCollectionService = new CardCollectionService( 72 collectionRepository, 73 collectionPublisher, 74 cardRepository, 75 ); 76 cardLibraryService = new CardLibraryService( 77 cardRepository, 78 cardPublisher, 79 collectionRepository, 80 cardCollectionService, 81 ); 82 83 atUriResolutionService = new InMemoryAtUriResolutionService( 84 collectionRepository, 85 cardRepository, 86 ); 87 88 // Create use cases for card processing 89 addUrlToLibraryUseCase = new AddUrlToLibraryUseCase( 90 cardRepository, 91 metadataService, 92 cardLibraryService, 93 cardCollectionService, 94 eventPublisher, 95 ); 96 97 updateUrlCardAssociationsUseCase = new UpdateUrlCardAssociationsUseCase( 98 cardRepository, 99 cardLibraryService, 100 cardCollectionService, 101 eventPublisher, 102 ); 103 104 removeCardFromLibraryUseCase = new RemoveCardFromLibraryUseCase( 105 cardRepository, 106 cardLibraryService, 107 eventPublisher, 108 ); 109 110 // Create use cases for collection processing 111 createCollectionUseCase = new CreateCollectionUseCase( 112 collectionRepository, 113 collectionPublisher, 114 ); 115 116 updateCollectionUseCase = new UpdateCollectionUseCase( 117 collectionRepository, 118 collectionPublisher, 119 ); 120 121 deleteCollectionUseCase = new DeleteCollectionUseCase( 122 collectionRepository, 123 collectionPublisher, 124 ); 125 126 // Create real use case instances 127 processCardFirehoseEventUseCase = new ProcessCardFirehoseEventUseCase( 128 atUriResolutionService, 129 addUrlToLibraryUseCase, 130 updateUrlCardAssociationsUseCase, 131 removeCardFromLibraryUseCase, 132 cardRepository, 133 ); 134 135 processCollectionFirehoseEventUseCase = 136 new ProcessCollectionFirehoseEventUseCase( 137 atUriResolutionService, 138 createCollectionUseCase, 139 updateCollectionUseCase, 140 deleteCollectionUseCase, 141 ); 142 143 processCollectionLinkFirehoseEventUseCase = 144 new ProcessCollectionLinkFirehoseEventUseCase( 145 atUriResolutionService, 146 updateUrlCardAssociationsUseCase, 147 ); 148 149 processMarginBookmarkFirehoseEventUseCase = 150 new ProcessMarginBookmarkFirehoseEventUseCase( 151 atUriResolutionService, 152 addUrlToLibraryUseCase, 153 removeCardFromLibraryUseCase, 154 ); 155 156 processMarginCollectionFirehoseEventUseCase = 157 new ProcessMarginCollectionFirehoseEventUseCase( 158 atUriResolutionService, 159 createCollectionUseCase, 160 updateCollectionUseCase, 161 deleteCollectionUseCase, 162 ); 163 164 processMarginCollectionItemFirehoseEventUseCase = 165 new ProcessMarginCollectionItemFirehoseEventUseCase( 166 atUriResolutionService, 167 updateUrlCardAssociationsUseCase, 168 ); 169 processCollectionLinkRemovalFirehoseEventUseCase = 170 new ProcessCollectionLinkRemovalFirehoseEventUseCase( 171 atUriResolutionService, 172 updateUrlCardAssociationsUseCase, 173 ); 174 175 useCase = new ProcessFirehoseEventUseCase( 176 duplicationService, 177 configService, 178 processCardFirehoseEventUseCase, 179 processCollectionFirehoseEventUseCase, 180 processCollectionLinkFirehoseEventUseCase, 181 processMarginBookmarkFirehoseEventUseCase, 182 processMarginCollectionFirehoseEventUseCase, 183 processMarginCollectionItemFirehoseEventUseCase, 184 processCollectionLinkRemovalFirehoseEventUseCase, 185 ); 186 }); 187 188 afterEach(() => { 189 duplicationService.clear(); 190 cardRepository.clear(); 191 collectionRepository.clear(); 192 cardPublisher.clear(); 193 collectionPublisher.clear(); 194 metadataService.clear(); 195 eventPublisher.clear(); 196 }); 197 198 describe('Event Routing', () => { 199 it('should route card events to ProcessCardFirehoseEventUseCase', async () => { 200 const collections = configService.getAtProtoCollections(); 201 const cardRecord: CardRecord = { 202 $type: 'network.cosmik.card', 203 type: 'URL', 204 content: { 205 $type: 'network.cosmik.card#urlContent', 206 url: 'https://example.com', 207 }, 208 }; 209 210 const request = { 211 atUri: `at://did:plc:test/${collections.card}/test-card-id`, 212 cid: 'test-cid', 213 eventType: 'create' as const, 214 record: cardRecord, 215 }; 216 217 const result = await useCase.execute(request); 218 219 expect(result.isOk()).toBe(true); 220 221 // Verify the card was actually created in the repository 222 const savedCards = cardRepository.getAllCards(); 223 expect(savedCards).toHaveLength(1); 224 expect(savedCards[0]?.content.type).toBe('URL'); 225 }); 226 227 it('should route collection events to ProcessCollectionFirehoseEventUseCase', async () => { 228 const collections = configService.getAtProtoCollections(); 229 const collectionRecord: CollectionRecord = { 230 $type: 'network.cosmik.collection', 231 name: 'Test Collection', 232 accessType: 'CLOSED', 233 }; 234 235 const request = { 236 atUri: `at://did:plc:test/${collections.collection}/test-collection-id`, 237 cid: 'test-cid', 238 eventType: 'create' as const, 239 record: collectionRecord, 240 }; 241 242 const result = await useCase.execute(request); 243 244 expect(result.isOk()).toBe(true); 245 246 // Verify the collection was actually created in the repository 247 const savedCollections = collectionRepository.getAllCollections(); 248 expect(savedCollections).toHaveLength(1); 249 expect(savedCollections[0]?.name.value).toBe('Test Collection'); 250 }); 251 252 it('should route collection link events to ProcessCollectionLinkFirehoseEventUseCase', async () => { 253 const collections = configService.getAtProtoCollections(); 254 const linkRecord: CollectionLinkRecord = { 255 $type: 'network.cosmik.collectionLink', 256 collection: { 257 uri: 'at://did:plc:test/network.cosmik.collection/collection-id', 258 cid: 'collection-cid', 259 }, 260 card: { 261 uri: 'at://did:plc:test/network.cosmik.card/card-id', 262 cid: 'card-cid', 263 }, 264 addedBy: 'did:plc:test', 265 addedAt: new Date().toISOString(), 266 }; 267 268 const request = { 269 atUri: `at://did:plc:test/${collections.collectionLink}/test-link-id`, 270 cid: 'test-cid', 271 eventType: 'create' as const, 272 record: linkRecord, 273 }; 274 275 const result = await useCase.execute(request); 276 277 expect(result.isOk()).toBe(true); 278 // Collection link processing will gracefully handle missing referenced entities 279 // so we just verify the request was processed without error 280 }); 281 282 it('should handle unknown collection types', async () => { 283 const request = { 284 atUri: 'at://did:plc:test/unknown.collection.type/test-id', 285 cid: 'test-cid', 286 eventType: 'create' as const, 287 record: {} as any, 288 }; 289 290 const result = await useCase.execute(request); 291 292 expect(result.isErr()).toBe(true); 293 if (result.isErr()) { 294 expect(result.error.message).toContain('Unknown collection type'); 295 } 296 // No specific side effects to verify for unknown collection types 297 }); 298 }); 299 300 describe('Duplicate Detection', () => { 301 it('should skip processing duplicate events', async () => { 302 const collections = configService.getAtProtoCollections(); 303 const atUri = `at://did:plc:test/${collections.card}/test-card-id`; 304 const cid = 'test-cid'; 305 const eventType = 'create'; 306 307 // Mark event as already processed 308 await duplicationService.markEventAsProcessed(atUri, cid, eventType); 309 310 const request = { 311 atUri, 312 cid, 313 eventType: eventType as 'create', 314 record: { 315 $type: 'network.cosmik.card', 316 type: 'URL', 317 content: { 318 $type: 'network.cosmik.card#urlContent', 319 url: 'https://example.com', 320 }, 321 } as CardRecord, 322 }; 323 324 const result = await useCase.execute(request); 325 326 expect(result.isOk()).toBe(true); 327 // Event was marked as duplicate, so no processing should occur 328 expect(cardRepository.getAllCards()).toHaveLength(0); 329 }); 330 331 it('should process non-duplicate events', async () => { 332 const collections = configService.getAtProtoCollections(); 333 const request = { 334 atUri: `at://did:plc:test/${collections.card}/test-card-id`, 335 cid: 'test-cid', 336 eventType: 'create' as const, 337 record: { 338 $type: 'network.cosmik.card', 339 type: 'URL', 340 content: { 341 $type: 'network.cosmik.card#urlContent', 342 url: 'https://example.com', 343 }, 344 } as CardRecord, 345 }; 346 347 const result = await useCase.execute(request); 348 349 expect(result.isOk()).toBe(true); 350 351 // Verify the card was actually created 352 const savedCards = cardRepository.getAllCards(); 353 expect(savedCards).toHaveLength(1); 354 }); 355 356 it('should handle duplication service errors', async () => { 357 // Configure duplication service to fail 358 duplicationService.setShouldFail(true); 359 360 const collections = configService.getAtProtoCollections(); 361 const request = { 362 atUri: `at://did:plc:test/${collections.card}/test-card-id`, 363 cid: 'test-cid', 364 eventType: 'create' as const, 365 record: { 366 $type: 'network.cosmik.card', 367 type: 'URL', 368 content: { 369 $type: 'network.cosmik.card#urlContent', 370 url: 'https://example.com', 371 }, 372 } as CardRecord, 373 }; 374 375 const result = await useCase.execute(request); 376 377 expect(result.isErr()).toBe(true); 378 379 // No processing should have occurred 380 expect(cardRepository.getAllCards()).toHaveLength(0); 381 }); 382 }); 383 384 describe('AT URI Validation', () => { 385 it('should handle invalid AT URI format', async () => { 386 const request = { 387 atUri: 'invalid-uri-format', 388 cid: 'test-cid', 389 eventType: 'create' as const, 390 record: {} as any, 391 }; 392 393 const result = await useCase.execute(request); 394 395 expect(result.isErr()).toBe(true); 396 if (result.isErr()) { 397 expect(result.error.message).toContain('Invalid AT URI'); 398 } 399 400 // No processing should have occurred 401 expect(cardRepository.getAllCards()).toHaveLength(0); 402 expect(collectionRepository.getAllCollections()).toHaveLength(0); 403 }); 404 405 it('should handle malformed AT URI', async () => { 406 const request = { 407 atUri: 'at://did:plc:test', // Missing collection and rkey 408 cid: 'test-cid', 409 eventType: 'create' as const, 410 record: {} as any, 411 }; 412 413 const result = await useCase.execute(request); 414 415 expect(result.isErr()).toBe(true); 416 if (result.isErr()) { 417 expect(result.error.message).toContain('Invalid AT URI'); 418 } 419 }); 420 }); 421 422 describe('Event Types', () => { 423 it('should handle create events', async () => { 424 const collections = configService.getAtProtoCollections(); 425 const request = { 426 atUri: `at://did:plc:test/${collections.card}/test-card-id`, 427 cid: 'test-cid', 428 eventType: 'create' as const, 429 record: { 430 $type: 'network.cosmik.card', 431 type: 'URL', 432 content: { 433 $type: 'network.cosmik.card#urlContent', 434 url: 'https://example.com', 435 }, 436 } as CardRecord, 437 }; 438 439 const result = await useCase.execute(request); 440 441 expect(result.isOk()).toBe(true); 442 443 // Verify the card was created 444 const savedCards = cardRepository.getAllCards(); 445 expect(savedCards).toHaveLength(1); 446 }); 447 448 it('should handle update events', async () => { 449 const collections = configService.getAtProtoCollections(); 450 const request = { 451 atUri: `at://did:plc:test/${collections.collection}/test-collection-id`, 452 cid: 'test-cid', 453 eventType: 'update' as const, 454 record: { 455 $type: 'network.cosmik.collection', 456 name: 'Updated Collection', 457 accessType: 'OPEN', 458 } as CollectionRecord, 459 }; 460 461 const result = await useCase.execute(request); 462 463 expect(result.isOk()).toBe(true); 464 465 // Verify the collection wasnt updated since it didn't exist before 466 const savedCollections = collectionRepository.getAllCollections(); 467 expect(savedCollections).toHaveLength(0); 468 }); 469 470 it('should handle delete events', async () => { 471 const collections = configService.getAtProtoCollections(); 472 const request = { 473 atUri: `at://did:plc:test/${collections.collectionLink}/test-link-id`, 474 cid: null, // CID can be null for delete events 475 eventType: 'delete' as const, 476 }; 477 478 const result = await useCase.execute(request); 479 480 expect(result.isOk()).toBe(true); 481 // Collection link processing handles missing entities gracefully 482 }); 483 }); 484 485 describe('Error Handling', () => { 486 it('should handle metadata service failures gracefully', async () => { 487 const collections = configService.getAtProtoCollections(); 488 489 // Configure metadata service to fail 490 metadataService.setShouldFail(true); 491 492 const request = { 493 atUri: `at://did:plc:test/${collections.card}/test-card-id`, 494 cid: 'test-cid', 495 eventType: 'create' as const, 496 record: { 497 $type: 'network.cosmik.card', 498 type: 'URL', 499 content: { 500 $type: 'network.cosmik.card#urlContent', 501 url: 'https://example.com', 502 }, 503 } as CardRecord, 504 }; 505 506 const result = await useCase.execute(request); 507 508 // The card processor handles metadata failures gracefully 509 expect(result.isOk()).toBe(true); 510 511 expect(cardRepository.getAllCards()).toHaveLength(1); 512 }); 513 514 it('should handle publisher failures gracefully', async () => { 515 const collections = configService.getAtProtoCollections(); 516 517 // Configure collection publisher to fail 518 collectionPublisher.setShouldFail(true); 519 520 const request = { 521 atUri: `at://did:plc:test/${collections.collection}/test-collection-id`, 522 cid: 'test-cid', 523 eventType: 'create' as const, 524 record: { 525 $type: 'network.cosmik.collection', 526 name: 'Test Collection', 527 accessType: 'CLOSED', 528 } as CollectionRecord, 529 }; 530 531 const result = await useCase.execute(request); 532 533 // The collection processor handles publisher failures gracefully 534 expect(result.isOk()).toBe(true); 535 536 // Collection should still be saved even if publishing fails 537 expect(collectionRepository.getAllCollections()).toHaveLength(1); 538 }); 539 }); 540});