@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.
- package/dist/core/matador.d.ts.map +1 -1
- package/dist/core/matador.js +4 -1
- package/dist/index.cjs +156 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline/pipeline.d.ts +30 -2
- package/dist/pipeline/pipeline.d.ts.map +1 -1
- package/dist/pipeline/pipeline.js +65 -9
- package/dist/retry/index.d.ts +1 -1
- package/dist/retry/index.d.ts.map +1 -1
- package/dist/retry/policy.d.ts +27 -0
- package/dist/retry/policy.d.ts.map +1 -1
- package/dist/retry/standard-policy.d.ts +33 -2
- package/dist/retry/standard-policy.d.ts.map +1 -1
- package/dist/retry/standard-policy.js +73 -9
- package/dist/topology/types.d.ts +14 -0
- package/dist/topology/types.d.ts.map +1 -1
- package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -1
- package/dist/transport/rabbitmq/rabbitmq-transport.js +3 -0
- package/package.json +1 -1
- package/dist/checkpoint/context.test.d.ts +0 -2
- package/dist/checkpoint/context.test.d.ts.map +0 -1
- package/dist/checkpoint/context.test.js +0 -428
- package/dist/checkpoint/stores/stores.test.d.ts +0 -2
- package/dist/checkpoint/stores/stores.test.d.ts.map +0 -1
- package/dist/checkpoint/stores/stores.test.js +0 -146
- package/dist/codec/rabbitmq-codec.test.d.ts +0 -2
- package/dist/codec/rabbitmq-codec.test.d.ts.map +0 -1
- package/dist/codec/rabbitmq-codec.test.js +0 -428
- package/dist/core/fanout.test.d.ts +0 -2
- package/dist/core/fanout.test.d.ts.map +0 -1
- package/dist/core/fanout.test.js +0 -1970
- package/dist/core/matador.test.d.ts +0 -2
- package/dist/core/matador.test.d.ts.map +0 -1
- package/dist/core/matador.test.js +0 -813
- package/dist/core/shutdown.test.d.ts +0 -2
- package/dist/core/shutdown.test.d.ts.map +0 -1
- package/dist/core/shutdown.test.js +0 -599
- package/dist/errors/retry-errors.test.d.ts +0 -2
- package/dist/errors/retry-errors.test.d.ts.map +0 -1
- package/dist/errors/retry-errors.test.js +0 -136
- package/dist/pipeline/pipeline.test.d.ts +0 -2
- package/dist/pipeline/pipeline.test.d.ts.map +0 -1
- package/dist/pipeline/pipeline.test.js +0 -1124
- package/dist/retry/standard-policy.test.d.ts +0 -2
- package/dist/retry/standard-policy.test.d.ts.map +0 -1
- package/dist/retry/standard-policy.test.js +0 -196
- package/dist/schema/registry.test.d.ts +0 -2
- package/dist/schema/registry.test.d.ts.map +0 -1
- package/dist/schema/registry.test.js +0 -278
- package/dist/schema/types.test.d.ts +0 -2
- package/dist/schema/types.test.d.ts.map +0 -1
- package/dist/schema/types.test.js +0 -246
- package/dist/topology/builder.test.d.ts +0 -2
- package/dist/topology/builder.test.d.ts.map +0 -1
- package/dist/topology/builder.test.js +0 -547
- package/dist/transport/local/local-transport.test.d.ts +0 -2
- package/dist/transport/local/local-transport.test.d.ts.map +0 -1
- package/dist/transport/local/local-transport.test.js +0 -232
- package/dist/transport/multi/multi-transport.test.d.ts +0 -2
- package/dist/transport/multi/multi-transport.test.d.ts.map +0 -1
- package/dist/transport/multi/multi-transport.test.js +0 -322
- package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.d.ts +0 -2
- package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.d.ts.map +0 -1
- package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.js +0 -149
- package/dist/transport/rabbitmq/rabbitmq-transport.test.d.ts +0 -2
- package/dist/transport/rabbitmq/rabbitmq-transport.test.d.ts.map +0 -1
- package/dist/transport/rabbitmq/rabbitmq-transport.test.js +0 -230
- package/dist/types/event.test.d.ts +0 -2
- package/dist/types/event.test.d.ts.map +0 -1
- package/dist/types/event.test.js +0 -130
package/dist/core/fanout.test.js
DELETED
|
@@ -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
|
-
});
|