@zdavison/matador 4.0.3 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/core/matador.d.ts.map +1 -1
  2. package/dist/core/matador.js +4 -1
  3. package/dist/index.cjs +156 -16
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/pipeline/pipeline.d.ts +30 -2
  7. package/dist/pipeline/pipeline.d.ts.map +1 -1
  8. package/dist/pipeline/pipeline.js +65 -9
  9. package/dist/retry/index.d.ts +1 -1
  10. package/dist/retry/index.d.ts.map +1 -1
  11. package/dist/retry/policy.d.ts +27 -0
  12. package/dist/retry/policy.d.ts.map +1 -1
  13. package/dist/retry/standard-policy.d.ts +33 -2
  14. package/dist/retry/standard-policy.d.ts.map +1 -1
  15. package/dist/retry/standard-policy.js +73 -9
  16. package/dist/topology/types.d.ts +14 -0
  17. package/dist/topology/types.d.ts.map +1 -1
  18. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -1
  19. package/dist/transport/rabbitmq/rabbitmq-transport.js +3 -0
  20. package/package.json +1 -1
  21. package/dist/checkpoint/context.test.d.ts +0 -2
  22. package/dist/checkpoint/context.test.d.ts.map +0 -1
  23. package/dist/checkpoint/context.test.js +0 -428
  24. package/dist/checkpoint/stores/stores.test.d.ts +0 -2
  25. package/dist/checkpoint/stores/stores.test.d.ts.map +0 -1
  26. package/dist/checkpoint/stores/stores.test.js +0 -146
  27. package/dist/codec/rabbitmq-codec.test.d.ts +0 -2
  28. package/dist/codec/rabbitmq-codec.test.d.ts.map +0 -1
  29. package/dist/codec/rabbitmq-codec.test.js +0 -428
  30. package/dist/core/fanout.test.d.ts +0 -2
  31. package/dist/core/fanout.test.d.ts.map +0 -1
  32. package/dist/core/fanout.test.js +0 -1970
  33. package/dist/core/matador.test.d.ts +0 -2
  34. package/dist/core/matador.test.d.ts.map +0 -1
  35. package/dist/core/matador.test.js +0 -813
  36. package/dist/core/shutdown.test.d.ts +0 -2
  37. package/dist/core/shutdown.test.d.ts.map +0 -1
  38. package/dist/core/shutdown.test.js +0 -599
  39. package/dist/errors/retry-errors.test.d.ts +0 -2
  40. package/dist/errors/retry-errors.test.d.ts.map +0 -1
  41. package/dist/errors/retry-errors.test.js +0 -136
  42. package/dist/pipeline/pipeline.test.d.ts +0 -2
  43. package/dist/pipeline/pipeline.test.d.ts.map +0 -1
  44. package/dist/pipeline/pipeline.test.js +0 -1124
  45. package/dist/retry/standard-policy.test.d.ts +0 -2
  46. package/dist/retry/standard-policy.test.d.ts.map +0 -1
  47. package/dist/retry/standard-policy.test.js +0 -196
  48. package/dist/schema/registry.test.d.ts +0 -2
  49. package/dist/schema/registry.test.d.ts.map +0 -1
  50. package/dist/schema/registry.test.js +0 -278
  51. package/dist/schema/types.test.d.ts +0 -2
  52. package/dist/schema/types.test.d.ts.map +0 -1
  53. package/dist/schema/types.test.js +0 -246
  54. package/dist/topology/builder.test.d.ts +0 -2
  55. package/dist/topology/builder.test.d.ts.map +0 -1
  56. package/dist/topology/builder.test.js +0 -547
  57. package/dist/transport/local/local-transport.test.d.ts +0 -2
  58. package/dist/transport/local/local-transport.test.d.ts.map +0 -1
  59. package/dist/transport/local/local-transport.test.js +0 -232
  60. package/dist/transport/multi/multi-transport.test.d.ts +0 -2
  61. package/dist/transport/multi/multi-transport.test.d.ts.map +0 -1
  62. package/dist/transport/multi/multi-transport.test.js +0 -322
  63. package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.d.ts +0 -2
  64. package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.d.ts.map +0 -1
  65. package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.js +0 -149
  66. package/dist/transport/rabbitmq/rabbitmq-transport.test.d.ts +0 -2
  67. package/dist/transport/rabbitmq/rabbitmq-transport.test.d.ts.map +0 -1
  68. package/dist/transport/rabbitmq/rabbitmq-transport.test.js +0 -230
  69. package/dist/types/event.test.d.ts +0 -2
  70. package/dist/types/event.test.d.ts.map +0 -1
  71. package/dist/types/event.test.js +0 -130
@@ -1,1970 +0,0 @@
1
- import { beforeEach, describe, expect, it, mock } from 'bun:test';
2
- import { TransportSendError } from '../errors/index.js';
3
- import { SafeHooks } from '../hooks/index.js';
4
- import { SchemaRegistry } from '../schema/index.js';
5
- import { TopologyBuilder, resolveTargetQueueName } from '../topology/index.js';
6
- import { LocalTransport, MultiTransport } from '../transport/index.js';
7
- import { MatadorEvent, createSubscriber, createSubscriberStub, } from '../types/index.js';
8
- import { FanoutEngine } from './fanout.js';
9
- class UserCreatedEvent extends MatadorEvent {
10
- data;
11
- static key = 'user.created';
12
- static description = 'Fired when a new user is created';
13
- constructor(data) {
14
- super();
15
- this.data = data;
16
- }
17
- }
18
- class OrderPlacedEvent extends MatadorEvent {
19
- data;
20
- static key = 'order.placed';
21
- static description = 'Fired when an order is placed';
22
- constructor(data) {
23
- super();
24
- this.data = data;
25
- }
26
- }
27
- class UserCreatedEventWithMetadata extends MatadorEvent {
28
- data;
29
- metadata;
30
- static key = 'user.created.with-metadata';
31
- static description = 'Fired when a new user is created (with metadata)';
32
- constructor(data, metadata) {
33
- super();
34
- this.data = data;
35
- this.metadata = metadata;
36
- }
37
- }
38
- /**
39
- * Waits for a triggered retry-buffer flush to fully settle, including the
40
- * inter-item throttle sleeps — a single macrotask tick isn't enough once the
41
- * flush loop sleeps between items.
42
- */
43
- async function waitForFlush(fanout) {
44
- const deadline = Date.now() + 1000;
45
- while (fanout.eventsBeingEnqueuedCount > 0) {
46
- if (Date.now() > deadline) {
47
- throw new Error('waitForFlush: timed out waiting for flush to settle');
48
- }
49
- await new Promise((resolve) => setTimeout(resolve, 1));
50
- }
51
- }
52
- const testTopology = TopologyBuilder.create()
53
- .withNamespace('test')
54
- .addQueue('events')
55
- .addQueue('notifications')
56
- .addQueue('queue-1')
57
- .addQueue('queue-2')
58
- .build();
59
- describe('FanoutEngine', () => {
60
- let transport;
61
- let schema;
62
- let hooks;
63
- let fanout;
64
- beforeEach(() => {
65
- schema = new SchemaRegistry();
66
- transport = {
67
- name: 'mock',
68
- capabilities: {
69
- deliveryModes: ['at-least-once'],
70
- delayedMessages: true,
71
- deadLetterRouting: 'native',
72
- attemptTracking: false,
73
- concurrencyModel: 'prefetch',
74
- ordering: 'none',
75
- priorities: false,
76
- },
77
- isConnected: () => true,
78
- connect: mock(async () => { }),
79
- disconnect: mock(async () => { }),
80
- send: mock(async () => 'mock'),
81
- subscribe: mock(async () => ({
82
- unsubscribe: async () => { },
83
- isActive: true,
84
- })),
85
- applyTopology: mock(async () => { }),
86
- complete: mock(async () => { }),
87
- };
88
- hooks = new SafeHooks();
89
- fanout = new FanoutEngine({
90
- transport,
91
- schema,
92
- hooks,
93
- topology: testTopology,
94
- defaultQueue: 'events',
95
- });
96
- });
97
- describe('creation', () => {
98
- it('should create instance via static factory', () => {
99
- expect(fanout).toBeInstanceOf(FanoutEngine);
100
- });
101
- it('should create instance via constructor', () => {
102
- const instance = new FanoutEngine({
103
- transport,
104
- schema,
105
- hooks,
106
- topology: testTopology,
107
- defaultQueue: 'events',
108
- });
109
- expect(instance).toBeInstanceOf(FanoutEngine);
110
- });
111
- it('should initialize with zero events being enqueued', () => {
112
- expect(fanout.eventsBeingEnqueuedCount).toBe(0);
113
- });
114
- });
115
- describe('send() with single subscriber', () => {
116
- it('should send event to one subscriber', async () => {
117
- const subscriber = createSubscriber({
118
- name: 'send-welcome-email',
119
- description: 'Sends welcome email',
120
- callback: async () => { },
121
- });
122
- schema.register(UserCreatedEvent, [subscriber]);
123
- const event = new UserCreatedEvent({
124
- userId: '123',
125
- email: 'test@example.com',
126
- });
127
- const result = await fanout.send(UserCreatedEvent, event);
128
- expect(result.eventKey).toBe('user.created');
129
- expect(result.subscribersSent).toBe(1);
130
- expect(result.subscribersSkipped).toBe(0);
131
- expect(result.errors).toHaveLength(0);
132
- expect(transport.send).toHaveBeenCalledTimes(1);
133
- });
134
- it('should use default queue when subscriber has no targetQueue', async () => {
135
- const subscriber = createSubscriber({
136
- name: 'handle-user',
137
- description: 'Handles user events',
138
- callback: async () => { },
139
- });
140
- schema.register(UserCreatedEvent, [subscriber]);
141
- const event = new UserCreatedEvent({
142
- userId: '123',
143
- email: 'test@example.com',
144
- });
145
- await fanout.send(UserCreatedEvent, event);
146
- expect(transport.send).toHaveBeenCalledWith('matador.test.events', expect.any(Object), undefined);
147
- });
148
- it('should use subscriber targetQueue when specified', async () => {
149
- const subscriber = createSubscriber({
150
- name: 'handle-user',
151
- description: 'Handles user events',
152
- callback: async () => { },
153
- targetQueue: 'notifications',
154
- });
155
- schema.register(UserCreatedEvent, [subscriber]);
156
- const event = new UserCreatedEvent({
157
- userId: '123',
158
- email: 'test@example.com',
159
- });
160
- await fanout.send(UserCreatedEvent, event);
161
- expect(transport.send).toHaveBeenCalledWith('matador.test.notifications', expect.any(Object), undefined);
162
- });
163
- it('should create envelope with correct structure', async () => {
164
- const subscriber = createSubscriber({
165
- name: 'handle-user',
166
- description: 'Handles user events',
167
- callback: async () => { },
168
- });
169
- schema.register(UserCreatedEvent, [subscriber]);
170
- const event = new UserCreatedEvent({
171
- userId: '123',
172
- email: 'test@example.com',
173
- });
174
- await fanout.send(UserCreatedEvent, event);
175
- const sendCall = transport.send.mock
176
- .calls[0];
177
- if (!sendCall)
178
- throw new Error('expected transport.send to be called');
179
- const envelope = sendCall[1];
180
- expect(envelope.docket.eventKey).toBe('user.created');
181
- expect(envelope.docket.eventDescription).toBe('Fired when a new user is created');
182
- expect(envelope.docket.targetSubscriber).toBe('handle-user');
183
- expect(envelope.data).toEqual({
184
- userId: '123',
185
- email: 'test@example.com',
186
- });
187
- expect(envelope.id).toBeDefined();
188
- expect(envelope.docket.importance).toBe('should-investigate');
189
- });
190
- it('should include eventDescription from event class', async () => {
191
- const subscriber = createSubscriber({
192
- name: 'order-handler',
193
- description: 'Handles order events',
194
- callback: async () => { },
195
- });
196
- schema.register(OrderPlacedEvent, [subscriber]);
197
- const event = new OrderPlacedEvent({
198
- orderId: 'ord_123',
199
- amount: 99.99,
200
- });
201
- await fanout.send(OrderPlacedEvent, event);
202
- const sendCall = transport.send.mock
203
- .calls[0];
204
- if (!sendCall)
205
- throw new Error('expected transport.send to be called');
206
- const envelope = sendCall[1];
207
- expect(envelope.docket.eventKey).toBe('order.placed');
208
- expect(envelope.docket.eventDescription).toBe('Fired when an order is placed');
209
- });
210
- });
211
- describe('send() with multiple subscribers', () => {
212
- it('should send to all subscribers', async () => {
213
- const sub1 = createSubscriber({
214
- name: 'subscriber-1',
215
- description: 'Subscriber 1',
216
- callback: async () => { },
217
- });
218
- const sub2 = createSubscriber({
219
- name: 'subscriber-2',
220
- description: 'Subscriber 2',
221
- callback: async () => { },
222
- });
223
- const sub3 = createSubscriber({
224
- name: 'subscriber-3',
225
- description: 'Subscriber 3',
226
- callback: async () => { },
227
- });
228
- schema.register(UserCreatedEvent, [sub1, sub2, sub3]);
229
- const event = new UserCreatedEvent({
230
- userId: '123',
231
- email: 'test@example.com',
232
- });
233
- const result = await fanout.send(UserCreatedEvent, event);
234
- expect(result.subscribersSent).toBe(3);
235
- expect(result.subscribersSkipped).toBe(0);
236
- expect(result.errors).toHaveLength(0);
237
- expect(transport.send).toHaveBeenCalledTimes(3);
238
- });
239
- it('should create separate envelope for each subscriber', async () => {
240
- const sub1 = createSubscriber({
241
- name: 'subscriber-1',
242
- description: 'Subscriber 1',
243
- callback: async () => { },
244
- });
245
- const sub2 = createSubscriber({
246
- name: 'subscriber-2',
247
- description: 'Subscriber 2',
248
- callback: async () => { },
249
- });
250
- schema.register(UserCreatedEvent, [sub1, sub2]);
251
- const event = new UserCreatedEvent({
252
- userId: '123',
253
- email: 'test@example.com',
254
- });
255
- await fanout.send(UserCreatedEvent, event);
256
- const calls = transport.send.mock.calls;
257
- const envelope1 = calls[0]?.[1];
258
- const envelope2 = calls[1]?.[1];
259
- expect(envelope1.id).not.toBe(envelope2.id);
260
- expect(envelope1.docket.targetSubscriber).toBe('subscriber-1');
261
- expect(envelope2.docket.targetSubscriber).toBe('subscriber-2');
262
- expect(envelope1.data).toEqual(envelope2.data);
263
- });
264
- it('should route to different queues based on targetQueue', async () => {
265
- const sub1 = createSubscriber({
266
- name: 'subscriber-1',
267
- description: 'Subscriber 1',
268
- callback: async () => { },
269
- targetQueue: 'queue-1',
270
- });
271
- const sub2 = createSubscriber({
272
- name: 'subscriber-2',
273
- description: 'Subscriber 2',
274
- callback: async () => { },
275
- targetQueue: 'queue-2',
276
- });
277
- schema.register(UserCreatedEvent, [sub1, sub2]);
278
- const event = new UserCreatedEvent({
279
- userId: '123',
280
- email: 'test@example.com',
281
- });
282
- await fanout.send(UserCreatedEvent, event);
283
- const calls = transport.send.mock.calls;
284
- expect(calls[0]?.[0]).toBe('matador.test.queue-1');
285
- expect(calls[1]?.[0]).toBe('matador.test.queue-2');
286
- });
287
- it('should send to no subscribers when event is not registered', async () => {
288
- const event = new OrderPlacedEvent({
289
- orderId: '456',
290
- amount: 99.99,
291
- });
292
- const result = await fanout.send(OrderPlacedEvent, event);
293
- expect(result.subscribersSent).toBe(0);
294
- expect(result.subscribersSkipped).toBe(0);
295
- expect(result.errors).toHaveLength(0);
296
- expect(transport.send).not.toHaveBeenCalled();
297
- });
298
- });
299
- describe('metadata merging', () => {
300
- it('should include event metadata in envelope', async () => {
301
- const subscriber = createSubscriber({
302
- name: 'handle-user',
303
- description: 'Handles user events',
304
- callback: async () => { },
305
- });
306
- schema.register(UserCreatedEventWithMetadata, [subscriber]);
307
- const event = new UserCreatedEventWithMetadata({ userId: '123', email: 'test@example.com' }, { source: 'api', version: 1 });
308
- await fanout.send(UserCreatedEventWithMetadata, event);
309
- const envelope = transport.send.mock
310
- .calls[0]?.[1];
311
- expect(envelope.docket.metadata).toEqual({ source: 'api', version: 1 });
312
- });
313
- it('should include options metadata in envelope', async () => {
314
- const subscriber = createSubscriber({
315
- name: 'handle-user',
316
- description: 'Handles user events',
317
- callback: async () => { },
318
- });
319
- schema.register(UserCreatedEvent, [subscriber]);
320
- const event = new UserCreatedEvent({
321
- userId: '123',
322
- email: 'test@example.com',
323
- });
324
- const options = {
325
- metadata: { requestId: 'req-123' },
326
- };
327
- await fanout.send(UserCreatedEvent, event, options);
328
- const envelope = transport.send.mock
329
- .calls[0]?.[1];
330
- expect(envelope.docket.metadata).toEqual({ requestId: 'req-123' });
331
- });
332
- it('should merge event and options metadata with options taking precedence', async () => {
333
- const subscriber = createSubscriber({
334
- name: 'handle-user',
335
- description: 'Handles user events',
336
- callback: async () => { },
337
- });
338
- schema.register(UserCreatedEventWithMetadata, [subscriber]);
339
- const event = new UserCreatedEventWithMetadata({ userId: '123', email: 'test@example.com' }, { source: 'api', version: 1 });
340
- const options = {
341
- metadata: { version: 2, requestId: 'req-123' },
342
- };
343
- await fanout.send(UserCreatedEventWithMetadata, event, options);
344
- const envelope = transport.send.mock
345
- .calls[0]?.[1];
346
- expect(envelope.docket.metadata).toEqual({
347
- source: 'api',
348
- version: 2,
349
- requestId: 'req-123',
350
- });
351
- });
352
- it('should include universal metadata from hooks', async () => {
353
- const hooksWithMetadata = {
354
- loadUniversalMetadata: async () => ({
355
- environment: 'test',
356
- hostname: 'test-host',
357
- }),
358
- };
359
- const hooksInstance = new SafeHooks(hooksWithMetadata);
360
- const fanoutWithHooks = new FanoutEngine({
361
- transport,
362
- schema,
363
- hooks: hooksInstance,
364
- topology: testTopology,
365
- defaultQueue: 'events',
366
- });
367
- const subscriber = createSubscriber({
368
- name: 'handle-user',
369
- description: 'Handles user events',
370
- callback: async () => { },
371
- });
372
- schema.register(UserCreatedEvent, [subscriber]);
373
- const event = new UserCreatedEvent({
374
- userId: '123',
375
- email: 'test@example.com',
376
- });
377
- await fanoutWithHooks.send(UserCreatedEvent, event);
378
- const envelope = transport.send.mock
379
- .calls[0]?.[1];
380
- expect(envelope.docket.metadata).toEqual({
381
- environment: 'test',
382
- hostname: 'test-host',
383
- });
384
- });
385
- it('should have empty metadata when no metadata provided', async () => {
386
- const subscriber = createSubscriber({
387
- name: 'handle-user',
388
- description: 'Handles user events',
389
- callback: async () => { },
390
- });
391
- schema.register(UserCreatedEvent, [subscriber]);
392
- const event = new UserCreatedEvent({
393
- userId: '123',
394
- email: 'test@example.com',
395
- });
396
- await fanout.send(UserCreatedEvent, event);
397
- const envelope = transport.send.mock
398
- .calls[0]?.[1];
399
- expect(envelope.docket.metadata).toEqual({});
400
- });
401
- });
402
- describe('filtering disabled subscribers', () => {
403
- it('should send to enabled subscriber', async () => {
404
- const subscriber = createSubscriber({
405
- name: 'handle-user',
406
- description: 'Handles user events',
407
- callback: async () => { },
408
- enabled: () => true,
409
- });
410
- schema.register(UserCreatedEvent, [subscriber]);
411
- const event = new UserCreatedEvent({
412
- userId: '123',
413
- email: 'test@example.com',
414
- });
415
- const result = await fanout.send(UserCreatedEvent, event);
416
- expect(result.subscribersSent).toBe(1);
417
- expect(result.subscribersSkipped).toBe(0);
418
- });
419
- it('should skip disabled subscriber', async () => {
420
- const subscriber = createSubscriber({
421
- name: 'handle-user',
422
- description: 'Handles user events',
423
- callback: async () => { },
424
- enabled: () => false,
425
- });
426
- schema.register(UserCreatedEvent, [subscriber]);
427
- const event = new UserCreatedEvent({
428
- userId: '123',
429
- email: 'test@example.com',
430
- });
431
- const result = await fanout.send(UserCreatedEvent, event);
432
- expect(result.subscribersSent).toBe(0);
433
- expect(result.subscribersSkipped).toBe(1);
434
- expect(transport.send).not.toHaveBeenCalled();
435
- });
436
- it('should filter some subscribers and send to others', async () => {
437
- const sub1 = createSubscriber({
438
- name: 'enabled-sub',
439
- description: 'Enabled subscriber',
440
- callback: async () => { },
441
- enabled: () => true,
442
- });
443
- const sub2 = createSubscriber({
444
- name: 'disabled-sub',
445
- description: 'Disabled subscriber',
446
- callback: async () => { },
447
- enabled: () => false,
448
- });
449
- const sub3 = createSubscriber({
450
- name: 'always-enabled-sub',
451
- description: 'Always enabled subscriber',
452
- callback: async () => { },
453
- });
454
- schema.register(UserCreatedEvent, [sub1, sub2, sub3]);
455
- const event = new UserCreatedEvent({
456
- userId: '123',
457
- email: 'test@example.com',
458
- });
459
- const result = await fanout.send(UserCreatedEvent, event);
460
- expect(result.subscribersSent).toBe(2);
461
- expect(result.subscribersSkipped).toBe(1);
462
- expect(transport.send).toHaveBeenCalledTimes(2);
463
- });
464
- it('should support async enabled function', async () => {
465
- const subscriber = createSubscriber({
466
- name: 'handle-user',
467
- description: 'Handles user events',
468
- callback: async () => { },
469
- enabled: async () => {
470
- await new Promise((resolve) => setTimeout(resolve, 10));
471
- return true;
472
- },
473
- });
474
- schema.register(UserCreatedEvent, [subscriber]);
475
- const event = new UserCreatedEvent({
476
- userId: '123',
477
- email: 'test@example.com',
478
- });
479
- const result = await fanout.send(UserCreatedEvent, event);
480
- expect(result.subscribersSent).toBe(1);
481
- expect(result.subscribersSkipped).toBe(0);
482
- });
483
- it('should treat subscriber as enabled if enabled() throws error', async () => {
484
- const subscriber = createSubscriber({
485
- name: 'handle-user',
486
- description: 'Handles user events',
487
- callback: async () => { },
488
- enabled: () => {
489
- throw new Error('Feature flag service down');
490
- },
491
- });
492
- schema.register(UserCreatedEvent, [subscriber]);
493
- const event = new UserCreatedEvent({
494
- userId: '123',
495
- email: 'test@example.com',
496
- });
497
- const result = await fanout.send(UserCreatedEvent, event);
498
- expect(result.subscribersSent).toBe(1);
499
- expect(result.subscribersSkipped).toBe(0);
500
- });
501
- it('should treat subscriber as enabled if no enabled hook provided', async () => {
502
- const subscriber = createSubscriber({
503
- name: 'handle-user',
504
- description: 'Handles user events',
505
- callback: async () => { },
506
- });
507
- schema.register(UserCreatedEvent, [subscriber]);
508
- const event = new UserCreatedEvent({
509
- userId: '123',
510
- email: 'test@example.com',
511
- });
512
- const result = await fanout.send(UserCreatedEvent, event);
513
- expect(result.subscribersSent).toBe(1);
514
- expect(result.subscribersSkipped).toBe(0);
515
- });
516
- it('should work with subscriber stubs', async () => {
517
- const stub = createSubscriberStub({
518
- name: 'remote-subscriber',
519
- enabled: () => false,
520
- });
521
- schema.register(UserCreatedEvent, [stub]);
522
- const event = new UserCreatedEvent({
523
- userId: '123',
524
- email: 'test@example.com',
525
- });
526
- const result = await fanout.send(UserCreatedEvent, event);
527
- expect(result.subscribersSent).toBe(0);
528
- expect(result.subscribersSkipped).toBe(1);
529
- });
530
- });
531
- describe('error handling', () => {
532
- it('should capture error when transport.send fails and buffer is disabled', async () => {
533
- const sendError = new Error('Network timeout');
534
- const failingTransport = {
535
- ...transport,
536
- send: mock(async () => {
537
- throw sendError;
538
- }),
539
- };
540
- const fanoutWithFailingTransport = new FanoutEngine({
541
- transport: failingTransport,
542
- schema,
543
- hooks,
544
- topology: testTopology,
545
- defaultQueue: 'events',
546
- });
547
- const subscriber = createSubscriber({
548
- name: 'handle-user',
549
- description: 'Handles user events',
550
- callback: async () => { },
551
- });
552
- schema.register(UserCreatedEvent, [subscriber]);
553
- const event = new UserCreatedEvent({
554
- userId: '123',
555
- email: 'test@example.com',
556
- });
557
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
558
- expect(result.subscribersSent).toBe(0);
559
- expect(result.subscribersSkipped).toBe(0);
560
- expect(result.errors).toHaveLength(1);
561
- expect(result.errors[0]?.subscriberName).toBe('handle-user');
562
- expect(result.errors[0]?.queue).toBe('matador.test.events');
563
- expect(result.errors[0]?.error).toBeInstanceOf(TransportSendError);
564
- });
565
- it('should continue sending to other subscribers after one fails', async () => {
566
- const failingTransport = {
567
- ...transport,
568
- send: mock(async (queue) => {
569
- if (queue === 'matador.test.queue-1') {
570
- throw new Error('Queue 1 failed');
571
- }
572
- return 'mock';
573
- }),
574
- };
575
- const fanoutWithFailingTransport = new FanoutEngine({
576
- transport: failingTransport,
577
- schema,
578
- hooks,
579
- topology: testTopology,
580
- defaultQueue: 'events',
581
- });
582
- const sub1 = createSubscriber({
583
- name: 'subscriber-1',
584
- description: 'Subscriber 1',
585
- callback: async () => { },
586
- targetQueue: 'queue-1',
587
- });
588
- const sub2 = createSubscriber({
589
- name: 'subscriber-2',
590
- description: 'Subscriber 2',
591
- callback: async () => { },
592
- targetQueue: 'queue-2',
593
- });
594
- schema.register(UserCreatedEvent, [sub1, sub2]);
595
- const event = new UserCreatedEvent({
596
- userId: '123',
597
- email: 'test@example.com',
598
- });
599
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
600
- expect(result.subscribersSent).toBe(1);
601
- expect(result.subscribersSkipped).toBe(0);
602
- expect(result.errors).toHaveLength(1);
603
- expect(result.errors[0]?.subscriberName).toBe('subscriber-1');
604
- });
605
- it('should wrap non-Error throws as Error in TransportSendError', async () => {
606
- const failingTransport = {
607
- ...transport,
608
- send: mock(async () => {
609
- throw 'string error';
610
- }),
611
- };
612
- const fanoutWithFailingTransport = new FanoutEngine({
613
- transport: failingTransport,
614
- schema,
615
- hooks,
616
- topology: testTopology,
617
- defaultQueue: 'events',
618
- });
619
- const subscriber = createSubscriber({
620
- name: 'handle-user',
621
- description: 'Handles user events',
622
- callback: async () => { },
623
- });
624
- schema.register(UserCreatedEvent, [subscriber]);
625
- const event = new UserCreatedEvent({
626
- userId: '123',
627
- email: 'test@example.com',
628
- });
629
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
630
- expect(result.errors).toHaveLength(1);
631
- expect(result.errors[0]?.error).toBeInstanceOf(TransportSendError);
632
- expect(result.errors[0]?.error.message).toContain('matador.test.events');
633
- });
634
- });
635
- describe('eventsBeingEnqueuedCount tracking', () => {
636
- it('should increment count during send', async () => {
637
- let countDuringSend = 0;
638
- // biome-ignore lint/style/useConst: Variable must be let to be assigned after closure definition
639
- let fanoutWithTracking;
640
- const trackingTransport = {
641
- ...transport,
642
- send: mock(async () => {
643
- countDuringSend = fanoutWithTracking.eventsBeingEnqueuedCount;
644
- return 'mock';
645
- }),
646
- };
647
- fanoutWithTracking = new FanoutEngine({
648
- transport: trackingTransport,
649
- schema,
650
- hooks,
651
- topology: testTopology,
652
- defaultQueue: 'events',
653
- });
654
- const subscriber = createSubscriber({
655
- name: 'handle-user',
656
- description: 'Handles user events',
657
- callback: async () => { },
658
- });
659
- schema.register(UserCreatedEvent, [subscriber]);
660
- const event = new UserCreatedEvent({
661
- userId: '123',
662
- email: 'test@example.com',
663
- });
664
- expect(fanoutWithTracking.eventsBeingEnqueuedCount).toBe(0);
665
- await fanoutWithTracking.send(UserCreatedEvent, event);
666
- expect(countDuringSend).toBe(1);
667
- });
668
- it('should decrement count after send completes', async () => {
669
- const subscriber = createSubscriber({
670
- name: 'handle-user',
671
- description: 'Handles user events',
672
- callback: async () => { },
673
- });
674
- schema.register(UserCreatedEvent, [subscriber]);
675
- const event = new UserCreatedEvent({
676
- userId: '123',
677
- email: 'test@example.com',
678
- });
679
- expect(fanout.eventsBeingEnqueuedCount).toBe(0);
680
- await fanout.send(UserCreatedEvent, event);
681
- expect(fanout.eventsBeingEnqueuedCount).toBe(0);
682
- });
683
- it('should decrement count even when send fails', async () => {
684
- const failingTransport = {
685
- ...transport,
686
- send: mock(async () => {
687
- throw new Error('Send failed');
688
- }),
689
- };
690
- const fanoutWithFailingTransport = new FanoutEngine({
691
- transport: failingTransport,
692
- schema,
693
- hooks,
694
- topology: testTopology,
695
- defaultQueue: 'events',
696
- });
697
- const subscriber = createSubscriber({
698
- name: 'handle-user',
699
- description: 'Handles user events',
700
- callback: async () => { },
701
- });
702
- schema.register(UserCreatedEvent, [subscriber]);
703
- const event = new UserCreatedEvent({
704
- userId: '123',
705
- email: 'test@example.com',
706
- });
707
- expect(fanoutWithFailingTransport.eventsBeingEnqueuedCount).toBe(0);
708
- await fanoutWithFailingTransport.send(UserCreatedEvent, event);
709
- expect(fanoutWithFailingTransport.eventsBeingEnqueuedCount).toBe(0);
710
- });
711
- it('should track multiple sequential sends to subscribers', async () => {
712
- const sub1 = createSubscriber({
713
- name: 'subscriber-1',
714
- description: 'Subscriber 1',
715
- callback: async () => { },
716
- });
717
- const sub2 = createSubscriber({
718
- name: 'subscriber-2',
719
- description: 'Subscriber 2',
720
- callback: async () => { },
721
- });
722
- schema.register(UserCreatedEvent, [sub1, sub2]);
723
- let maxCount = 0;
724
- const counts = [];
725
- // biome-ignore lint/style/useConst: Variable must be let to be assigned after closure definition
726
- let fanoutWithTracking;
727
- const trackingTransport = {
728
- ...transport,
729
- send: mock(async () => {
730
- const current = fanoutWithTracking.eventsBeingEnqueuedCount;
731
- counts.push(current);
732
- maxCount = Math.max(maxCount, current);
733
- await new Promise((resolve) => setTimeout(resolve, 10));
734
- return 'mock';
735
- }),
736
- };
737
- fanoutWithTracking = new FanoutEngine({
738
- transport: trackingTransport,
739
- schema,
740
- hooks,
741
- topology: testTopology,
742
- defaultQueue: 'events',
743
- });
744
- const event = new UserCreatedEvent({
745
- userId: '123',
746
- email: 'test@example.com',
747
- });
748
- await fanoutWithTracking.send(UserCreatedEvent, event);
749
- expect(maxCount).toBe(1);
750
- expect(counts).toEqual([1, 1]);
751
- expect(fanoutWithTracking.eventsBeingEnqueuedCount).toBe(0);
752
- });
753
- });
754
- describe('correlation ID propagation', () => {
755
- it('should include correlation ID in envelope when provided', async () => {
756
- const subscriber = createSubscriber({
757
- name: 'handle-user',
758
- description: 'Handles user events',
759
- callback: async () => { },
760
- });
761
- schema.register(UserCreatedEvent, [subscriber]);
762
- const event = new UserCreatedEvent({
763
- userId: '123',
764
- email: 'test@example.com',
765
- });
766
- const options = {
767
- correlationId: 'corr-123',
768
- };
769
- await fanout.send(UserCreatedEvent, event, options);
770
- const envelope = transport.send.mock
771
- .calls[0]?.[1];
772
- expect(envelope.docket.correlationId).toBe('corr-123');
773
- });
774
- it('should propagate same correlation ID to all subscribers', async () => {
775
- const sub1 = createSubscriber({
776
- name: 'subscriber-1',
777
- description: 'Subscriber 1',
778
- callback: async () => { },
779
- });
780
- const sub2 = createSubscriber({
781
- name: 'subscriber-2',
782
- description: 'Subscriber 2',
783
- callback: async () => { },
784
- });
785
- schema.register(UserCreatedEvent, [sub1, sub2]);
786
- const event = new UserCreatedEvent({
787
- userId: '123',
788
- email: 'test@example.com',
789
- });
790
- const options = {
791
- correlationId: 'corr-456',
792
- };
793
- await fanout.send(UserCreatedEvent, event, options);
794
- const calls = transport.send.mock.calls;
795
- const envelope1 = calls[0]?.[1];
796
- const envelope2 = calls[1]?.[1];
797
- expect(envelope1.docket.correlationId).toBe('corr-456');
798
- expect(envelope2.docket.correlationId).toBe('corr-456');
799
- });
800
- it('should have undefined correlation ID when not provided', async () => {
801
- const subscriber = createSubscriber({
802
- name: 'handle-user',
803
- description: 'Handles user events',
804
- callback: async () => { },
805
- });
806
- schema.register(UserCreatedEvent, [subscriber]);
807
- const event = new UserCreatedEvent({
808
- userId: '123',
809
- email: 'test@example.com',
810
- });
811
- await fanout.send(UserCreatedEvent, event);
812
- const envelope = transport.send.mock
813
- .calls[0]?.[1];
814
- expect(envelope.docket.correlationId).toBeUndefined();
815
- });
816
- });
817
- describe('hook invocation', () => {
818
- it('should call onEnqueueSuccess after successful send', async () => {
819
- const onEnqueueSuccess = mock(async () => { });
820
- const hooksWithSuccess = {
821
- onEnqueueSuccess,
822
- };
823
- const hooksInstance = new SafeHooks(hooksWithSuccess);
824
- const fanoutWithHooks = new FanoutEngine({
825
- transport,
826
- schema,
827
- hooks: hooksInstance,
828
- topology: testTopology,
829
- defaultQueue: 'events',
830
- });
831
- const subscriber = createSubscriber({
832
- name: 'handle-user',
833
- description: 'Handles user events',
834
- callback: async () => { },
835
- });
836
- schema.register(UserCreatedEvent, [subscriber]);
837
- const event = new UserCreatedEvent({
838
- userId: '123',
839
- email: 'test@example.com',
840
- });
841
- await fanoutWithHooks.send(UserCreatedEvent, event);
842
- expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
843
- expect(onEnqueueSuccess).toHaveBeenCalledWith({
844
- envelope: expect.any(Object),
845
- queue: 'matador.test.events',
846
- transport: 'mock',
847
- });
848
- const calls = onEnqueueSuccess.mock.calls;
849
- const firstCall = calls[0];
850
- if (!firstCall)
851
- throw new Error('expected onEnqueueSuccess to be called');
852
- const callArgs = firstCall[0];
853
- expect(callArgs.envelope.docket.eventKey).toBe('user.created');
854
- expect(callArgs.envelope.docket.targetSubscriber).toBe('handle-user');
855
- });
856
- it('should call onEnqueueError after failed send', async () => {
857
- const onEnqueueError = mock(async () => { });
858
- const hooksWithError = {
859
- onEnqueueError,
860
- };
861
- const hooksInstance = new SafeHooks(hooksWithError);
862
- const failingTransport = {
863
- ...transport,
864
- send: mock(async () => {
865
- throw new Error('Send failed');
866
- }),
867
- };
868
- const fanoutWithHooks = new FanoutEngine({
869
- transport: failingTransport,
870
- schema,
871
- hooks: hooksInstance,
872
- topology: testTopology,
873
- defaultQueue: 'events',
874
- });
875
- const subscriber = createSubscriber({
876
- name: 'handle-user',
877
- description: 'Handles user events',
878
- callback: async () => { },
879
- });
880
- schema.register(UserCreatedEvent, [subscriber]);
881
- const event = new UserCreatedEvent({
882
- userId: '123',
883
- email: 'test@example.com',
884
- });
885
- await fanoutWithHooks.send(UserCreatedEvent, event, {
886
- throwOnBufferedFailure: true,
887
- });
888
- expect(onEnqueueError).toHaveBeenCalledTimes(1);
889
- expect(onEnqueueError).toHaveBeenCalledWith({
890
- envelope: expect.any(Object),
891
- error: expect.any(TransportSendError),
892
- transport: 'mock',
893
- });
894
- const calls = onEnqueueError.mock.calls;
895
- const firstCall = calls[0];
896
- if (!firstCall)
897
- throw new Error('expected onEnqueueError to be called');
898
- const callArgs = firstCall[0];
899
- expect(callArgs.envelope.docket.eventKey).toBe('user.created');
900
- expect(callArgs.error).toBeInstanceOf(TransportSendError);
901
- });
902
- it('should call hooks for each subscriber', async () => {
903
- const onEnqueueSuccess = mock(async () => { });
904
- const hooksWithSuccess = {
905
- onEnqueueSuccess,
906
- };
907
- const hooksInstance = new SafeHooks(hooksWithSuccess);
908
- const fanoutWithHooks = new FanoutEngine({
909
- transport,
910
- schema,
911
- hooks: hooksInstance,
912
- topology: testTopology,
913
- defaultQueue: 'events',
914
- });
915
- const sub1 = createSubscriber({
916
- name: 'subscriber-1',
917
- description: 'Subscriber 1',
918
- callback: async () => { },
919
- });
920
- const sub2 = createSubscriber({
921
- name: 'subscriber-2',
922
- description: 'Subscriber 2',
923
- callback: async () => { },
924
- });
925
- const sub3 = createSubscriber({
926
- name: 'subscriber-3',
927
- description: 'Subscriber 3',
928
- callback: async () => { },
929
- });
930
- schema.register(UserCreatedEvent, [sub1, sub2, sub3]);
931
- const event = new UserCreatedEvent({
932
- userId: '123',
933
- email: 'test@example.com',
934
- });
935
- await fanoutWithHooks.send(UserCreatedEvent, event);
936
- expect(onEnqueueSuccess).toHaveBeenCalledTimes(3);
937
- });
938
- it('should not call onEnqueueSuccess when subscriber is disabled', async () => {
939
- const onEnqueueSuccess = mock(async () => { });
940
- const hooksWithSuccess = {
941
- onEnqueueSuccess,
942
- };
943
- const hooksInstance = new SafeHooks(hooksWithSuccess);
944
- const fanoutWithHooks = new FanoutEngine({
945
- transport,
946
- schema,
947
- hooks: hooksInstance,
948
- topology: testTopology,
949
- defaultQueue: 'events',
950
- });
951
- const subscriber = createSubscriber({
952
- name: 'handle-user',
953
- description: 'Handles user events',
954
- callback: async () => { },
955
- enabled: () => false,
956
- });
957
- schema.register(UserCreatedEvent, [subscriber]);
958
- const event = new UserCreatedEvent({
959
- userId: '123',
960
- email: 'test@example.com',
961
- });
962
- await fanoutWithHooks.send(UserCreatedEvent, event);
963
- expect(onEnqueueSuccess).not.toHaveBeenCalled();
964
- });
965
- it('should continue processing even if hook throws error', async () => {
966
- const onEnqueueSuccess = mock(async () => {
967
- throw new Error('Hook error');
968
- });
969
- const hooksWithSuccess = {
970
- onEnqueueSuccess,
971
- };
972
- const hooksInstance = new SafeHooks(hooksWithSuccess);
973
- const fanoutWithHooks = new FanoutEngine({
974
- transport,
975
- schema,
976
- hooks: hooksInstance,
977
- topology: testTopology,
978
- defaultQueue: 'events',
979
- });
980
- const subscriber = createSubscriber({
981
- name: 'handle-user',
982
- description: 'Handles user events',
983
- callback: async () => { },
984
- });
985
- schema.register(UserCreatedEvent, [subscriber]);
986
- const event = new UserCreatedEvent({
987
- userId: '123',
988
- email: 'test@example.com',
989
- });
990
- const result = await fanoutWithHooks.send(UserCreatedEvent, event);
991
- expect(result.subscribersSent).toBe(1);
992
- expect(result.errors).toHaveLength(0);
993
- expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
994
- });
995
- });
996
- describe('delay options', () => {
997
- it('should pass delay option to transport', async () => {
998
- const subscriber = createSubscriber({
999
- name: 'handle-user',
1000
- description: 'Handles user events',
1001
- callback: async () => { },
1002
- });
1003
- schema.register(UserCreatedEvent, [subscriber]);
1004
- const event = new UserCreatedEvent({
1005
- userId: '123',
1006
- email: 'test@example.com',
1007
- });
1008
- const options = {
1009
- delayMs: 5000,
1010
- };
1011
- await fanout.send(UserCreatedEvent, event, options);
1012
- expect(transport.send).toHaveBeenCalledWith('matador.test.events', expect.any(Object), { delay: 5000 });
1013
- });
1014
- it('should include delay in envelope', async () => {
1015
- const subscriber = createSubscriber({
1016
- name: 'handle-user',
1017
- description: 'Handles user events',
1018
- callback: async () => { },
1019
- });
1020
- schema.register(UserCreatedEvent, [subscriber]);
1021
- const event = new UserCreatedEvent({
1022
- userId: '123',
1023
- email: 'test@example.com',
1024
- });
1025
- const options = {
1026
- delayMs: 3000,
1027
- };
1028
- await fanout.send(UserCreatedEvent, event, options);
1029
- const envelope = transport.send.mock
1030
- .calls[0]?.[1];
1031
- expect(envelope.docket.scheduledFor).toBeDefined();
1032
- if (envelope.docket.scheduledFor) {
1033
- const scheduledTime = new Date(envelope.docket.scheduledFor).getTime();
1034
- const now = Date.now();
1035
- expect(scheduledTime).toBeGreaterThan(now);
1036
- expect(scheduledTime).toBeLessThan(now + 4000);
1037
- }
1038
- });
1039
- it('should not pass delay option when delayMs is undefined', async () => {
1040
- const subscriber = createSubscriber({
1041
- name: 'handle-user',
1042
- description: 'Handles user events',
1043
- callback: async () => { },
1044
- });
1045
- schema.register(UserCreatedEvent, [subscriber]);
1046
- const event = new UserCreatedEvent({
1047
- userId: '123',
1048
- email: 'test@example.com',
1049
- });
1050
- await fanout.send(UserCreatedEvent, event);
1051
- expect(transport.send).toHaveBeenCalledWith('matador.test.events', expect.any(Object), undefined);
1052
- });
1053
- });
1054
- describe('subscriber importance', () => {
1055
- it('should use subscriber importance when specified', async () => {
1056
- const subscriber = createSubscriber({
1057
- name: 'handle-user',
1058
- description: 'Handles user events',
1059
- callback: async () => { },
1060
- importance: 'must-investigate',
1061
- });
1062
- schema.register(UserCreatedEvent, [subscriber]);
1063
- const event = new UserCreatedEvent({
1064
- userId: '123',
1065
- email: 'test@example.com',
1066
- });
1067
- await fanout.send(UserCreatedEvent, event);
1068
- const envelope = transport.send.mock
1069
- .calls[0]?.[1];
1070
- expect(envelope.docket.importance).toBe('must-investigate');
1071
- });
1072
- it('should default to should-investigate when importance not specified', async () => {
1073
- const subscriber = createSubscriber({
1074
- name: 'handle-user',
1075
- description: 'Handles user events',
1076
- callback: async () => { },
1077
- });
1078
- schema.register(UserCreatedEvent, [subscriber]);
1079
- const event = new UserCreatedEvent({
1080
- userId: '123',
1081
- email: 'test@example.com',
1082
- });
1083
- await fanout.send(UserCreatedEvent, event);
1084
- const envelope = transport.send.mock
1085
- .calls[0]?.[1];
1086
- expect(envelope.docket.importance).toBe('should-investigate');
1087
- });
1088
- });
1089
- describe('retry buffering', () => {
1090
- it('should buffer a failed send and not return it as an error', async () => {
1091
- const subscriber = createSubscriber({
1092
- name: 'handle-user',
1093
- description: 'Handles user events',
1094
- callback: async () => { },
1095
- });
1096
- schema.register(UserCreatedEvent, [subscriber]);
1097
- const failingTransport = {
1098
- ...transport,
1099
- send: mock(async () => {
1100
- throw new Error('Connection lost');
1101
- }),
1102
- };
1103
- const fanoutWithFailingTransport = new FanoutEngine({
1104
- transport: failingTransport,
1105
- schema,
1106
- hooks,
1107
- topology: testTopology,
1108
- defaultQueue: 'events',
1109
- });
1110
- const event = new UserCreatedEvent({
1111
- userId: '123',
1112
- email: 'test@example.com',
1113
- });
1114
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
1115
- expect(result.errors).toHaveLength(0);
1116
- expect(result.subscribersSent).toBe(0);
1117
- });
1118
- it('should buffer stub failures too', async () => {
1119
- const stub = createSubscriberStub({ name: 'remote-service' });
1120
- schema.register(UserCreatedEvent, [stub]);
1121
- const failingTransport = {
1122
- ...transport,
1123
- send: mock(async () => {
1124
- throw new Error('Connection lost');
1125
- }),
1126
- };
1127
- const fanoutWithFailingTransport = new FanoutEngine({
1128
- transport: failingTransport,
1129
- schema,
1130
- hooks,
1131
- topology: testTopology,
1132
- defaultQueue: 'events',
1133
- });
1134
- const event = new UserCreatedEvent({
1135
- userId: '123',
1136
- email: 'test@example.com',
1137
- });
1138
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
1139
- expect(result.errors).toHaveLength(0);
1140
- expect(result.subscribersSent).toBe(0);
1141
- });
1142
- it('should buffer instead of reporting success when a stub subscriber has no local subscriber', async () => {
1143
- // LocalTransport rejects sends to a queue with no active subscriber in
1144
- // this process. A stub subscriber's real implementation lives in another
1145
- // service, so this queue will never have one here — the send must fail
1146
- // and be buffered, not reported as delivered.
1147
- const stub = createSubscriberStub({ name: 'remote-service' });
1148
- schema.register(UserCreatedEvent, [stub]);
1149
- const onEnqueueSuccess = mock(async () => { });
1150
- const hooksInstance = new SafeHooks({ onEnqueueSuccess });
1151
- const localTransport = new LocalTransport();
1152
- await localTransport.connect();
1153
- await localTransport.applyTopology(testTopology);
1154
- const fanoutWithLocal = new FanoutEngine({
1155
- transport: localTransport,
1156
- schema,
1157
- hooks: hooksInstance,
1158
- topology: testTopology,
1159
- defaultQueue: 'events',
1160
- // Fast interval so the test doesn't have to wait on the 30s default
1161
- // to prove the message is actually held for retry, not dropped.
1162
- retryIntervalMs: 10,
1163
- });
1164
- const event = new UserCreatedEvent({
1165
- userId: '123',
1166
- email: 'test@example.com',
1167
- });
1168
- const result = await fanoutWithLocal.send(UserCreatedEvent, event);
1169
- expect(result.errors).toHaveLength(0);
1170
- expect(result.subscribersSent).toBe(0);
1171
- expect(onEnqueueSuccess).not.toHaveBeenCalled();
1172
- // The message never reached LocalTransport's own storage (enqueue()
1173
- // throws before storing), so it can only still exist in-memory if
1174
- // FanoutEngine actually buffered it. Prove that by attaching a
1175
- // subscriber and letting the periodic flush retry the send — if the
1176
- // message had been dropped instead of buffered, nothing would arrive.
1177
- const received = [];
1178
- await localTransport.subscribe(resolveTargetQueueName(testTopology, 'events'), async (envelope) => {
1179
- received.push(envelope);
1180
- });
1181
- await new Promise((resolve) => setTimeout(resolve, 30));
1182
- expect(received).toHaveLength(1);
1183
- expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
1184
- fanoutWithLocal.dispose();
1185
- });
1186
- it('should flush buffered messages when onConnected fires', async () => {
1187
- let connectedCallback;
1188
- const failThenSucceed = mock(async () => {
1189
- throw new Error('Connection lost');
1190
- });
1191
- const transportWithReconnect = {
1192
- ...transport,
1193
- send: failThenSucceed,
1194
- onConnected: (cb) => {
1195
- connectedCallback = cb;
1196
- return () => { };
1197
- },
1198
- };
1199
- const fanoutWithReconnect = new FanoutEngine({
1200
- transport: transportWithReconnect,
1201
- schema,
1202
- hooks,
1203
- topology: testTopology,
1204
- defaultQueue: 'events',
1205
- });
1206
- const subscriber = createSubscriber({
1207
- name: 'handle-user',
1208
- description: 'Handles user events',
1209
- callback: async () => { },
1210
- });
1211
- schema.register(UserCreatedEvent, [subscriber]);
1212
- const event = new UserCreatedEvent({
1213
- userId: '123',
1214
- email: 'test@example.com',
1215
- });
1216
- // First send — transport fails, message should be buffered
1217
- await fanoutWithReconnect.send(UserCreatedEvent, event);
1218
- expect(failThenSucceed).toHaveBeenCalledTimes(1);
1219
- // Simulate reconnect — now send succeeds
1220
- transportWithReconnect.send.mockImplementation(async () => 'mock');
1221
- expect(connectedCallback).toBeDefined();
1222
- connectedCallback?.();
1223
- // Give the async flush a chance to run
1224
- await waitForFlush(fanoutWithReconnect);
1225
- expect(transportWithReconnect.send).toHaveBeenCalledTimes(2);
1226
- });
1227
- it('should re-buffer on flush failure and retry on next reconnect', async () => {
1228
- let connectedCallback;
1229
- const transportWithReconnect = {
1230
- ...transport,
1231
- send: mock(async () => {
1232
- throw new Error('Still down');
1233
- }),
1234
- onConnected: (cb) => {
1235
- connectedCallback = cb;
1236
- return () => { };
1237
- },
1238
- };
1239
- const fanoutWithReconnect = new FanoutEngine({
1240
- transport: transportWithReconnect,
1241
- schema,
1242
- hooks,
1243
- topology: testTopology,
1244
- defaultQueue: 'events',
1245
- });
1246
- const subscriber = createSubscriber({
1247
- name: 'handle-user',
1248
- description: 'Handles user events',
1249
- callback: async () => { },
1250
- });
1251
- schema.register(UserCreatedEvent, [subscriber]);
1252
- const event = new UserCreatedEvent({
1253
- userId: '123',
1254
- email: 'test@example.com',
1255
- });
1256
- await fanoutWithReconnect.send(UserCreatedEvent, event);
1257
- expect(transportWithReconnect.send).toHaveBeenCalledTimes(1);
1258
- // Simulate reconnect — flush fails, item should be re-buffered
1259
- connectedCallback?.();
1260
- await waitForFlush(fanoutWithReconnect);
1261
- // send was attempted again during the flush
1262
- expect(transportWithReconnect.send).toHaveBeenCalledTimes(2);
1263
- // Simulate a second reconnect — should retry again
1264
- connectedCallback?.();
1265
- await waitForFlush(fanoutWithReconnect);
1266
- expect(transportWithReconnect.send).toHaveBeenCalledTimes(3);
1267
- });
1268
- it('should not buffer when transport works fine', async () => {
1269
- let connectedCallback;
1270
- const successTransport = {
1271
- ...transport,
1272
- onConnected: (cb) => {
1273
- connectedCallback = cb;
1274
- return () => { };
1275
- },
1276
- };
1277
- const fanoutWithReconnect = new FanoutEngine({
1278
- transport: successTransport,
1279
- schema,
1280
- hooks,
1281
- topology: testTopology,
1282
- defaultQueue: 'events',
1283
- });
1284
- const subscriber = createSubscriber({
1285
- name: 'handle-user',
1286
- description: 'Handles user events',
1287
- callback: async () => { },
1288
- });
1289
- schema.register(UserCreatedEvent, [subscriber]);
1290
- const event = new UserCreatedEvent({
1291
- userId: '123',
1292
- email: 'test@example.com',
1293
- });
1294
- const result = await fanoutWithReconnect.send(UserCreatedEvent, event);
1295
- expect(result.subscribersSent).toBe(1);
1296
- expect(result.errors).toHaveLength(0);
1297
- // onConnected fires — nothing buffered so flush is a no-op
1298
- connectedCallback?.();
1299
- await waitForFlush(fanoutWithReconnect);
1300
- // Only one call total (the original send — not a spurious re-send)
1301
- expect(successTransport.send).toHaveBeenCalledTimes(1);
1302
- });
1303
- it('should report error in result when buffer is false', async () => {
1304
- const subscriber = createSubscriber({
1305
- name: 'handle-user',
1306
- description: 'Handles user events',
1307
- callback: async () => { },
1308
- });
1309
- schema.register(UserCreatedEvent, [subscriber]);
1310
- const failingTransport = {
1311
- ...transport,
1312
- send: mock(async () => {
1313
- throw new Error('Connection lost');
1314
- }),
1315
- };
1316
- const fanoutWithFailingTransport = new FanoutEngine({
1317
- transport: failingTransport,
1318
- schema,
1319
- hooks,
1320
- topology: testTopology,
1321
- defaultQueue: 'events',
1322
- });
1323
- const event = new UserCreatedEvent({
1324
- userId: '123',
1325
- email: 'test@example.com',
1326
- });
1327
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
1328
- expect(result.errors).toHaveLength(1);
1329
- expect(result.errors[0]?.subscriberName).toBe('handle-user');
1330
- expect(result.errors[0]?.error).toBeInstanceOf(TransportSendError);
1331
- });
1332
- it('should both buffer and report error when reportBufferedFailure is true', async () => {
1333
- let connectedCallback;
1334
- const failingTransport = {
1335
- ...transport,
1336
- send: mock(async () => {
1337
- throw new Error('Connection lost');
1338
- }),
1339
- onConnected: (cb) => {
1340
- connectedCallback = cb;
1341
- return () => { };
1342
- },
1343
- };
1344
- const fanoutWithReconnect = new FanoutEngine({
1345
- transport: failingTransport,
1346
- schema,
1347
- hooks,
1348
- topology: testTopology,
1349
- defaultQueue: 'events',
1350
- });
1351
- const subscriber = createSubscriber({
1352
- name: 'handle-user',
1353
- description: 'Handles user events',
1354
- callback: async () => { },
1355
- });
1356
- schema.register(UserCreatedEvent, [subscriber]);
1357
- const event = new UserCreatedEvent({
1358
- userId: '123',
1359
- email: 'test@example.com',
1360
- });
1361
- const result = await fanoutWithReconnect.send(UserCreatedEvent, event, {
1362
- throwOnBufferedFailure: true,
1363
- });
1364
- // Error is reported immediately
1365
- expect(result.errors).toHaveLength(1);
1366
- expect(result.errors[0]?.subscriberName).toBe('handle-user');
1367
- // But it was also buffered — it should retry on reconnect
1368
- failingTransport.send.mockImplementation(async () => 'mock');
1369
- connectedCallback?.();
1370
- await waitForFlush(fanoutWithReconnect);
1371
- expect(failingTransport.send).toHaveBeenCalledTimes(2);
1372
- });
1373
- it('should unsubscribe the onConnected listener when dispose() is called', () => {
1374
- const unsubscribe = mock(() => { });
1375
- const transportWithOnConnected = {
1376
- ...transport,
1377
- onConnected: mock((_cb) => unsubscribe),
1378
- };
1379
- const fanout = new FanoutEngine({
1380
- transport: transportWithOnConnected,
1381
- schema,
1382
- hooks,
1383
- topology: testTopology,
1384
- defaultQueue: 'events',
1385
- });
1386
- expect(unsubscribe).not.toHaveBeenCalled();
1387
- fanout.dispose();
1388
- expect(unsubscribe).toHaveBeenCalledTimes(1);
1389
- });
1390
- it('should drop and report error when retry buffer is full', async () => {
1391
- const subscriber = createSubscriber({
1392
- name: 'handle-user',
1393
- description: 'Handles user events',
1394
- callback: async () => { },
1395
- });
1396
- schema.register(UserCreatedEvent, [subscriber]);
1397
- const failingTransport = {
1398
- ...transport,
1399
- send: mock(async () => {
1400
- throw new Error('Connection lost');
1401
- }),
1402
- };
1403
- const fanoutWithSmallBuffer = new FanoutEngine({
1404
- transport: failingTransport,
1405
- schema,
1406
- hooks,
1407
- topology: testTopology,
1408
- defaultQueue: 'events',
1409
- maxRetryBufferSize: 1,
1410
- });
1411
- const event = new UserCreatedEvent({
1412
- userId: '123',
1413
- email: 'test@example.com',
1414
- });
1415
- // First send fills the buffer
1416
- const first = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1417
- expect(first.errors).toHaveLength(0);
1418
- // Second send hits the full buffer — must report an error
1419
- const second = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1420
- expect(second.errors).toHaveLength(1);
1421
- expect(second.errors[0]?.error).toBeInstanceOf(TransportSendError);
1422
- });
1423
- it('should report buffer-full drop via onEnqueueError on initial send', async () => {
1424
- const onEnqueueError = mock(async () => { });
1425
- const hooksInstance = new SafeHooks({
1426
- onEnqueueError,
1427
- });
1428
- const failingTransport = {
1429
- ...transport,
1430
- send: mock(async () => {
1431
- throw new Error('Connection lost');
1432
- }),
1433
- };
1434
- const subscriber = createSubscriber({
1435
- name: 'handle-user',
1436
- description: 'Handles user events',
1437
- callback: async () => { },
1438
- });
1439
- schema.register(UserCreatedEvent, [subscriber]);
1440
- const fanoutWithSmallBuffer = new FanoutEngine({
1441
- transport: failingTransport,
1442
- schema,
1443
- hooks: hooksInstance,
1444
- topology: testTopology,
1445
- defaultQueue: 'events',
1446
- maxRetryBufferSize: 1,
1447
- });
1448
- const event = new UserCreatedEvent({
1449
- userId: '123',
1450
- email: 'test@example.com',
1451
- });
1452
- // First send fills the buffer — no error reported to caller or hook
1453
- const first = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1454
- expect(first.errors).toHaveLength(0);
1455
- expect(onEnqueueError).not.toHaveBeenCalled();
1456
- // Second send finds the buffer full — onEnqueueError fires and error is returned
1457
- const second = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1458
- expect(second.errors).toHaveLength(1);
1459
- expect(onEnqueueError).toHaveBeenCalledTimes(1);
1460
- });
1461
- it('should not buffer when a fallback transport succeeds after the primary fails', async () => {
1462
- const rabbitMock = {
1463
- ...transport,
1464
- name: 'rabbitmq',
1465
- send: mock(async () => {
1466
- throw new Error('RabbitMQ down');
1467
- }),
1468
- };
1469
- const localMock = {
1470
- ...transport,
1471
- name: 'local',
1472
- send: mock(async () => 'local'),
1473
- };
1474
- const multiTransport = new MultiTransport({
1475
- transports: [rabbitMock, localMock],
1476
- });
1477
- const fanoutWithMulti = new FanoutEngine({
1478
- transport: multiTransport,
1479
- schema,
1480
- hooks,
1481
- topology: testTopology,
1482
- defaultQueue: 'events',
1483
- });
1484
- const subscriber = createSubscriber({
1485
- name: 'handle-user',
1486
- description: 'Handles user events',
1487
- callback: async () => { },
1488
- });
1489
- schema.register(UserCreatedEvent, [subscriber]);
1490
- const event = new UserCreatedEvent({
1491
- userId: '123',
1492
- email: 'test@example.com',
1493
- });
1494
- const result = await fanoutWithMulti.send(UserCreatedEvent, event);
1495
- expect(result.subscribersSent).toBe(1);
1496
- expect(result.errors).toHaveLength(0);
1497
- expect(rabbitMock.send).toHaveBeenCalledTimes(1);
1498
- expect(localMock.send).toHaveBeenCalledTimes(1);
1499
- expect(fanoutWithMulti.eventsBeingEnqueuedCount).toBe(0);
1500
- });
1501
- it('should buffer (not report success) when a real MultiTransport falls back to local for a stub subscriber', async () => {
1502
- const stub = createSubscriberStub({ name: 'remote-service' });
1503
- schema.register(UserCreatedEvent, [stub]);
1504
- const rabbitMock = {
1505
- ...transport,
1506
- name: 'rabbitmq',
1507
- send: mock(async () => {
1508
- throw new Error('RabbitMQ down');
1509
- }),
1510
- };
1511
- const localTransport = new LocalTransport();
1512
- await localTransport.connect();
1513
- await localTransport.applyTopology(testTopology);
1514
- const multiTransport = new MultiTransport({
1515
- transports: [rabbitMock, localTransport],
1516
- });
1517
- const onEnqueueSuccess = mock(async () => { });
1518
- const hooksInstance = new SafeHooks({ onEnqueueSuccess });
1519
- const fanoutWithMulti = new FanoutEngine({
1520
- transport: multiTransport,
1521
- schema,
1522
- hooks: hooksInstance,
1523
- topology: testTopology,
1524
- defaultQueue: 'events',
1525
- // Fast interval so the test doesn't have to wait on the 30s default
1526
- // to prove the message is actually held for retry, not dropped.
1527
- retryIntervalMs: 10,
1528
- });
1529
- const event = new UserCreatedEvent({
1530
- userId: '123',
1531
- email: 'test@example.com',
1532
- });
1533
- const result = await fanoutWithMulti.send(UserCreatedEvent, event);
1534
- expect(result.subscribersSent).toBe(0);
1535
- expect(result.errors).toHaveLength(0);
1536
- expect(onEnqueueSuccess).not.toHaveBeenCalled();
1537
- expect(rabbitMock.send).toHaveBeenCalledTimes(1);
1538
- // Neither rabbitmq nor local ever stored the message durably (rabbit
1539
- // threw, local's enqueue() throws with no subscriber), so it can only
1540
- // still be in memory if FanoutEngine actually buffered it. Prove that
1541
- // by attaching a local subscriber and letting the periodic flush retry.
1542
- const received = [];
1543
- await localTransport.subscribe(resolveTargetQueueName(testTopology, 'events'), async (envelope) => {
1544
- received.push(envelope);
1545
- });
1546
- await new Promise((resolve) => setTimeout(resolve, 30));
1547
- expect(received).toHaveLength(1);
1548
- expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
1549
- fanoutWithMulti.dispose();
1550
- });
1551
- it('should re-buffer a stub message that falls back to local again on flush, then deliver once the primary recovers', async () => {
1552
- const stub = createSubscriberStub({ name: 'remote-service' });
1553
- schema.register(UserCreatedEvent, [stub]);
1554
- let rabbitUp = false;
1555
- let connectedCallback;
1556
- const rabbitMock = {
1557
- ...transport,
1558
- name: 'rabbitmq',
1559
- send: mock(async () => {
1560
- if (!rabbitUp)
1561
- throw new Error('RabbitMQ down');
1562
- return 'rabbitmq';
1563
- }),
1564
- onConnected: (cb) => {
1565
- connectedCallback = cb;
1566
- return () => { };
1567
- },
1568
- };
1569
- const localTransport = new LocalTransport();
1570
- await localTransport.connect();
1571
- await localTransport.applyTopology(testTopology);
1572
- const multiTransport = new MultiTransport({
1573
- transports: [rabbitMock, localTransport],
1574
- });
1575
- const onEnqueueSuccess = mock(async () => { });
1576
- const hooksInstance = new SafeHooks({ onEnqueueSuccess });
1577
- const fanoutWithMulti = new FanoutEngine({
1578
- transport: multiTransport,
1579
- schema,
1580
- hooks: hooksInstance,
1581
- topology: testTopology,
1582
- defaultQueue: 'events',
1583
- });
1584
- const event = new UserCreatedEvent({
1585
- userId: '123',
1586
- email: 'test@example.com',
1587
- });
1588
- // Initial send: rabbit down, falls back to local, buffered (not delivered).
1589
- await fanoutWithMulti.send(UserCreatedEvent, event);
1590
- expect(onEnqueueSuccess).not.toHaveBeenCalled();
1591
- // Reconnect fires while rabbit is still down — flush falls back to local
1592
- // again and must re-buffer, not report success.
1593
- connectedCallback?.();
1594
- await waitForFlush(fanoutWithMulti);
1595
- expect(onEnqueueSuccess).not.toHaveBeenCalled();
1596
- // Rabbit recovers — next flush should deliver for real via rabbitmq.
1597
- rabbitUp = true;
1598
- connectedCallback?.();
1599
- await waitForFlush(fanoutWithMulti);
1600
- expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
1601
- expect(onEnqueueSuccess).toHaveBeenCalledWith(expect.objectContaining({ transport: 'rabbitmq' }));
1602
- });
1603
- it('should retry via the periodic interval even without a reconnect event', async () => {
1604
- // Covers the case where the transport stays connected the whole time
1605
- // but an individual publish keeps failing — onConnected never fires
1606
- // again for that, so only the interval can recover it.
1607
- const subscriber = createSubscriber({
1608
- name: 'handle-user',
1609
- description: 'Handles user events',
1610
- callback: async () => { },
1611
- });
1612
- schema.register(UserCreatedEvent, [subscriber]);
1613
- let shouldFail = true;
1614
- const flakyTransport = {
1615
- ...transport,
1616
- send: mock(async () => {
1617
- if (shouldFail)
1618
- throw new Error('Publish nacked');
1619
- return 'mock';
1620
- }),
1621
- // Deliberately no onConnected — the transport never disconnects.
1622
- };
1623
- const fanoutWithInterval = new FanoutEngine({
1624
- transport: flakyTransport,
1625
- schema,
1626
- hooks,
1627
- topology: testTopology,
1628
- defaultQueue: 'events',
1629
- retryIntervalMs: 20,
1630
- });
1631
- const event = new UserCreatedEvent({
1632
- userId: '123',
1633
- email: 'test@example.com',
1634
- });
1635
- await fanoutWithInterval.send(UserCreatedEvent, event);
1636
- expect(flakyTransport.send).toHaveBeenCalledTimes(1);
1637
- shouldFail = false;
1638
- await new Promise((resolve) => setTimeout(resolve, 50));
1639
- expect(flakyTransport.send).toHaveBeenCalledTimes(2);
1640
- fanoutWithInterval.dispose();
1641
- });
1642
- it('should stop retrying the rest of the buffer after 10 consecutive failures in a flush pass', async () => {
1643
- let connectedCallback;
1644
- let shouldFail = true;
1645
- const flakyTransport = {
1646
- ...transport,
1647
- send: mock(async () => {
1648
- if (shouldFail)
1649
- throw new Error('Broker rejecting everything');
1650
- return 'mock';
1651
- }),
1652
- onConnected: (cb) => {
1653
- connectedCallback = cb;
1654
- return () => { };
1655
- },
1656
- };
1657
- const fanoutWithReconnect = new FanoutEngine({
1658
- transport: flakyTransport,
1659
- schema,
1660
- hooks,
1661
- topology: testTopology,
1662
- defaultQueue: 'events',
1663
- });
1664
- const subscriber = createSubscriber({
1665
- name: 'handle-user',
1666
- description: 'Handles user events',
1667
- callback: async () => { },
1668
- });
1669
- schema.register(UserCreatedEvent, [subscriber]);
1670
- const event = new UserCreatedEvent({
1671
- userId: '123',
1672
- email: 'test@example.com',
1673
- });
1674
- // 12 sends buffered; flush bails after the 10th consecutive failure,
1675
- // leaving 2 untried.
1676
- for (let i = 0; i < 12; i++) {
1677
- await fanoutWithReconnect.send(UserCreatedEvent, event);
1678
- }
1679
- expect(flakyTransport.send).toHaveBeenCalledTimes(12);
1680
- connectedCallback?.();
1681
- await waitForFlush(fanoutWithReconnect);
1682
- expect(flakyTransport.send).toHaveBeenCalledTimes(22);
1683
- // Recovery: next flush delivers all 12 re-buffered/untried items.
1684
- shouldFail = false;
1685
- connectedCallback?.();
1686
- await waitForFlush(fanoutWithReconnect);
1687
- expect(flakyTransport.send).toHaveBeenCalledTimes(34);
1688
- });
1689
- it('should respect a custom maxConsecutiveFlushFailures threshold', async () => {
1690
- let connectedCallback;
1691
- const flakyTransport = {
1692
- ...transport,
1693
- send: mock(async () => {
1694
- throw new Error('Broker rejecting everything');
1695
- }),
1696
- onConnected: (cb) => {
1697
- connectedCallback = cb;
1698
- return () => { };
1699
- },
1700
- };
1701
- const fanoutWithCustomThreshold = new FanoutEngine({
1702
- transport: flakyTransport,
1703
- schema,
1704
- hooks,
1705
- topology: testTopology,
1706
- defaultQueue: 'events',
1707
- maxConsecutiveFlushFailures: 3,
1708
- });
1709
- const subscriber = createSubscriber({
1710
- name: 'handle-user',
1711
- description: 'Handles user events',
1712
- callback: async () => { },
1713
- });
1714
- schema.register(UserCreatedEvent, [subscriber]);
1715
- const event = new UserCreatedEvent({
1716
- userId: '123',
1717
- email: 'test@example.com',
1718
- });
1719
- // 5 sends buffered; flush should bail after the 3rd consecutive
1720
- // failure, leaving 2 untried, instead of the default threshold of 10.
1721
- for (let i = 0; i < 5; i++) {
1722
- await fanoutWithCustomThreshold.send(UserCreatedEvent, event);
1723
- }
1724
- expect(flakyTransport.send).toHaveBeenCalledTimes(5);
1725
- connectedCallback?.();
1726
- await waitForFlush(fanoutWithCustomThreshold);
1727
- expect(flakyTransport.send).toHaveBeenCalledTimes(8);
1728
- });
1729
- it('should never bail out of a flush pass when maxConsecutiveFlushFailures is disabled (0)', async () => {
1730
- let connectedCallback;
1731
- const flakyTransport = {
1732
- ...transport,
1733
- send: mock(async () => {
1734
- throw new Error('Broker rejecting everything');
1735
- }),
1736
- onConnected: (cb) => {
1737
- connectedCallback = cb;
1738
- return () => { };
1739
- },
1740
- };
1741
- const fanoutWithBreakerDisabled = new FanoutEngine({
1742
- transport: flakyTransport,
1743
- schema,
1744
- hooks,
1745
- topology: testTopology,
1746
- defaultQueue: 'events',
1747
- maxConsecutiveFlushFailures: 0,
1748
- });
1749
- const subscriber = createSubscriber({
1750
- name: 'handle-user',
1751
- description: 'Handles user events',
1752
- callback: async () => { },
1753
- });
1754
- schema.register(UserCreatedEvent, [subscriber]);
1755
- const event = new UserCreatedEvent({
1756
- userId: '123',
1757
- email: 'test@example.com',
1758
- });
1759
- // 15 sends buffered, well past the default breaker threshold of 10;
1760
- // with the breaker disabled every single one should still be attempted.
1761
- for (let i = 0; i < 15; i++) {
1762
- await fanoutWithBreakerDisabled.send(UserCreatedEvent, event);
1763
- }
1764
- expect(flakyTransport.send).toHaveBeenCalledTimes(15);
1765
- connectedCallback?.();
1766
- await waitForFlush(fanoutWithBreakerDisabled);
1767
- expect(flakyTransport.send).toHaveBeenCalledTimes(30);
1768
- });
1769
- it('should not trip the failure breaker on isolated failures interspersed with successes', async () => {
1770
- let connectedCallback;
1771
- let phase = 'buffering';
1772
- let flushCallIndex = 0;
1773
- const onEnqueueSuccess = mock(async () => { });
1774
- const hooksInstance = new SafeHooks({ onEnqueueSuccess });
1775
- const mostlyHealthyTransport = {
1776
- ...transport,
1777
- send: mock(async () => {
1778
- if (phase === 'buffering') {
1779
- throw new Error('Initially down');
1780
- }
1781
- flushCallIndex++;
1782
- // Every 3rd flushed item fails — isolated, never two in a row.
1783
- if (flushCallIndex % 3 === 0)
1784
- throw new Error('Occasional nack');
1785
- return 'mock';
1786
- }),
1787
- onConnected: (cb) => {
1788
- connectedCallback = cb;
1789
- return () => { };
1790
- },
1791
- };
1792
- const fanoutWithReconnect = new FanoutEngine({
1793
- transport: mostlyHealthyTransport,
1794
- schema,
1795
- hooks: hooksInstance,
1796
- topology: testTopology,
1797
- defaultQueue: 'events',
1798
- });
1799
- const subscriber = createSubscriber({
1800
- name: 'handle-user',
1801
- description: 'Handles user events',
1802
- callback: async () => { },
1803
- });
1804
- schema.register(UserCreatedEvent, [subscriber]);
1805
- const event = new UserCreatedEvent({
1806
- userId: '123',
1807
- email: 'test@example.com',
1808
- });
1809
- for (let i = 0; i < 9; i++) {
1810
- await fanoutWithReconnect.send(UserCreatedEvent, event);
1811
- }
1812
- expect(mostlyHealthyTransport.send).toHaveBeenCalledTimes(9);
1813
- // Every 3rd flushed item fails, but never two in a row, so the
1814
- // breaker (threshold 10) never trips and all 9 get attempted.
1815
- phase = 'flushing';
1816
- connectedCallback?.();
1817
- await waitForFlush(fanoutWithReconnect);
1818
- expect(mostlyHealthyTransport.send).toHaveBeenCalledTimes(9 + 9);
1819
- expect(onEnqueueSuccess).toHaveBeenCalledTimes(9 - 3);
1820
- });
1821
- it('should drop untried messages that no longer fit once maxRetryBufferSize is hit by concurrent buffering', async () => {
1822
- let connectedCallback;
1823
- let phase = 'buffering';
1824
- let flushCallIndex = 0;
1825
- let triggeredConcurrentBuffering = false;
1826
- let fanoutWithReconnect;
1827
- const onEnqueueError = mock(async () => { });
1828
- const hooksInstance = new SafeHooks({ onEnqueueError });
1829
- const subscriber = createSubscriber({
1830
- name: 'handle-user',
1831
- description: 'Handles user events',
1832
- callback: async () => { },
1833
- });
1834
- schema.register(UserCreatedEvent, [subscriber]);
1835
- const event = new UserCreatedEvent({
1836
- userId: '123',
1837
- email: 'test@example.com',
1838
- });
1839
- const flakyTransport = {
1840
- ...transport,
1841
- send: mock(async () => {
1842
- if (phase === 'buffering') {
1843
- throw new Error('Initially down');
1844
- }
1845
- flushCallIndex++;
1846
- // Simulate a concurrent send() buffering more items mid-flush.
1847
- if (flushCallIndex === 5 && !triggeredConcurrentBuffering) {
1848
- triggeredConcurrentBuffering = true;
1849
- for (let i = 0; i < 4; i++) {
1850
- await fanoutWithReconnect.send(UserCreatedEvent, event);
1851
- }
1852
- }
1853
- throw new Error('Still down');
1854
- }),
1855
- onConnected: (cb) => {
1856
- connectedCallback = cb;
1857
- return () => { };
1858
- },
1859
- };
1860
- fanoutWithReconnect = new FanoutEngine({
1861
- transport: flakyTransport,
1862
- schema,
1863
- hooks: hooksInstance,
1864
- topology: testTopology,
1865
- defaultQueue: 'events',
1866
- maxRetryBufferSize: 13,
1867
- });
1868
- // 11 sends buffered under the cap of 13 — nothing dropped yet.
1869
- for (let i = 0; i < 11; i++) {
1870
- await fanoutWithReconnect.send(UserCreatedEvent, event);
1871
- }
1872
- expect(flakyTransport.send).toHaveBeenCalledTimes(11);
1873
- // Flush: 10 consecutive failures trip the breaker, leaving the 11th
1874
- // item untried. The 4 concurrently-buffered items (injected mid-pass)
1875
- // fill the buffer to its cap of 13 by the time the streak trips, so
1876
- // both the 10th flush item (existing buffer-full path) and the
1877
- // untried 11th item (rebufferUntried's buffer-full path) get dropped.
1878
- phase = 'flushing';
1879
- connectedCallback?.();
1880
- await waitForFlush(fanoutWithReconnect);
1881
- // 11 initial + 10 flush-loop attempts + 4 injected.
1882
- expect(flakyTransport.send).toHaveBeenCalledTimes(11 + 10 + 4);
1883
- expect(onEnqueueError).toHaveBeenCalledTimes(2);
1884
- });
1885
- it('should stop the periodic retry timer after dispose()', async () => {
1886
- const subscriber = createSubscriber({
1887
- name: 'handle-user',
1888
- description: 'Handles user events',
1889
- callback: async () => { },
1890
- });
1891
- schema.register(UserCreatedEvent, [subscriber]);
1892
- const failingTransport = {
1893
- ...transport,
1894
- send: mock(async () => {
1895
- throw new Error('Connection lost');
1896
- }),
1897
- };
1898
- const fanoutWithInterval = new FanoutEngine({
1899
- transport: failingTransport,
1900
- schema,
1901
- hooks,
1902
- topology: testTopology,
1903
- defaultQueue: 'events',
1904
- retryIntervalMs: 20,
1905
- });
1906
- const event = new UserCreatedEvent({
1907
- userId: '123',
1908
- email: 'test@example.com',
1909
- });
1910
- await fanoutWithInterval.send(UserCreatedEvent, event);
1911
- expect(failingTransport.send).toHaveBeenCalledTimes(1);
1912
- fanoutWithInterval.dispose();
1913
- await new Promise((resolve) => setTimeout(resolve, 50));
1914
- // No further flush attempts after dispose, despite the buffered message.
1915
- expect(failingTransport.send).toHaveBeenCalledTimes(1);
1916
- });
1917
- it('should drop a message after exceeding maxRetryAttempts instead of retrying forever', async () => {
1918
- const subscriber = createSubscriber({
1919
- name: 'handle-user',
1920
- description: 'Handles user events',
1921
- callback: async () => { },
1922
- });
1923
- schema.register(UserCreatedEvent, [subscriber]);
1924
- let connectedCallback;
1925
- const failingTransport = {
1926
- ...transport,
1927
- send: mock(async () => {
1928
- throw new Error('Still down');
1929
- }),
1930
- onConnected: (cb) => {
1931
- connectedCallback = cb;
1932
- return () => { };
1933
- },
1934
- };
1935
- const onEnqueueError = mock(async () => { });
1936
- const hooksInstance = new SafeHooks({
1937
- onEnqueueError,
1938
- });
1939
- const fanoutWithMaxAttempts = new FanoutEngine({
1940
- transport: failingTransport,
1941
- schema,
1942
- hooks: hooksInstance,
1943
- topology: testTopology,
1944
- defaultQueue: 'events',
1945
- maxRetryAttempts: 2,
1946
- });
1947
- const event = new UserCreatedEvent({
1948
- userId: '123',
1949
- email: 'test@example.com',
1950
- });
1951
- // Initial send fails and buffers (attempts: 0).
1952
- await fanoutWithMaxAttempts.send(UserCreatedEvent, event);
1953
- expect(onEnqueueError).not.toHaveBeenCalled();
1954
- // First flush fails: attempts becomes 1, still under the cap, re-buffered.
1955
- connectedCallback?.();
1956
- await waitForFlush(fanoutWithMaxAttempts);
1957
- expect(onEnqueueError).not.toHaveBeenCalled();
1958
- // Second flush fails: attempts becomes 2, meets the cap, dropped for good.
1959
- connectedCallback?.();
1960
- await waitForFlush(fanoutWithMaxAttempts);
1961
- expect(onEnqueueError).toHaveBeenCalledTimes(1);
1962
- // A further reconnect must not retry it again — it's gone.
1963
- connectedCallback?.();
1964
- await waitForFlush(fanoutWithMaxAttempts);
1965
- expect(onEnqueueError).toHaveBeenCalledTimes(1);
1966
- // 1 initial + 2 flush attempts = 3 total send() calls; no 4th.
1967
- expect(failingTransport.send).toHaveBeenCalledTimes(3);
1968
- });
1969
- });
1970
- });