@zdavison/matador 2.0.1

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 (256) hide show
  1. package/cli.ts +453 -0
  2. package/dist/checkpoint/context.d.ts +59 -0
  3. package/dist/checkpoint/context.d.ts.map +1 -0
  4. package/dist/checkpoint/context.js +140 -0
  5. package/dist/checkpoint/context.test.d.ts +2 -0
  6. package/dist/checkpoint/context.test.d.ts.map +1 -0
  7. package/dist/checkpoint/context.test.js +424 -0
  8. package/dist/checkpoint/index.d.ts +7 -0
  9. package/dist/checkpoint/index.d.ts.map +1 -0
  10. package/dist/checkpoint/index.js +6 -0
  11. package/dist/checkpoint/stores/memory.d.ts +29 -0
  12. package/dist/checkpoint/stores/memory.d.ts.map +1 -0
  13. package/dist/checkpoint/stores/memory.js +39 -0
  14. package/dist/checkpoint/stores/noop.d.ts +14 -0
  15. package/dist/checkpoint/stores/noop.d.ts.map +1 -0
  16. package/dist/checkpoint/stores/noop.js +18 -0
  17. package/dist/checkpoint/stores/stores.test.d.ts +2 -0
  18. package/dist/checkpoint/stores/stores.test.d.ts.map +1 -0
  19. package/dist/checkpoint/stores/stores.test.js +146 -0
  20. package/dist/checkpoint/types.d.ts +119 -0
  21. package/dist/checkpoint/types.d.ts.map +1 -0
  22. package/dist/checkpoint/types.js +1 -0
  23. package/dist/codec/codec.d.ts +29 -0
  24. package/dist/codec/codec.d.ts.map +1 -0
  25. package/dist/codec/codec.js +15 -0
  26. package/dist/codec/header-aware-codec.d.ts +36 -0
  27. package/dist/codec/header-aware-codec.d.ts.map +1 -0
  28. package/dist/codec/header-aware-codec.js +1 -0
  29. package/dist/codec/index.d.ts +6 -0
  30. package/dist/codec/index.d.ts.map +1 -0
  31. package/dist/codec/index.js +3 -0
  32. package/dist/codec/json-codec.d.ts +13 -0
  33. package/dist/codec/json-codec.d.ts.map +1 -0
  34. package/dist/codec/json-codec.js +64 -0
  35. package/dist/codec/rabbitmq-codec.d.ts +28 -0
  36. package/dist/codec/rabbitmq-codec.d.ts.map +1 -0
  37. package/dist/codec/rabbitmq-codec.js +242 -0
  38. package/dist/codec/rabbitmq-codec.test.d.ts +2 -0
  39. package/dist/codec/rabbitmq-codec.test.d.ts.map +1 -0
  40. package/dist/codec/rabbitmq-codec.test.js +433 -0
  41. package/dist/core/fanout.d.ts +59 -0
  42. package/dist/core/fanout.d.ts.map +1 -0
  43. package/dist/core/fanout.js +121 -0
  44. package/dist/core/fanout.test.d.ts +2 -0
  45. package/dist/core/fanout.test.d.ts.map +1 -0
  46. package/dist/core/fanout.test.js +1055 -0
  47. package/dist/core/index.d.ts +7 -0
  48. package/dist/core/index.d.ts.map +1 -0
  49. package/dist/core/index.js +3 -0
  50. package/dist/core/matador.d.ts +133 -0
  51. package/dist/core/matador.d.ts.map +1 -0
  52. package/dist/core/matador.js +222 -0
  53. package/dist/core/matador.test.d.ts +2 -0
  54. package/dist/core/matador.test.d.ts.map +1 -0
  55. package/dist/core/matador.test.js +446 -0
  56. package/dist/core/shutdown.d.ts +78 -0
  57. package/dist/core/shutdown.d.ts.map +1 -0
  58. package/dist/core/shutdown.js +111 -0
  59. package/dist/core/shutdown.test.d.ts +2 -0
  60. package/dist/core/shutdown.test.d.ts.map +1 -0
  61. package/dist/core/shutdown.test.js +524 -0
  62. package/dist/errors/checkpoint-errors.d.ts +30 -0
  63. package/dist/errors/checkpoint-errors.d.ts.map +1 -0
  64. package/dist/errors/checkpoint-errors.js +49 -0
  65. package/dist/errors/has-description.d.ts +18 -0
  66. package/dist/errors/has-description.d.ts.map +1 -0
  67. package/dist/errors/has-description.js +9 -0
  68. package/dist/errors/index.d.ts +6 -0
  69. package/dist/errors/index.d.ts.map +1 -0
  70. package/dist/errors/index.js +22 -0
  71. package/dist/errors/matador-errors.d.ts +183 -0
  72. package/dist/errors/matador-errors.d.ts.map +1 -0
  73. package/dist/errors/matador-errors.js +376 -0
  74. package/dist/errors/retry-errors.d.ts +67 -0
  75. package/dist/errors/retry-errors.d.ts.map +1 -0
  76. package/dist/errors/retry-errors.js +108 -0
  77. package/dist/hooks/index.d.ts +4 -0
  78. package/dist/hooks/index.d.ts.map +1 -0
  79. package/dist/hooks/index.js +2 -0
  80. package/dist/hooks/safe-hooks.d.ts +34 -0
  81. package/dist/hooks/safe-hooks.d.ts.map +1 -0
  82. package/dist/hooks/safe-hooks.js +135 -0
  83. package/dist/hooks/types.d.ts +189 -0
  84. package/dist/hooks/types.d.ts.map +1 -0
  85. package/dist/hooks/types.js +9 -0
  86. package/dist/index.cjs +3156 -0
  87. package/dist/index.cjs.map +1 -0
  88. package/dist/index.d.cts +23 -0
  89. package/dist/index.d.ts +23 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +31 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/pipeline/index.d.ts +3 -0
  94. package/dist/pipeline/index.d.ts.map +1 -0
  95. package/dist/pipeline/index.js +1 -0
  96. package/dist/pipeline/pipeline.d.ts +60 -0
  97. package/dist/pipeline/pipeline.d.ts.map +1 -0
  98. package/dist/pipeline/pipeline.js +261 -0
  99. package/dist/pipeline/pipeline.test.d.ts +2 -0
  100. package/dist/pipeline/pipeline.test.d.ts.map +1 -0
  101. package/dist/pipeline/pipeline.test.js +1065 -0
  102. package/dist/retry/index.d.ts +4 -0
  103. package/dist/retry/index.d.ts.map +1 -0
  104. package/dist/retry/index.js +1 -0
  105. package/dist/retry/policy.d.ts +43 -0
  106. package/dist/retry/policy.d.ts.map +1 -0
  107. package/dist/retry/policy.js +1 -0
  108. package/dist/retry/standard-policy.d.ts +44 -0
  109. package/dist/retry/standard-policy.d.ts.map +1 -0
  110. package/dist/retry/standard-policy.js +102 -0
  111. package/dist/retry/standard-policy.test.d.ts +2 -0
  112. package/dist/retry/standard-policy.test.d.ts.map +1 -0
  113. package/dist/retry/standard-policy.test.js +190 -0
  114. package/dist/schema/index.d.ts +4 -0
  115. package/dist/schema/index.d.ts.map +1 -0
  116. package/dist/schema/index.js +2 -0
  117. package/dist/schema/registry.d.ts +63 -0
  118. package/dist/schema/registry.d.ts.map +1 -0
  119. package/dist/schema/registry.js +171 -0
  120. package/dist/schema/registry.test.d.ts +2 -0
  121. package/dist/schema/registry.test.d.ts.map +1 -0
  122. package/dist/schema/registry.test.js +278 -0
  123. package/dist/schema/types.d.ts +158 -0
  124. package/dist/schema/types.d.ts.map +1 -0
  125. package/dist/schema/types.js +74 -0
  126. package/dist/schema/types.test.d.ts +2 -0
  127. package/dist/schema/types.test.d.ts.map +1 -0
  128. package/dist/schema/types.test.js +243 -0
  129. package/dist/topology/builder.d.ts +66 -0
  130. package/dist/topology/builder.d.ts.map +1 -0
  131. package/dist/topology/builder.js +156 -0
  132. package/dist/topology/builder.test.d.ts +2 -0
  133. package/dist/topology/builder.test.d.ts.map +1 -0
  134. package/dist/topology/builder.test.js +222 -0
  135. package/dist/topology/index.d.ts +5 -0
  136. package/dist/topology/index.d.ts.map +1 -0
  137. package/dist/topology/index.js +2 -0
  138. package/dist/topology/types.d.ts +76 -0
  139. package/dist/topology/types.d.ts.map +1 -0
  140. package/dist/topology/types.js +18 -0
  141. package/dist/transport/capabilities.d.ts +66 -0
  142. package/dist/transport/capabilities.d.ts.map +1 -0
  143. package/dist/transport/capabilities.js +18 -0
  144. package/dist/transport/connection-manager.d.ts +95 -0
  145. package/dist/transport/connection-manager.d.ts.map +1 -0
  146. package/dist/transport/connection-manager.js +144 -0
  147. package/dist/transport/index.d.ts +11 -0
  148. package/dist/transport/index.d.ts.map +1 -0
  149. package/dist/transport/index.js +5 -0
  150. package/dist/transport/local/local-transport.d.ts +62 -0
  151. package/dist/transport/local/local-transport.d.ts.map +1 -0
  152. package/dist/transport/local/local-transport.js +241 -0
  153. package/dist/transport/local/local-transport.test.d.ts +2 -0
  154. package/dist/transport/local/local-transport.test.d.ts.map +1 -0
  155. package/dist/transport/local/local-transport.test.js +192 -0
  156. package/dist/transport/multi/multi-transport.d.ts +94 -0
  157. package/dist/transport/multi/multi-transport.d.ts.map +1 -0
  158. package/dist/transport/multi/multi-transport.js +184 -0
  159. package/dist/transport/multi/multi-transport.test.d.ts +2 -0
  160. package/dist/transport/multi/multi-transport.test.d.ts.map +1 -0
  161. package/dist/transport/multi/multi-transport.test.js +236 -0
  162. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts +73 -0
  163. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -0
  164. package/dist/transport/rabbitmq/rabbitmq-transport.js +518 -0
  165. package/dist/transport/transport.d.ts +152 -0
  166. package/dist/transport/transport.d.ts.map +1 -0
  167. package/dist/transport/transport.js +1 -0
  168. package/dist/types/common.d.ts +41 -0
  169. package/dist/types/common.d.ts.map +1 -0
  170. package/dist/types/common.js +12 -0
  171. package/dist/types/envelope.d.ts +82 -0
  172. package/dist/types/envelope.d.ts.map +1 -0
  173. package/dist/types/envelope.js +36 -0
  174. package/dist/types/event.d.ts +96 -0
  175. package/dist/types/event.d.ts.map +1 -0
  176. package/dist/types/event.js +26 -0
  177. package/dist/types/event.test.d.ts +2 -0
  178. package/dist/types/event.test.d.ts.map +1 -0
  179. package/dist/types/event.test.js +130 -0
  180. package/dist/types/index.d.ts +9 -0
  181. package/dist/types/index.d.ts.map +1 -0
  182. package/dist/types/index.js +4 -0
  183. package/dist/types/subscriber.d.ts +207 -0
  184. package/dist/types/subscriber.d.ts.map +1 -0
  185. package/dist/types/subscriber.js +99 -0
  186. package/examples/config.ts +126 -0
  187. package/examples/event.ts +26 -0
  188. package/examples/order-event.json +19 -0
  189. package/package.json +66 -0
  190. package/src/checkpoint/context.test.ts +510 -0
  191. package/src/checkpoint/context.ts +213 -0
  192. package/src/checkpoint/index.ts +30 -0
  193. package/src/checkpoint/stores/memory.ts +47 -0
  194. package/src/checkpoint/stores/noop.ts +22 -0
  195. package/src/checkpoint/stores/stores.test.ts +177 -0
  196. package/src/checkpoint/types.ts +147 -0
  197. package/src/codec/codec.ts +42 -0
  198. package/src/codec/header-aware-codec.ts +41 -0
  199. package/src/codec/index.ts +11 -0
  200. package/src/codec/json-codec.ts +69 -0
  201. package/src/codec/rabbitmq-codec.test.ts +516 -0
  202. package/src/codec/rabbitmq-codec.ts +336 -0
  203. package/src/core/fanout.test.ts +1351 -0
  204. package/src/core/fanout.ts +184 -0
  205. package/src/core/index.ts +12 -0
  206. package/src/core/matador.test.ts +575 -0
  207. package/src/core/matador.ts +344 -0
  208. package/src/core/shutdown.test.ts +853 -0
  209. package/src/core/shutdown.ts +165 -0
  210. package/src/errors/checkpoint-errors.ts +62 -0
  211. package/src/errors/has-description.ts +25 -0
  212. package/src/errors/index.ts +57 -0
  213. package/src/errors/matador-errors.ts +477 -0
  214. package/src/errors/retry-errors.ts +134 -0
  215. package/src/hooks/index.ts +15 -0
  216. package/src/hooks/safe-hooks.ts +223 -0
  217. package/src/hooks/types.ts +248 -0
  218. package/src/index.ts +231 -0
  219. package/src/pipeline/index.ts +2 -0
  220. package/src/pipeline/pipeline.test.ts +1317 -0
  221. package/src/pipeline/pipeline.ts +371 -0
  222. package/src/retry/index.ts +4 -0
  223. package/src/retry/policy.ts +46 -0
  224. package/src/retry/standard-policy.test.ts +282 -0
  225. package/src/retry/standard-policy.ts +156 -0
  226. package/src/schema/index.ts +16 -0
  227. package/src/schema/registry.test.ts +339 -0
  228. package/src/schema/registry.ts +226 -0
  229. package/src/schema/types.test.ts +281 -0
  230. package/src/schema/types.ts +217 -0
  231. package/src/topology/builder.test.ts +275 -0
  232. package/src/topology/builder.ts +199 -0
  233. package/src/topology/index.ts +15 -0
  234. package/src/topology/types.ts +109 -0
  235. package/src/transport/capabilities.ts +88 -0
  236. package/src/transport/connection-manager.ts +218 -0
  237. package/src/transport/index.ts +42 -0
  238. package/src/transport/local/local-transport.test.ts +262 -0
  239. package/src/transport/local/local-transport.ts +327 -0
  240. package/src/transport/multi/multi-transport.test.ts +320 -0
  241. package/src/transport/multi/multi-transport.ts +294 -0
  242. package/src/transport/rabbitmq/rabbitmq-transport.ts +753 -0
  243. package/src/transport/transport.ts +200 -0
  244. package/src/types/common.ts +53 -0
  245. package/src/types/envelope.ts +152 -0
  246. package/src/types/event.test.ts +157 -0
  247. package/src/types/event.ts +118 -0
  248. package/src/types/index.ts +52 -0
  249. package/src/types/subscriber.ts +310 -0
  250. package/test/e2e/multi-transport.e2e.test.ts +236 -0
  251. package/test/e2e/rabbitmq-transport.e2e.test.ts +327 -0
  252. package/test/e2e/transport-compliance.e2e.test.ts +505 -0
  253. package/test/integration/matador.integration.test.ts +634 -0
  254. package/tsconfig.json +29 -0
  255. package/tsconfig.tsbuildinfo +1 -0
  256. package/tsup.config.ts +13 -0
@@ -0,0 +1,1351 @@
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 type { MatadorHooks } from '../hooks/index.js';
5
+ import { SchemaRegistry } from '../schema/index.js';
6
+ import type { Transport } from '../transport/index.js';
7
+ import {
8
+ MatadorEvent,
9
+ createSubscriber,
10
+ createSubscriberStub,
11
+ } from '../types/index.js';
12
+ import type { Envelope, EventOptions } from '../types/index.js';
13
+ import { FanoutEngine } from './fanout.js';
14
+
15
+ class UserCreatedEvent extends MatadorEvent {
16
+ static readonly key = 'user.created';
17
+ static readonly description = 'Fired when a new user is created';
18
+
19
+ constructor(public data: { userId: string; email: string }) {
20
+ super();
21
+ }
22
+ }
23
+
24
+ class OrderPlacedEvent extends MatadorEvent {
25
+ static readonly key = 'order.placed';
26
+ static readonly description = 'Fired when an order is placed';
27
+
28
+ constructor(public data: { orderId: string; amount: number }) {
29
+ super();
30
+ }
31
+ }
32
+
33
+ class UserCreatedEventWithMetadata extends MatadorEvent {
34
+ static readonly key = 'user.created.with-metadata';
35
+ static readonly description =
36
+ 'Fired when a new user is created (with metadata)';
37
+
38
+ constructor(
39
+ public data: { userId: string; email: string },
40
+ public override metadata?: { source: string; version: number },
41
+ ) {
42
+ super();
43
+ }
44
+ }
45
+
46
+ describe('FanoutEngine', () => {
47
+ let transport: Transport;
48
+ let schema: SchemaRegistry;
49
+ let hooks: SafeHooks;
50
+ let fanout: FanoutEngine;
51
+
52
+ beforeEach(() => {
53
+ schema = new SchemaRegistry();
54
+
55
+ transport = {
56
+ name: 'mock',
57
+ capabilities: {
58
+ deliveryModes: ['at-least-once'],
59
+ delayedMessages: true,
60
+ deadLetterRouting: 'native',
61
+ attemptTracking: false,
62
+ concurrencyModel: 'prefetch',
63
+ ordering: 'none',
64
+ priorities: false,
65
+ },
66
+ isConnected: () => true,
67
+ connect: mock(async () => {}),
68
+ disconnect: mock(async () => {}),
69
+ send: mock(async () => 'mock'),
70
+ subscribe: mock(async () => ({
71
+ unsubscribe: async () => {},
72
+ isActive: true,
73
+ })),
74
+ applyTopology: mock(async () => {}),
75
+ complete: mock(async () => {}),
76
+ };
77
+
78
+ hooks = new SafeHooks();
79
+
80
+ fanout = new FanoutEngine({
81
+ transport,
82
+ schema,
83
+ hooks,
84
+ namespace: 'test',
85
+ defaultQueue: 'events',
86
+ });
87
+ });
88
+
89
+ describe('creation', () => {
90
+ it('should create instance via static factory', () => {
91
+ expect(fanout).toBeInstanceOf(FanoutEngine);
92
+ });
93
+
94
+ it('should create instance via constructor', () => {
95
+ const instance = new FanoutEngine({
96
+ transport,
97
+ schema,
98
+ hooks,
99
+ namespace: 'test',
100
+ defaultQueue: 'events',
101
+ });
102
+ expect(instance).toBeInstanceOf(FanoutEngine);
103
+ });
104
+
105
+ it('should initialize with zero events being enqueued', () => {
106
+ expect(fanout.eventsBeingEnqueuedCount).toBe(0);
107
+ });
108
+ });
109
+
110
+ describe('send() with single subscriber', () => {
111
+ it('should send event to one subscriber', async () => {
112
+ const subscriber = createSubscriber({
113
+ name: 'send-welcome-email',
114
+ description: 'Sends welcome email',
115
+ callback: async () => {},
116
+ });
117
+
118
+ schema.register(UserCreatedEvent, [subscriber]);
119
+
120
+ const event = new UserCreatedEvent({
121
+ userId: '123',
122
+ email: 'test@example.com',
123
+ });
124
+
125
+ const result = await fanout.send(UserCreatedEvent, event);
126
+
127
+ expect(result.eventKey).toBe('user.created');
128
+ expect(result.subscribersSent).toBe(1);
129
+ expect(result.subscribersSkipped).toBe(0);
130
+ expect(result.errors).toHaveLength(0);
131
+ expect(transport.send).toHaveBeenCalledTimes(1);
132
+ });
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
+
141
+ schema.register(UserCreatedEvent, [subscriber]);
142
+
143
+ const event = new UserCreatedEvent({
144
+ userId: '123',
145
+ email: 'test@example.com',
146
+ });
147
+
148
+ await fanout.send(UserCreatedEvent, event);
149
+
150
+ expect(transport.send).toHaveBeenCalledWith(
151
+ 'test.events',
152
+ expect.any(Object),
153
+ undefined,
154
+ );
155
+ });
156
+
157
+ it('should use subscriber targetQueue when specified', async () => {
158
+ const subscriber = createSubscriber({
159
+ name: 'handle-user',
160
+ description: 'Handles user events',
161
+ callback: async () => {},
162
+ targetQueue: 'notifications',
163
+ });
164
+
165
+ schema.register(UserCreatedEvent, [subscriber]);
166
+
167
+ const event = new UserCreatedEvent({
168
+ userId: '123',
169
+ email: 'test@example.com',
170
+ });
171
+
172
+ await fanout.send(UserCreatedEvent, event);
173
+
174
+ expect(transport.send).toHaveBeenCalledWith(
175
+ 'test.notifications',
176
+ expect.any(Object),
177
+ undefined,
178
+ );
179
+ });
180
+
181
+ it('should create envelope with correct structure', async () => {
182
+ const subscriber = createSubscriber({
183
+ name: 'handle-user',
184
+ description: 'Handles user events',
185
+ callback: async () => {},
186
+ });
187
+
188
+ schema.register(UserCreatedEvent, [subscriber]);
189
+
190
+ const event = new UserCreatedEvent({
191
+ userId: '123',
192
+ email: 'test@example.com',
193
+ });
194
+
195
+ await fanout.send(UserCreatedEvent, event);
196
+
197
+ const sendCall = (transport.send as ReturnType<typeof mock>).mock
198
+ .calls[0]!;
199
+ const envelope = sendCall[1] as Envelope;
200
+
201
+ expect(envelope.docket.eventKey).toBe('user.created');
202
+ expect(envelope.docket.eventDescription).toBe(
203
+ 'Fired when a new user is created',
204
+ );
205
+ expect(envelope.docket.targetSubscriber).toBe('handle-user');
206
+ expect(envelope.data).toEqual({
207
+ userId: '123',
208
+ email: 'test@example.com',
209
+ });
210
+ expect(envelope.id).toBeDefined();
211
+ expect(envelope.docket.importance).toBe('should-investigate');
212
+ });
213
+
214
+ it('should include eventDescription from event class', async () => {
215
+ const subscriber = createSubscriber({
216
+ name: 'order-handler',
217
+ description: 'Handles order events',
218
+ callback: async () => {},
219
+ });
220
+
221
+ schema.register(OrderPlacedEvent, [subscriber]);
222
+
223
+ const event = new OrderPlacedEvent({
224
+ orderId: 'ord_123',
225
+ amount: 99.99,
226
+ });
227
+
228
+ await fanout.send(OrderPlacedEvent, event);
229
+
230
+ const sendCall = (transport.send as ReturnType<typeof mock>).mock
231
+ .calls[0]!;
232
+ const envelope = sendCall[1] as Envelope;
233
+
234
+ expect(envelope.docket.eventKey).toBe('order.placed');
235
+ expect(envelope.docket.eventDescription).toBe(
236
+ 'Fired when an order is placed',
237
+ );
238
+ });
239
+ });
240
+
241
+ describe('send() with multiple subscribers', () => {
242
+ it('should send to all subscribers', async () => {
243
+ const sub1 = createSubscriber({
244
+ name: 'subscriber-1',
245
+ description: 'Subscriber 1',
246
+ callback: async () => {},
247
+ });
248
+ const sub2 = createSubscriber({
249
+ name: 'subscriber-2',
250
+ description: 'Subscriber 2',
251
+ callback: async () => {},
252
+ });
253
+ const sub3 = createSubscriber({
254
+ name: 'subscriber-3',
255
+ description: 'Subscriber 3',
256
+ callback: async () => {},
257
+ });
258
+
259
+ schema.register(UserCreatedEvent, [sub1, sub2, sub3]);
260
+
261
+ const event = new UserCreatedEvent({
262
+ userId: '123',
263
+ email: 'test@example.com',
264
+ });
265
+
266
+ const result = await fanout.send(UserCreatedEvent, event);
267
+
268
+ expect(result.subscribersSent).toBe(3);
269
+ expect(result.subscribersSkipped).toBe(0);
270
+ expect(result.errors).toHaveLength(0);
271
+ expect(transport.send).toHaveBeenCalledTimes(3);
272
+ });
273
+
274
+ it('should create separate envelope for each subscriber', async () => {
275
+ const sub1 = createSubscriber({
276
+ name: 'subscriber-1',
277
+ description: 'Subscriber 1',
278
+ callback: async () => {},
279
+ });
280
+ const sub2 = createSubscriber({
281
+ name: 'subscriber-2',
282
+ description: 'Subscriber 2',
283
+ callback: async () => {},
284
+ });
285
+
286
+ schema.register(UserCreatedEvent, [sub1, sub2]);
287
+
288
+ const event = new UserCreatedEvent({
289
+ userId: '123',
290
+ email: 'test@example.com',
291
+ });
292
+
293
+ await fanout.send(UserCreatedEvent, event);
294
+
295
+ const calls = (transport.send as ReturnType<typeof mock>).mock.calls;
296
+ const envelope1 = calls[0]![1] as Envelope;
297
+ const envelope2 = calls[1]![1] as Envelope;
298
+
299
+ expect(envelope1.id).not.toBe(envelope2.id);
300
+ expect(envelope1.docket.targetSubscriber).toBe('subscriber-1');
301
+ expect(envelope2.docket.targetSubscriber).toBe('subscriber-2');
302
+ expect(envelope1.data).toEqual(envelope2.data);
303
+ });
304
+
305
+ it('should route to different queues based on targetQueue', async () => {
306
+ const sub1 = createSubscriber({
307
+ name: 'subscriber-1',
308
+ description: 'Subscriber 1',
309
+ callback: async () => {},
310
+ targetQueue: 'queue-1',
311
+ });
312
+ const sub2 = createSubscriber({
313
+ name: 'subscriber-2',
314
+ description: 'Subscriber 2',
315
+ callback: async () => {},
316
+ targetQueue: 'queue-2',
317
+ });
318
+
319
+ schema.register(UserCreatedEvent, [sub1, sub2]);
320
+
321
+ const event = new UserCreatedEvent({
322
+ userId: '123',
323
+ email: 'test@example.com',
324
+ });
325
+
326
+ await fanout.send(UserCreatedEvent, event);
327
+
328
+ const calls = (transport.send as ReturnType<typeof mock>).mock.calls;
329
+ expect(calls[0]![0]).toBe('test.queue-1');
330
+ expect(calls[1]![0]).toBe('test.queue-2');
331
+ });
332
+
333
+ it('should send to no subscribers when event is not registered', async () => {
334
+ const event = new OrderPlacedEvent({
335
+ orderId: '456',
336
+ amount: 99.99,
337
+ });
338
+
339
+ const result = await fanout.send(OrderPlacedEvent, event);
340
+
341
+ expect(result.subscribersSent).toBe(0);
342
+ expect(result.subscribersSkipped).toBe(0);
343
+ expect(result.errors).toHaveLength(0);
344
+ expect(transport.send).not.toHaveBeenCalled();
345
+ });
346
+ });
347
+
348
+ describe('metadata merging', () => {
349
+ it('should include event metadata in envelope', async () => {
350
+ const subscriber = createSubscriber({
351
+ name: 'handle-user',
352
+ description: 'Handles user events',
353
+ callback: async () => {},
354
+ });
355
+
356
+ schema.register(UserCreatedEventWithMetadata, [subscriber]);
357
+
358
+ const event = new UserCreatedEventWithMetadata(
359
+ { userId: '123', email: 'test@example.com' },
360
+ { source: 'api', version: 1 },
361
+ );
362
+
363
+ await fanout.send(UserCreatedEventWithMetadata, event);
364
+
365
+ const envelope = (transport.send as ReturnType<typeof mock>).mock
366
+ .calls[0]![1] as Envelope;
367
+ expect(envelope.docket.metadata).toEqual({ source: 'api', version: 1 });
368
+ });
369
+
370
+ it('should include options metadata in envelope', async () => {
371
+ const subscriber = createSubscriber({
372
+ name: 'handle-user',
373
+ description: 'Handles user events',
374
+ callback: async () => {},
375
+ });
376
+
377
+ schema.register(UserCreatedEvent, [subscriber]);
378
+
379
+ const event = new UserCreatedEvent({
380
+ userId: '123',
381
+ email: 'test@example.com',
382
+ });
383
+
384
+ const options: EventOptions = {
385
+ metadata: { requestId: 'req-123' },
386
+ };
387
+
388
+ await fanout.send(UserCreatedEvent, event, options);
389
+
390
+ const envelope = (transport.send as ReturnType<typeof mock>).mock
391
+ .calls[0]![1] as Envelope;
392
+ expect(envelope.docket.metadata).toEqual({ requestId: 'req-123' });
393
+ });
394
+
395
+ it('should merge event and options metadata with options taking precedence', async () => {
396
+ const subscriber = createSubscriber({
397
+ name: 'handle-user',
398
+ description: 'Handles user events',
399
+ callback: async () => {},
400
+ });
401
+
402
+ schema.register(UserCreatedEventWithMetadata, [subscriber]);
403
+
404
+ const event = new UserCreatedEventWithMetadata(
405
+ { userId: '123', email: 'test@example.com' },
406
+ { source: 'api', version: 1 },
407
+ );
408
+
409
+ const options: EventOptions = {
410
+ metadata: { version: 2, requestId: 'req-123' },
411
+ };
412
+
413
+ await fanout.send(UserCreatedEventWithMetadata, event, options);
414
+
415
+ const envelope = (transport.send as ReturnType<typeof mock>).mock
416
+ .calls[0]![1] as Envelope;
417
+ expect(envelope.docket.metadata).toEqual({
418
+ source: 'api',
419
+ version: 2,
420
+ requestId: 'req-123',
421
+ });
422
+ });
423
+
424
+ it('should include universal metadata from hooks', async () => {
425
+ const hooksWithMetadata: MatadorHooks = {
426
+ loadUniversalMetadata: async () => ({
427
+ environment: 'test',
428
+ hostname: 'test-host',
429
+ }),
430
+ };
431
+ const hooksInstance = new SafeHooks(hooksWithMetadata);
432
+
433
+ const fanoutWithHooks = new FanoutEngine({
434
+ transport,
435
+ schema,
436
+ hooks: hooksInstance,
437
+ namespace: 'test',
438
+ defaultQueue: 'events',
439
+ });
440
+
441
+ const subscriber = createSubscriber({
442
+ name: 'handle-user',
443
+ description: 'Handles user events',
444
+ callback: async () => {},
445
+ });
446
+
447
+ schema.register(UserCreatedEvent, [subscriber]);
448
+
449
+ const event = new UserCreatedEvent({
450
+ userId: '123',
451
+ email: 'test@example.com',
452
+ });
453
+
454
+ await fanoutWithHooks.send(UserCreatedEvent, event);
455
+
456
+ const envelope = (transport.send as ReturnType<typeof mock>).mock
457
+ .calls[0]![1] as Envelope;
458
+ expect(envelope.docket.metadata).toEqual({
459
+ environment: 'test',
460
+ hostname: 'test-host',
461
+ });
462
+ });
463
+
464
+ it('should have empty metadata when no metadata provided', async () => {
465
+ const subscriber = createSubscriber({
466
+ name: 'handle-user',
467
+ description: 'Handles user events',
468
+ callback: async () => {},
469
+ });
470
+
471
+ schema.register(UserCreatedEvent, [subscriber]);
472
+
473
+ const event = new UserCreatedEvent({
474
+ userId: '123',
475
+ email: 'test@example.com',
476
+ });
477
+
478
+ await fanout.send(UserCreatedEvent, event);
479
+
480
+ const envelope = (transport.send as ReturnType<typeof mock>).mock
481
+ .calls[0]![1] as Envelope;
482
+ expect(envelope.docket.metadata).toEqual({});
483
+ });
484
+ });
485
+
486
+ describe('filtering disabled subscribers', () => {
487
+ it('should send to enabled subscriber', async () => {
488
+ const subscriber = createSubscriber({
489
+ name: 'handle-user',
490
+ description: 'Handles user events',
491
+ callback: async () => {},
492
+ enabled: () => true,
493
+ });
494
+
495
+ schema.register(UserCreatedEvent, [subscriber]);
496
+
497
+ const event = new UserCreatedEvent({
498
+ userId: '123',
499
+ email: 'test@example.com',
500
+ });
501
+
502
+ const result = await fanout.send(UserCreatedEvent, event);
503
+
504
+ expect(result.subscribersSent).toBe(1);
505
+ expect(result.subscribersSkipped).toBe(0);
506
+ });
507
+
508
+ it('should skip disabled subscriber', async () => {
509
+ const subscriber = createSubscriber({
510
+ name: 'handle-user',
511
+ description: 'Handles user events',
512
+ callback: async () => {},
513
+ enabled: () => false,
514
+ });
515
+
516
+ schema.register(UserCreatedEvent, [subscriber]);
517
+
518
+ const event = new UserCreatedEvent({
519
+ userId: '123',
520
+ email: 'test@example.com',
521
+ });
522
+
523
+ const result = await fanout.send(UserCreatedEvent, event);
524
+
525
+ expect(result.subscribersSent).toBe(0);
526
+ expect(result.subscribersSkipped).toBe(1);
527
+ expect(transport.send).not.toHaveBeenCalled();
528
+ });
529
+
530
+ it('should filter some subscribers and send to others', async () => {
531
+ const sub1 = createSubscriber({
532
+ name: 'enabled-sub',
533
+ description: 'Enabled subscriber',
534
+ callback: async () => {},
535
+ enabled: () => true,
536
+ });
537
+ const sub2 = createSubscriber({
538
+ name: 'disabled-sub',
539
+ description: 'Disabled subscriber',
540
+ callback: async () => {},
541
+ enabled: () => false,
542
+ });
543
+ const sub3 = createSubscriber({
544
+ name: 'always-enabled-sub',
545
+ description: 'Always enabled subscriber',
546
+ callback: async () => {},
547
+ });
548
+
549
+ schema.register(UserCreatedEvent, [sub1, sub2, sub3]);
550
+
551
+ const event = new UserCreatedEvent({
552
+ userId: '123',
553
+ email: 'test@example.com',
554
+ });
555
+
556
+ const result = await fanout.send(UserCreatedEvent, event);
557
+
558
+ expect(result.subscribersSent).toBe(2);
559
+ expect(result.subscribersSkipped).toBe(1);
560
+ expect(transport.send).toHaveBeenCalledTimes(2);
561
+ });
562
+
563
+ it('should support async enabled function', async () => {
564
+ const subscriber = createSubscriber({
565
+ name: 'handle-user',
566
+ description: 'Handles user events',
567
+ callback: async () => {},
568
+ enabled: async () => {
569
+ await new Promise((resolve) => setTimeout(resolve, 10));
570
+ return true;
571
+ },
572
+ });
573
+
574
+ schema.register(UserCreatedEvent, [subscriber]);
575
+
576
+ const event = new UserCreatedEvent({
577
+ userId: '123',
578
+ email: 'test@example.com',
579
+ });
580
+
581
+ const result = await fanout.send(UserCreatedEvent, event);
582
+
583
+ expect(result.subscribersSent).toBe(1);
584
+ expect(result.subscribersSkipped).toBe(0);
585
+ });
586
+
587
+ it('should treat subscriber as enabled if enabled() throws error', async () => {
588
+ const subscriber = createSubscriber({
589
+ name: 'handle-user',
590
+ description: 'Handles user events',
591
+ callback: async () => {},
592
+ enabled: () => {
593
+ throw new Error('Feature flag service down');
594
+ },
595
+ });
596
+
597
+ schema.register(UserCreatedEvent, [subscriber]);
598
+
599
+ const event = new UserCreatedEvent({
600
+ userId: '123',
601
+ email: 'test@example.com',
602
+ });
603
+
604
+ const result = await fanout.send(UserCreatedEvent, event);
605
+
606
+ expect(result.subscribersSent).toBe(1);
607
+ expect(result.subscribersSkipped).toBe(0);
608
+ });
609
+
610
+ it('should treat subscriber as enabled if no enabled hook provided', async () => {
611
+ const subscriber = createSubscriber({
612
+ name: 'handle-user',
613
+ description: 'Handles user events',
614
+ callback: async () => {},
615
+ });
616
+
617
+ schema.register(UserCreatedEvent, [subscriber]);
618
+
619
+ const event = new UserCreatedEvent({
620
+ userId: '123',
621
+ email: 'test@example.com',
622
+ });
623
+
624
+ const result = await fanout.send(UserCreatedEvent, event);
625
+
626
+ expect(result.subscribersSent).toBe(1);
627
+ expect(result.subscribersSkipped).toBe(0);
628
+ });
629
+
630
+ it('should work with subscriber stubs', async () => {
631
+ const stub = createSubscriberStub({
632
+ name: 'remote-subscriber',
633
+ description: 'Remote subscriber stub',
634
+ enabled: () => false,
635
+ });
636
+
637
+ schema.register(UserCreatedEvent, [stub]);
638
+
639
+ const event = new UserCreatedEvent({
640
+ userId: '123',
641
+ email: 'test@example.com',
642
+ });
643
+
644
+ const result = await fanout.send(UserCreatedEvent, event);
645
+
646
+ expect(result.subscribersSent).toBe(0);
647
+ expect(result.subscribersSkipped).toBe(1);
648
+ });
649
+ });
650
+
651
+ describe('error handling', () => {
652
+ it('should capture error when transport.send fails', async () => {
653
+ const sendError = new Error('Network timeout');
654
+ const failingTransport: Transport = {
655
+ ...transport,
656
+ send: mock(async () => {
657
+ throw sendError;
658
+ }),
659
+ };
660
+
661
+ const fanoutWithFailingTransport = new FanoutEngine({
662
+ transport: failingTransport,
663
+ schema,
664
+ hooks,
665
+ namespace: 'test',
666
+ defaultQueue: 'events',
667
+ });
668
+
669
+ const subscriber = createSubscriber({
670
+ name: 'handle-user',
671
+ description: 'Handles user events',
672
+ callback: async () => {},
673
+ });
674
+
675
+ schema.register(UserCreatedEvent, [subscriber]);
676
+
677
+ const event = new UserCreatedEvent({
678
+ userId: '123',
679
+ email: 'test@example.com',
680
+ });
681
+
682
+ const result = await fanoutWithFailingTransport.send(
683
+ UserCreatedEvent,
684
+ event,
685
+ );
686
+
687
+ expect(result.subscribersSent).toBe(0);
688
+ expect(result.subscribersSkipped).toBe(0);
689
+ expect(result.errors).toHaveLength(1);
690
+ expect(result.errors[0]!.subscriberName).toBe('handle-user');
691
+ expect(result.errors[0]!.queue).toBe('test.events');
692
+ expect(result.errors[0]!.error).toBeInstanceOf(TransportSendError);
693
+ });
694
+
695
+ it('should continue sending to other subscribers after one fails', async () => {
696
+ const failingTransport: Transport = {
697
+ ...transport,
698
+ send: mock(async (queue: string) => {
699
+ if (queue === 'test.queue-1') {
700
+ throw new Error('Queue 1 failed');
701
+ }
702
+ return 'mock';
703
+ }),
704
+ };
705
+
706
+ const fanoutWithFailingTransport = new FanoutEngine({
707
+ transport: failingTransport,
708
+ schema,
709
+ hooks,
710
+ namespace: 'test',
711
+ defaultQueue: 'events',
712
+ });
713
+
714
+ const sub1 = createSubscriber({
715
+ name: 'subscriber-1',
716
+ description: 'Subscriber 1',
717
+ callback: async () => {},
718
+ targetQueue: 'queue-1',
719
+ });
720
+ const sub2 = createSubscriber({
721
+ name: 'subscriber-2',
722
+ description: 'Subscriber 2',
723
+ callback: async () => {},
724
+ targetQueue: 'queue-2',
725
+ });
726
+
727
+ schema.register(UserCreatedEvent, [sub1, sub2]);
728
+
729
+ const event = new UserCreatedEvent({
730
+ userId: '123',
731
+ email: 'test@example.com',
732
+ });
733
+
734
+ const result = await fanoutWithFailingTransport.send(
735
+ UserCreatedEvent,
736
+ event,
737
+ );
738
+
739
+ expect(result.subscribersSent).toBe(1);
740
+ expect(result.subscribersSkipped).toBe(0);
741
+ expect(result.errors).toHaveLength(1);
742
+ expect(result.errors[0]!.subscriberName).toBe('subscriber-1');
743
+ });
744
+
745
+ it('should wrap non-Error throws as Error in TransportSendError', async () => {
746
+ const failingTransport: Transport = {
747
+ ...transport,
748
+ send: mock(async () => {
749
+ throw 'string error';
750
+ }),
751
+ };
752
+
753
+ const fanoutWithFailingTransport = new FanoutEngine({
754
+ transport: failingTransport,
755
+ schema,
756
+ hooks,
757
+ namespace: 'test',
758
+ defaultQueue: 'events',
759
+ });
760
+
761
+ const subscriber = createSubscriber({
762
+ name: 'handle-user',
763
+ description: 'Handles user events',
764
+ callback: async () => {},
765
+ });
766
+
767
+ schema.register(UserCreatedEvent, [subscriber]);
768
+
769
+ const event = new UserCreatedEvent({
770
+ userId: '123',
771
+ email: 'test@example.com',
772
+ });
773
+
774
+ const result = await fanoutWithFailingTransport.send(
775
+ UserCreatedEvent,
776
+ event,
777
+ );
778
+
779
+ expect(result.errors).toHaveLength(1);
780
+ expect(result.errors[0]!.error).toBeInstanceOf(TransportSendError);
781
+ expect(result.errors[0]!.error.message).toContain('test.events');
782
+ });
783
+ });
784
+
785
+ describe('eventsBeingEnqueuedCount tracking', () => {
786
+ it('should increment count during send', async () => {
787
+ let countDuringSend = 0;
788
+ // biome-ignore lint/style/useConst: Variable must be let to be assigned after closure definition
789
+ let fanoutWithTracking: FanoutEngine;
790
+
791
+ const trackingTransport: Transport = {
792
+ ...transport,
793
+ send: mock(async () => {
794
+ countDuringSend = fanoutWithTracking.eventsBeingEnqueuedCount;
795
+ return 'mock';
796
+ }),
797
+ };
798
+
799
+ fanoutWithTracking = new FanoutEngine({
800
+ transport: trackingTransport,
801
+ schema,
802
+ hooks,
803
+ namespace: 'test',
804
+ defaultQueue: 'events',
805
+ });
806
+
807
+ const subscriber = createSubscriber({
808
+ name: 'handle-user',
809
+ description: 'Handles user events',
810
+ callback: async () => {},
811
+ });
812
+
813
+ schema.register(UserCreatedEvent, [subscriber]);
814
+
815
+ const event = new UserCreatedEvent({
816
+ userId: '123',
817
+ email: 'test@example.com',
818
+ });
819
+
820
+ expect(fanoutWithTracking.eventsBeingEnqueuedCount).toBe(0);
821
+ await fanoutWithTracking.send(UserCreatedEvent, event);
822
+
823
+ expect(countDuringSend).toBe(1);
824
+ });
825
+
826
+ it('should decrement count after send completes', async () => {
827
+ const subscriber = createSubscriber({
828
+ name: 'handle-user',
829
+ description: 'Handles user events',
830
+ callback: async () => {},
831
+ });
832
+
833
+ schema.register(UserCreatedEvent, [subscriber]);
834
+
835
+ const event = new UserCreatedEvent({
836
+ userId: '123',
837
+ email: 'test@example.com',
838
+ });
839
+
840
+ expect(fanout.eventsBeingEnqueuedCount).toBe(0);
841
+ await fanout.send(UserCreatedEvent, event);
842
+ expect(fanout.eventsBeingEnqueuedCount).toBe(0);
843
+ });
844
+
845
+ it('should decrement count even when send fails', async () => {
846
+ const failingTransport: Transport = {
847
+ ...transport,
848
+ send: mock(async () => {
849
+ throw new Error('Send failed');
850
+ }),
851
+ };
852
+
853
+ const fanoutWithFailingTransport = new FanoutEngine({
854
+ transport: failingTransport,
855
+ schema,
856
+ hooks,
857
+ namespace: 'test',
858
+ defaultQueue: 'events',
859
+ });
860
+
861
+ const subscriber = createSubscriber({
862
+ name: 'handle-user',
863
+ description: 'Handles user events',
864
+ callback: async () => {},
865
+ });
866
+
867
+ schema.register(UserCreatedEvent, [subscriber]);
868
+
869
+ const event = new UserCreatedEvent({
870
+ userId: '123',
871
+ email: 'test@example.com',
872
+ });
873
+
874
+ expect(fanoutWithFailingTransport.eventsBeingEnqueuedCount).toBe(0);
875
+ await fanoutWithFailingTransport.send(UserCreatedEvent, event);
876
+ expect(fanoutWithFailingTransport.eventsBeingEnqueuedCount).toBe(0);
877
+ });
878
+
879
+ it('should track multiple sequential sends to subscribers', async () => {
880
+ const sub1 = createSubscriber({
881
+ name: 'subscriber-1',
882
+ description: 'Subscriber 1',
883
+ callback: async () => {},
884
+ });
885
+ const sub2 = createSubscriber({
886
+ name: 'subscriber-2',
887
+ description: 'Subscriber 2',
888
+ callback: async () => {},
889
+ });
890
+
891
+ schema.register(UserCreatedEvent, [sub1, sub2]);
892
+
893
+ let maxCount = 0;
894
+ const counts: number[] = [];
895
+ // biome-ignore lint/style/useConst: Variable must be let to be assigned after closure definition
896
+ let fanoutWithTracking: FanoutEngine;
897
+
898
+ const trackingTransport: Transport = {
899
+ ...transport,
900
+ send: mock(async () => {
901
+ const current = fanoutWithTracking.eventsBeingEnqueuedCount;
902
+ counts.push(current);
903
+ maxCount = Math.max(maxCount, current);
904
+ await new Promise((resolve) => setTimeout(resolve, 10));
905
+ return 'mock';
906
+ }),
907
+ };
908
+
909
+ fanoutWithTracking = new FanoutEngine({
910
+ transport: trackingTransport,
911
+ schema,
912
+ hooks,
913
+ namespace: 'test',
914
+ defaultQueue: 'events',
915
+ });
916
+
917
+ const event = new UserCreatedEvent({
918
+ userId: '123',
919
+ email: 'test@example.com',
920
+ });
921
+
922
+ await fanoutWithTracking.send(UserCreatedEvent, event);
923
+
924
+ expect(maxCount).toBe(1);
925
+ expect(counts).toEqual([1, 1]);
926
+ expect(fanoutWithTracking.eventsBeingEnqueuedCount).toBe(0);
927
+ });
928
+ });
929
+
930
+ describe('correlation ID propagation', () => {
931
+ it('should include correlation ID in envelope when provided', async () => {
932
+ const subscriber = createSubscriber({
933
+ name: 'handle-user',
934
+ description: 'Handles user events',
935
+ callback: async () => {},
936
+ });
937
+
938
+ schema.register(UserCreatedEvent, [subscriber]);
939
+
940
+ const event = new UserCreatedEvent({
941
+ userId: '123',
942
+ email: 'test@example.com',
943
+ });
944
+
945
+ const options: EventOptions = {
946
+ correlationId: 'corr-123',
947
+ };
948
+
949
+ await fanout.send(UserCreatedEvent, event, options);
950
+
951
+ const envelope = (transport.send as ReturnType<typeof mock>).mock
952
+ .calls[0]![1] as Envelope;
953
+ expect(envelope.docket.correlationId).toBe('corr-123');
954
+ });
955
+
956
+ it('should propagate same correlation ID to all subscribers', async () => {
957
+ const sub1 = createSubscriber({
958
+ name: 'subscriber-1',
959
+ description: 'Subscriber 1',
960
+ callback: async () => {},
961
+ });
962
+ const sub2 = createSubscriber({
963
+ name: 'subscriber-2',
964
+ description: 'Subscriber 2',
965
+ callback: async () => {},
966
+ });
967
+
968
+ schema.register(UserCreatedEvent, [sub1, sub2]);
969
+
970
+ const event = new UserCreatedEvent({
971
+ userId: '123',
972
+ email: 'test@example.com',
973
+ });
974
+
975
+ const options: EventOptions = {
976
+ correlationId: 'corr-456',
977
+ };
978
+
979
+ await fanout.send(UserCreatedEvent, event, options);
980
+
981
+ const calls = (transport.send as ReturnType<typeof mock>).mock.calls;
982
+ const envelope1 = calls[0]![1] as Envelope;
983
+ const envelope2 = calls[1]![1] as Envelope;
984
+
985
+ expect(envelope1.docket.correlationId).toBe('corr-456');
986
+ expect(envelope2.docket.correlationId).toBe('corr-456');
987
+ });
988
+
989
+ it('should have undefined correlation ID when not provided', async () => {
990
+ const subscriber = createSubscriber({
991
+ name: 'handle-user',
992
+ description: 'Handles user events',
993
+ callback: async () => {},
994
+ });
995
+
996
+ schema.register(UserCreatedEvent, [subscriber]);
997
+
998
+ const event = new UserCreatedEvent({
999
+ userId: '123',
1000
+ email: 'test@example.com',
1001
+ });
1002
+
1003
+ await fanout.send(UserCreatedEvent, event);
1004
+
1005
+ const envelope = (transport.send as ReturnType<typeof mock>).mock
1006
+ .calls[0]![1] as Envelope;
1007
+ expect(envelope.docket.correlationId).toBeUndefined();
1008
+ });
1009
+ });
1010
+
1011
+ describe('hook invocation', () => {
1012
+ it('should call onEnqueueSuccess after successful send', async () => {
1013
+ const onEnqueueSuccess = mock(async () => {});
1014
+ const hooksWithSuccess: MatadorHooks = {
1015
+ onEnqueueSuccess,
1016
+ };
1017
+ const hooksInstance = new SafeHooks(hooksWithSuccess);
1018
+
1019
+ const fanoutWithHooks = new FanoutEngine({
1020
+ transport,
1021
+ schema,
1022
+ hooks: hooksInstance,
1023
+ namespace: 'test',
1024
+ defaultQueue: 'events',
1025
+ });
1026
+
1027
+ const subscriber = createSubscriber({
1028
+ name: 'handle-user',
1029
+ description: 'Handles user events',
1030
+ callback: async () => {},
1031
+ });
1032
+
1033
+ schema.register(UserCreatedEvent, [subscriber]);
1034
+
1035
+ const event = new UserCreatedEvent({
1036
+ userId: '123',
1037
+ email: 'test@example.com',
1038
+ });
1039
+
1040
+ await fanoutWithHooks.send(UserCreatedEvent, event);
1041
+
1042
+ expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
1043
+ expect(onEnqueueSuccess).toHaveBeenCalledWith({
1044
+ envelope: expect.any(Object),
1045
+ queue: 'test.events',
1046
+ transport: 'mock',
1047
+ });
1048
+
1049
+ const calls = onEnqueueSuccess.mock.calls as unknown as Array<
1050
+ [{ envelope: Envelope; queue: string }]
1051
+ >;
1052
+ const callArgs = calls[0]![0];
1053
+ expect(callArgs.envelope.docket.eventKey).toBe('user.created');
1054
+ expect(callArgs.envelope.docket.targetSubscriber).toBe('handle-user');
1055
+ });
1056
+
1057
+ it('should call onEnqueueError after failed send', async () => {
1058
+ const onEnqueueError = mock(async () => {});
1059
+ const hooksWithError: MatadorHooks = {
1060
+ onEnqueueError,
1061
+ };
1062
+ const hooksInstance = new SafeHooks(hooksWithError);
1063
+
1064
+ const failingTransport: Transport = {
1065
+ ...transport,
1066
+ send: mock(async () => {
1067
+ throw new Error('Send failed');
1068
+ }),
1069
+ };
1070
+
1071
+ const fanoutWithHooks = new FanoutEngine({
1072
+ transport: failingTransport,
1073
+ schema,
1074
+ hooks: hooksInstance,
1075
+ namespace: 'test',
1076
+ defaultQueue: 'events',
1077
+ });
1078
+
1079
+ const subscriber = createSubscriber({
1080
+ name: 'handle-user',
1081
+ description: 'Handles user events',
1082
+ callback: async () => {},
1083
+ });
1084
+
1085
+ schema.register(UserCreatedEvent, [subscriber]);
1086
+
1087
+ const event = new UserCreatedEvent({
1088
+ userId: '123',
1089
+ email: 'test@example.com',
1090
+ });
1091
+
1092
+ await fanoutWithHooks.send(UserCreatedEvent, event);
1093
+
1094
+ expect(onEnqueueError).toHaveBeenCalledTimes(1);
1095
+ expect(onEnqueueError).toHaveBeenCalledWith({
1096
+ envelope: expect.any(Object),
1097
+ error: expect.any(TransportSendError),
1098
+ transport: 'mock',
1099
+ });
1100
+
1101
+ const calls = onEnqueueError.mock.calls as unknown as Array<
1102
+ [{ envelope: Envelope; error: TransportSendError }]
1103
+ >;
1104
+ const callArgs = calls[0]![0];
1105
+ expect(callArgs.envelope.docket.eventKey).toBe('user.created');
1106
+ expect(callArgs.error).toBeInstanceOf(TransportSendError);
1107
+ });
1108
+
1109
+ it('should call hooks for each subscriber', async () => {
1110
+ const onEnqueueSuccess = mock(async () => {});
1111
+ const hooksWithSuccess: MatadorHooks = {
1112
+ onEnqueueSuccess,
1113
+ };
1114
+ const hooksInstance = new SafeHooks(hooksWithSuccess);
1115
+
1116
+ const fanoutWithHooks = new FanoutEngine({
1117
+ transport,
1118
+ schema,
1119
+ hooks: hooksInstance,
1120
+ namespace: 'test',
1121
+ defaultQueue: 'events',
1122
+ });
1123
+
1124
+ const sub1 = createSubscriber({
1125
+ name: 'subscriber-1',
1126
+ description: 'Subscriber 1',
1127
+ callback: async () => {},
1128
+ });
1129
+ const sub2 = createSubscriber({
1130
+ name: 'subscriber-2',
1131
+ description: 'Subscriber 2',
1132
+ callback: async () => {},
1133
+ });
1134
+ const sub3 = createSubscriber({
1135
+ name: 'subscriber-3',
1136
+ description: 'Subscriber 3',
1137
+ callback: async () => {},
1138
+ });
1139
+
1140
+ schema.register(UserCreatedEvent, [sub1, sub2, sub3]);
1141
+
1142
+ const event = new UserCreatedEvent({
1143
+ userId: '123',
1144
+ email: 'test@example.com',
1145
+ });
1146
+
1147
+ await fanoutWithHooks.send(UserCreatedEvent, event);
1148
+
1149
+ expect(onEnqueueSuccess).toHaveBeenCalledTimes(3);
1150
+ });
1151
+
1152
+ it('should not call onEnqueueSuccess when subscriber is disabled', async () => {
1153
+ const onEnqueueSuccess = mock(async () => {});
1154
+ const hooksWithSuccess: MatadorHooks = {
1155
+ onEnqueueSuccess,
1156
+ };
1157
+ const hooksInstance = new SafeHooks(hooksWithSuccess);
1158
+
1159
+ const fanoutWithHooks = new FanoutEngine({
1160
+ transport,
1161
+ schema,
1162
+ hooks: hooksInstance,
1163
+ namespace: 'test',
1164
+ defaultQueue: 'events',
1165
+ });
1166
+
1167
+ const subscriber = createSubscriber({
1168
+ name: 'handle-user',
1169
+ description: 'Handles user events',
1170
+ callback: async () => {},
1171
+ enabled: () => false,
1172
+ });
1173
+
1174
+ schema.register(UserCreatedEvent, [subscriber]);
1175
+
1176
+ const event = new UserCreatedEvent({
1177
+ userId: '123',
1178
+ email: 'test@example.com',
1179
+ });
1180
+
1181
+ await fanoutWithHooks.send(UserCreatedEvent, event);
1182
+
1183
+ expect(onEnqueueSuccess).not.toHaveBeenCalled();
1184
+ });
1185
+
1186
+ it('should continue processing even if hook throws error', async () => {
1187
+ const onEnqueueSuccess = mock(async () => {
1188
+ throw new Error('Hook error');
1189
+ });
1190
+ const hooksWithSuccess: MatadorHooks = {
1191
+ onEnqueueSuccess,
1192
+ };
1193
+ const hooksInstance = new SafeHooks(hooksWithSuccess);
1194
+
1195
+ const fanoutWithHooks = new FanoutEngine({
1196
+ transport,
1197
+ schema,
1198
+ hooks: hooksInstance,
1199
+ namespace: 'test',
1200
+ defaultQueue: 'events',
1201
+ });
1202
+
1203
+ const subscriber = createSubscriber({
1204
+ name: 'handle-user',
1205
+ description: 'Handles user events',
1206
+ callback: async () => {},
1207
+ });
1208
+
1209
+ schema.register(UserCreatedEvent, [subscriber]);
1210
+
1211
+ const event = new UserCreatedEvent({
1212
+ userId: '123',
1213
+ email: 'test@example.com',
1214
+ });
1215
+
1216
+ const result = await fanoutWithHooks.send(UserCreatedEvent, event);
1217
+
1218
+ expect(result.subscribersSent).toBe(1);
1219
+ expect(result.errors).toHaveLength(0);
1220
+ expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
1221
+ });
1222
+ });
1223
+
1224
+ describe('delay options', () => {
1225
+ it('should pass delay option to transport', async () => {
1226
+ const subscriber = createSubscriber({
1227
+ name: 'handle-user',
1228
+ description: 'Handles user events',
1229
+ callback: async () => {},
1230
+ });
1231
+
1232
+ schema.register(UserCreatedEvent, [subscriber]);
1233
+
1234
+ const event = new UserCreatedEvent({
1235
+ userId: '123',
1236
+ email: 'test@example.com',
1237
+ });
1238
+
1239
+ const options: EventOptions = {
1240
+ delayMs: 5000,
1241
+ };
1242
+
1243
+ await fanout.send(UserCreatedEvent, event, options);
1244
+
1245
+ expect(transport.send).toHaveBeenCalledWith(
1246
+ 'test.events',
1247
+ expect.any(Object),
1248
+ { delay: 5000 },
1249
+ );
1250
+ });
1251
+
1252
+ it('should include delay in envelope', async () => {
1253
+ const subscriber = createSubscriber({
1254
+ name: 'handle-user',
1255
+ description: 'Handles user events',
1256
+ callback: async () => {},
1257
+ });
1258
+
1259
+ schema.register(UserCreatedEvent, [subscriber]);
1260
+
1261
+ const event = new UserCreatedEvent({
1262
+ userId: '123',
1263
+ email: 'test@example.com',
1264
+ });
1265
+
1266
+ const options: EventOptions = {
1267
+ delayMs: 3000,
1268
+ };
1269
+
1270
+ await fanout.send(UserCreatedEvent, event, options);
1271
+
1272
+ const envelope = (transport.send as ReturnType<typeof mock>).mock
1273
+ .calls[0]![1] as Envelope;
1274
+ expect(envelope.docket.scheduledFor).toBeDefined();
1275
+ if (envelope.docket.scheduledFor) {
1276
+ const scheduledTime = new Date(envelope.docket.scheduledFor).getTime();
1277
+ const now = Date.now();
1278
+ expect(scheduledTime).toBeGreaterThan(now);
1279
+ expect(scheduledTime).toBeLessThan(now + 4000);
1280
+ }
1281
+ });
1282
+
1283
+ it('should not pass delay option when delayMs is undefined', async () => {
1284
+ const subscriber = createSubscriber({
1285
+ name: 'handle-user',
1286
+ description: 'Handles user events',
1287
+ callback: async () => {},
1288
+ });
1289
+
1290
+ schema.register(UserCreatedEvent, [subscriber]);
1291
+
1292
+ const event = new UserCreatedEvent({
1293
+ userId: '123',
1294
+ email: 'test@example.com',
1295
+ });
1296
+
1297
+ await fanout.send(UserCreatedEvent, event);
1298
+
1299
+ expect(transport.send).toHaveBeenCalledWith(
1300
+ 'test.events',
1301
+ expect.any(Object),
1302
+ undefined,
1303
+ );
1304
+ });
1305
+ });
1306
+
1307
+ describe('subscriber importance', () => {
1308
+ it('should use subscriber importance when specified', async () => {
1309
+ const subscriber = createSubscriber({
1310
+ name: 'handle-user',
1311
+ description: 'Handles user events',
1312
+ callback: async () => {},
1313
+ importance: 'must-investigate',
1314
+ });
1315
+
1316
+ schema.register(UserCreatedEvent, [subscriber]);
1317
+
1318
+ const event = new UserCreatedEvent({
1319
+ userId: '123',
1320
+ email: 'test@example.com',
1321
+ });
1322
+
1323
+ await fanout.send(UserCreatedEvent, event);
1324
+
1325
+ const envelope = (transport.send as ReturnType<typeof mock>).mock
1326
+ .calls[0]![1] as Envelope;
1327
+ expect(envelope.docket.importance).toBe('must-investigate');
1328
+ });
1329
+
1330
+ it('should default to should-investigate when importance not specified', async () => {
1331
+ const subscriber = createSubscriber({
1332
+ name: 'handle-user',
1333
+ description: 'Handles user events',
1334
+ callback: async () => {},
1335
+ });
1336
+
1337
+ schema.register(UserCreatedEvent, [subscriber]);
1338
+
1339
+ const event = new UserCreatedEvent({
1340
+ userId: '123',
1341
+ email: 'test@example.com',
1342
+ });
1343
+
1344
+ await fanout.send(UserCreatedEvent, event);
1345
+
1346
+ const envelope = (transport.send as ReturnType<typeof mock>).mock
1347
+ .calls[0]![1] as Envelope;
1348
+ expect(envelope.docket.importance).toBe('should-investigate');
1349
+ });
1350
+ });
1351
+ });