This repository has no description
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});