@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,1055 @@
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 { MatadorEvent, createSubscriber, createSubscriberStub, } from '../types/index.js';
6
+ import { FanoutEngine } from './fanout.js';
7
+ class UserCreatedEvent extends MatadorEvent {
8
+ data;
9
+ static key = 'user.created';
10
+ static description = 'Fired when a new user is created';
11
+ constructor(data) {
12
+ super();
13
+ this.data = data;
14
+ }
15
+ }
16
+ class OrderPlacedEvent extends MatadorEvent {
17
+ data;
18
+ static key = 'order.placed';
19
+ static description = 'Fired when an order is placed';
20
+ constructor(data) {
21
+ super();
22
+ this.data = data;
23
+ }
24
+ }
25
+ class UserCreatedEventWithMetadata extends MatadorEvent {
26
+ data;
27
+ metadata;
28
+ static key = 'user.created.with-metadata';
29
+ static description = 'Fired when a new user is created (with metadata)';
30
+ constructor(data, metadata) {
31
+ super();
32
+ this.data = data;
33
+ this.metadata = metadata;
34
+ }
35
+ }
36
+ describe('FanoutEngine', () => {
37
+ let transport;
38
+ let schema;
39
+ let hooks;
40
+ let fanout;
41
+ beforeEach(() => {
42
+ schema = new SchemaRegistry();
43
+ transport = {
44
+ name: 'mock',
45
+ capabilities: {
46
+ deliveryModes: ['at-least-once'],
47
+ delayedMessages: true,
48
+ deadLetterRouting: 'native',
49
+ attemptTracking: false,
50
+ concurrencyModel: 'prefetch',
51
+ ordering: 'none',
52
+ priorities: false,
53
+ },
54
+ isConnected: () => true,
55
+ connect: mock(async () => { }),
56
+ disconnect: mock(async () => { }),
57
+ send: mock(async () => 'mock'),
58
+ subscribe: mock(async () => ({
59
+ unsubscribe: async () => { },
60
+ isActive: true,
61
+ })),
62
+ applyTopology: mock(async () => { }),
63
+ complete: mock(async () => { }),
64
+ };
65
+ hooks = new SafeHooks();
66
+ fanout = new FanoutEngine({
67
+ transport,
68
+ schema,
69
+ hooks,
70
+ namespace: 'test',
71
+ defaultQueue: 'events',
72
+ });
73
+ });
74
+ describe('creation', () => {
75
+ it('should create instance via static factory', () => {
76
+ expect(fanout).toBeInstanceOf(FanoutEngine);
77
+ });
78
+ it('should create instance via constructor', () => {
79
+ const instance = new FanoutEngine({
80
+ transport,
81
+ schema,
82
+ hooks,
83
+ namespace: 'test',
84
+ defaultQueue: 'events',
85
+ });
86
+ expect(instance).toBeInstanceOf(FanoutEngine);
87
+ });
88
+ it('should initialize with zero events being enqueued', () => {
89
+ expect(fanout.eventsBeingEnqueuedCount).toBe(0);
90
+ });
91
+ });
92
+ describe('send() with single subscriber', () => {
93
+ it('should send event to one subscriber', async () => {
94
+ const subscriber = createSubscriber({
95
+ name: 'send-welcome-email',
96
+ description: 'Sends welcome email',
97
+ callback: async () => { },
98
+ });
99
+ schema.register(UserCreatedEvent, [subscriber]);
100
+ const event = new UserCreatedEvent({
101
+ userId: '123',
102
+ email: 'test@example.com',
103
+ });
104
+ const result = await fanout.send(UserCreatedEvent, event);
105
+ expect(result.eventKey).toBe('user.created');
106
+ expect(result.subscribersSent).toBe(1);
107
+ expect(result.subscribersSkipped).toBe(0);
108
+ expect(result.errors).toHaveLength(0);
109
+ expect(transport.send).toHaveBeenCalledTimes(1);
110
+ });
111
+ it('should use default queue when subscriber has no targetQueue', async () => {
112
+ const subscriber = createSubscriber({
113
+ name: 'handle-user',
114
+ description: 'Handles user events',
115
+ callback: async () => { },
116
+ });
117
+ schema.register(UserCreatedEvent, [subscriber]);
118
+ const event = new UserCreatedEvent({
119
+ userId: '123',
120
+ email: 'test@example.com',
121
+ });
122
+ await fanout.send(UserCreatedEvent, event);
123
+ expect(transport.send).toHaveBeenCalledWith('test.events', expect.any(Object), undefined);
124
+ });
125
+ it('should use subscriber targetQueue when specified', async () => {
126
+ const subscriber = createSubscriber({
127
+ name: 'handle-user',
128
+ description: 'Handles user events',
129
+ callback: async () => { },
130
+ targetQueue: 'notifications',
131
+ });
132
+ schema.register(UserCreatedEvent, [subscriber]);
133
+ const event = new UserCreatedEvent({
134
+ userId: '123',
135
+ email: 'test@example.com',
136
+ });
137
+ await fanout.send(UserCreatedEvent, event);
138
+ expect(transport.send).toHaveBeenCalledWith('test.notifications', expect.any(Object), undefined);
139
+ });
140
+ it('should create envelope with correct structure', async () => {
141
+ const subscriber = createSubscriber({
142
+ name: 'handle-user',
143
+ description: 'Handles user events',
144
+ callback: async () => { },
145
+ });
146
+ schema.register(UserCreatedEvent, [subscriber]);
147
+ const event = new UserCreatedEvent({
148
+ userId: '123',
149
+ email: 'test@example.com',
150
+ });
151
+ await fanout.send(UserCreatedEvent, event);
152
+ const sendCall = transport.send.mock
153
+ .calls[0];
154
+ const envelope = sendCall[1];
155
+ expect(envelope.docket.eventKey).toBe('user.created');
156
+ expect(envelope.docket.eventDescription).toBe('Fired when a new user is created');
157
+ expect(envelope.docket.targetSubscriber).toBe('handle-user');
158
+ expect(envelope.data).toEqual({
159
+ userId: '123',
160
+ email: 'test@example.com',
161
+ });
162
+ expect(envelope.id).toBeDefined();
163
+ expect(envelope.docket.importance).toBe('should-investigate');
164
+ });
165
+ it('should include eventDescription from event class', async () => {
166
+ const subscriber = createSubscriber({
167
+ name: 'order-handler',
168
+ description: 'Handles order events',
169
+ callback: async () => { },
170
+ });
171
+ schema.register(OrderPlacedEvent, [subscriber]);
172
+ const event = new OrderPlacedEvent({
173
+ orderId: 'ord_123',
174
+ amount: 99.99,
175
+ });
176
+ await fanout.send(OrderPlacedEvent, event);
177
+ const sendCall = transport.send.mock
178
+ .calls[0];
179
+ const envelope = sendCall[1];
180
+ expect(envelope.docket.eventKey).toBe('order.placed');
181
+ expect(envelope.docket.eventDescription).toBe('Fired when an order is placed');
182
+ });
183
+ });
184
+ describe('send() with multiple subscribers', () => {
185
+ it('should send to all subscribers', async () => {
186
+ const sub1 = createSubscriber({
187
+ name: 'subscriber-1',
188
+ description: 'Subscriber 1',
189
+ callback: async () => { },
190
+ });
191
+ const sub2 = createSubscriber({
192
+ name: 'subscriber-2',
193
+ description: 'Subscriber 2',
194
+ callback: async () => { },
195
+ });
196
+ const sub3 = createSubscriber({
197
+ name: 'subscriber-3',
198
+ description: 'Subscriber 3',
199
+ callback: async () => { },
200
+ });
201
+ schema.register(UserCreatedEvent, [sub1, sub2, sub3]);
202
+ const event = new UserCreatedEvent({
203
+ userId: '123',
204
+ email: 'test@example.com',
205
+ });
206
+ const result = await fanout.send(UserCreatedEvent, event);
207
+ expect(result.subscribersSent).toBe(3);
208
+ expect(result.subscribersSkipped).toBe(0);
209
+ expect(result.errors).toHaveLength(0);
210
+ expect(transport.send).toHaveBeenCalledTimes(3);
211
+ });
212
+ it('should create separate envelope for each subscriber', 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
+ schema.register(UserCreatedEvent, [sub1, sub2]);
224
+ const event = new UserCreatedEvent({
225
+ userId: '123',
226
+ email: 'test@example.com',
227
+ });
228
+ await fanout.send(UserCreatedEvent, event);
229
+ const calls = transport.send.mock.calls;
230
+ const envelope1 = calls[0][1];
231
+ const envelope2 = calls[1][1];
232
+ expect(envelope1.id).not.toBe(envelope2.id);
233
+ expect(envelope1.docket.targetSubscriber).toBe('subscriber-1');
234
+ expect(envelope2.docket.targetSubscriber).toBe('subscriber-2');
235
+ expect(envelope1.data).toEqual(envelope2.data);
236
+ });
237
+ it('should route to different queues based on targetQueue', async () => {
238
+ const sub1 = createSubscriber({
239
+ name: 'subscriber-1',
240
+ description: 'Subscriber 1',
241
+ callback: async () => { },
242
+ targetQueue: 'queue-1',
243
+ });
244
+ const sub2 = createSubscriber({
245
+ name: 'subscriber-2',
246
+ description: 'Subscriber 2',
247
+ callback: async () => { },
248
+ targetQueue: 'queue-2',
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
+ expect(calls[0][0]).toBe('test.queue-1');
258
+ expect(calls[1][0]).toBe('test.queue-2');
259
+ });
260
+ it('should send to no subscribers when event is not registered', async () => {
261
+ const event = new OrderPlacedEvent({
262
+ orderId: '456',
263
+ amount: 99.99,
264
+ });
265
+ const result = await fanout.send(OrderPlacedEvent, event);
266
+ expect(result.subscribersSent).toBe(0);
267
+ expect(result.subscribersSkipped).toBe(0);
268
+ expect(result.errors).toHaveLength(0);
269
+ expect(transport.send).not.toHaveBeenCalled();
270
+ });
271
+ });
272
+ describe('metadata merging', () => {
273
+ it('should include event metadata in envelope', async () => {
274
+ const subscriber = createSubscriber({
275
+ name: 'handle-user',
276
+ description: 'Handles user events',
277
+ callback: async () => { },
278
+ });
279
+ schema.register(UserCreatedEventWithMetadata, [subscriber]);
280
+ const event = new UserCreatedEventWithMetadata({ userId: '123', email: 'test@example.com' }, { source: 'api', version: 1 });
281
+ await fanout.send(UserCreatedEventWithMetadata, event);
282
+ const envelope = transport.send.mock
283
+ .calls[0][1];
284
+ expect(envelope.docket.metadata).toEqual({ source: 'api', version: 1 });
285
+ });
286
+ it('should include options metadata in envelope', async () => {
287
+ const subscriber = createSubscriber({
288
+ name: 'handle-user',
289
+ description: 'Handles user events',
290
+ callback: async () => { },
291
+ });
292
+ schema.register(UserCreatedEvent, [subscriber]);
293
+ const event = new UserCreatedEvent({
294
+ userId: '123',
295
+ email: 'test@example.com',
296
+ });
297
+ const options = {
298
+ metadata: { requestId: 'req-123' },
299
+ };
300
+ await fanout.send(UserCreatedEvent, event, options);
301
+ const envelope = transport.send.mock
302
+ .calls[0][1];
303
+ expect(envelope.docket.metadata).toEqual({ requestId: 'req-123' });
304
+ });
305
+ it('should merge event and options metadata with options taking precedence', async () => {
306
+ const subscriber = createSubscriber({
307
+ name: 'handle-user',
308
+ description: 'Handles user events',
309
+ callback: async () => { },
310
+ });
311
+ schema.register(UserCreatedEventWithMetadata, [subscriber]);
312
+ const event = new UserCreatedEventWithMetadata({ userId: '123', email: 'test@example.com' }, { source: 'api', version: 1 });
313
+ const options = {
314
+ metadata: { version: 2, requestId: 'req-123' },
315
+ };
316
+ await fanout.send(UserCreatedEventWithMetadata, event, options);
317
+ const envelope = transport.send.mock
318
+ .calls[0][1];
319
+ expect(envelope.docket.metadata).toEqual({
320
+ source: 'api',
321
+ version: 2,
322
+ requestId: 'req-123',
323
+ });
324
+ });
325
+ it('should include universal metadata from hooks', async () => {
326
+ const hooksWithMetadata = {
327
+ loadUniversalMetadata: async () => ({
328
+ environment: 'test',
329
+ hostname: 'test-host',
330
+ }),
331
+ };
332
+ const hooksInstance = new SafeHooks(hooksWithMetadata);
333
+ const fanoutWithHooks = new FanoutEngine({
334
+ transport,
335
+ schema,
336
+ hooks: hooksInstance,
337
+ namespace: 'test',
338
+ defaultQueue: 'events',
339
+ });
340
+ const subscriber = createSubscriber({
341
+ name: 'handle-user',
342
+ description: 'Handles user events',
343
+ callback: async () => { },
344
+ });
345
+ schema.register(UserCreatedEvent, [subscriber]);
346
+ const event = new UserCreatedEvent({
347
+ userId: '123',
348
+ email: 'test@example.com',
349
+ });
350
+ await fanoutWithHooks.send(UserCreatedEvent, event);
351
+ const envelope = transport.send.mock
352
+ .calls[0][1];
353
+ expect(envelope.docket.metadata).toEqual({
354
+ environment: 'test',
355
+ hostname: 'test-host',
356
+ });
357
+ });
358
+ it('should have empty metadata when no metadata provided', async () => {
359
+ const subscriber = createSubscriber({
360
+ name: 'handle-user',
361
+ description: 'Handles user events',
362
+ callback: async () => { },
363
+ });
364
+ schema.register(UserCreatedEvent, [subscriber]);
365
+ const event = new UserCreatedEvent({
366
+ userId: '123',
367
+ email: 'test@example.com',
368
+ });
369
+ await fanout.send(UserCreatedEvent, event);
370
+ const envelope = transport.send.mock
371
+ .calls[0][1];
372
+ expect(envelope.docket.metadata).toEqual({});
373
+ });
374
+ });
375
+ describe('filtering disabled subscribers', () => {
376
+ it('should send to enabled subscriber', async () => {
377
+ const subscriber = createSubscriber({
378
+ name: 'handle-user',
379
+ description: 'Handles user events',
380
+ callback: async () => { },
381
+ enabled: () => true,
382
+ });
383
+ schema.register(UserCreatedEvent, [subscriber]);
384
+ const event = new UserCreatedEvent({
385
+ userId: '123',
386
+ email: 'test@example.com',
387
+ });
388
+ const result = await fanout.send(UserCreatedEvent, event);
389
+ expect(result.subscribersSent).toBe(1);
390
+ expect(result.subscribersSkipped).toBe(0);
391
+ });
392
+ it('should skip disabled subscriber', async () => {
393
+ const subscriber = createSubscriber({
394
+ name: 'handle-user',
395
+ description: 'Handles user events',
396
+ callback: async () => { },
397
+ enabled: () => false,
398
+ });
399
+ schema.register(UserCreatedEvent, [subscriber]);
400
+ const event = new UserCreatedEvent({
401
+ userId: '123',
402
+ email: 'test@example.com',
403
+ });
404
+ const result = await fanout.send(UserCreatedEvent, event);
405
+ expect(result.subscribersSent).toBe(0);
406
+ expect(result.subscribersSkipped).toBe(1);
407
+ expect(transport.send).not.toHaveBeenCalled();
408
+ });
409
+ it('should filter some subscribers and send to others', async () => {
410
+ const sub1 = createSubscriber({
411
+ name: 'enabled-sub',
412
+ description: 'Enabled subscriber',
413
+ callback: async () => { },
414
+ enabled: () => true,
415
+ });
416
+ const sub2 = createSubscriber({
417
+ name: 'disabled-sub',
418
+ description: 'Disabled subscriber',
419
+ callback: async () => { },
420
+ enabled: () => false,
421
+ });
422
+ const sub3 = createSubscriber({
423
+ name: 'always-enabled-sub',
424
+ description: 'Always enabled subscriber',
425
+ callback: async () => { },
426
+ });
427
+ schema.register(UserCreatedEvent, [sub1, sub2, sub3]);
428
+ const event = new UserCreatedEvent({
429
+ userId: '123',
430
+ email: 'test@example.com',
431
+ });
432
+ const result = await fanout.send(UserCreatedEvent, event);
433
+ expect(result.subscribersSent).toBe(2);
434
+ expect(result.subscribersSkipped).toBe(1);
435
+ expect(transport.send).toHaveBeenCalledTimes(2);
436
+ });
437
+ it('should support async enabled function', async () => {
438
+ const subscriber = createSubscriber({
439
+ name: 'handle-user',
440
+ description: 'Handles user events',
441
+ callback: async () => { },
442
+ enabled: async () => {
443
+ await new Promise((resolve) => setTimeout(resolve, 10));
444
+ return true;
445
+ },
446
+ });
447
+ schema.register(UserCreatedEvent, [subscriber]);
448
+ const event = new UserCreatedEvent({
449
+ userId: '123',
450
+ email: 'test@example.com',
451
+ });
452
+ const result = await fanout.send(UserCreatedEvent, event);
453
+ expect(result.subscribersSent).toBe(1);
454
+ expect(result.subscribersSkipped).toBe(0);
455
+ });
456
+ it('should treat subscriber as enabled if enabled() throws error', async () => {
457
+ const subscriber = createSubscriber({
458
+ name: 'handle-user',
459
+ description: 'Handles user events',
460
+ callback: async () => { },
461
+ enabled: () => {
462
+ throw new Error('Feature flag service down');
463
+ },
464
+ });
465
+ schema.register(UserCreatedEvent, [subscriber]);
466
+ const event = new UserCreatedEvent({
467
+ userId: '123',
468
+ email: 'test@example.com',
469
+ });
470
+ const result = await fanout.send(UserCreatedEvent, event);
471
+ expect(result.subscribersSent).toBe(1);
472
+ expect(result.subscribersSkipped).toBe(0);
473
+ });
474
+ it('should treat subscriber as enabled if no enabled hook provided', async () => {
475
+ const subscriber = createSubscriber({
476
+ name: 'handle-user',
477
+ description: 'Handles user events',
478
+ callback: async () => { },
479
+ });
480
+ schema.register(UserCreatedEvent, [subscriber]);
481
+ const event = new UserCreatedEvent({
482
+ userId: '123',
483
+ email: 'test@example.com',
484
+ });
485
+ const result = await fanout.send(UserCreatedEvent, event);
486
+ expect(result.subscribersSent).toBe(1);
487
+ expect(result.subscribersSkipped).toBe(0);
488
+ });
489
+ it('should work with subscriber stubs', async () => {
490
+ const stub = createSubscriberStub({
491
+ name: 'remote-subscriber',
492
+ description: 'Remote subscriber stub',
493
+ enabled: () => false,
494
+ });
495
+ schema.register(UserCreatedEvent, [stub]);
496
+ const event = new UserCreatedEvent({
497
+ userId: '123',
498
+ email: 'test@example.com',
499
+ });
500
+ const result = await fanout.send(UserCreatedEvent, event);
501
+ expect(result.subscribersSent).toBe(0);
502
+ expect(result.subscribersSkipped).toBe(1);
503
+ });
504
+ });
505
+ describe('error handling', () => {
506
+ it('should capture error when transport.send fails', async () => {
507
+ const sendError = new Error('Network timeout');
508
+ const failingTransport = {
509
+ ...transport,
510
+ send: mock(async () => {
511
+ throw sendError;
512
+ }),
513
+ };
514
+ const fanoutWithFailingTransport = new FanoutEngine({
515
+ transport: failingTransport,
516
+ schema,
517
+ hooks,
518
+ namespace: 'test',
519
+ defaultQueue: 'events',
520
+ });
521
+ const subscriber = createSubscriber({
522
+ name: 'handle-user',
523
+ description: 'Handles user events',
524
+ callback: async () => { },
525
+ });
526
+ schema.register(UserCreatedEvent, [subscriber]);
527
+ const event = new UserCreatedEvent({
528
+ userId: '123',
529
+ email: 'test@example.com',
530
+ });
531
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
532
+ expect(result.subscribersSent).toBe(0);
533
+ expect(result.subscribersSkipped).toBe(0);
534
+ expect(result.errors).toHaveLength(1);
535
+ expect(result.errors[0].subscriberName).toBe('handle-user');
536
+ expect(result.errors[0].queue).toBe('test.events');
537
+ expect(result.errors[0].error).toBeInstanceOf(TransportSendError);
538
+ });
539
+ it('should continue sending to other subscribers after one fails', async () => {
540
+ const failingTransport = {
541
+ ...transport,
542
+ send: mock(async (queue) => {
543
+ if (queue === 'test.queue-1') {
544
+ throw new Error('Queue 1 failed');
545
+ }
546
+ return 'mock';
547
+ }),
548
+ };
549
+ const fanoutWithFailingTransport = new FanoutEngine({
550
+ transport: failingTransport,
551
+ schema,
552
+ hooks,
553
+ namespace: 'test',
554
+ defaultQueue: 'events',
555
+ });
556
+ const sub1 = createSubscriber({
557
+ name: 'subscriber-1',
558
+ description: 'Subscriber 1',
559
+ callback: async () => { },
560
+ targetQueue: 'queue-1',
561
+ });
562
+ const sub2 = createSubscriber({
563
+ name: 'subscriber-2',
564
+ description: 'Subscriber 2',
565
+ callback: async () => { },
566
+ targetQueue: 'queue-2',
567
+ });
568
+ schema.register(UserCreatedEvent, [sub1, sub2]);
569
+ const event = new UserCreatedEvent({
570
+ userId: '123',
571
+ email: 'test@example.com',
572
+ });
573
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
574
+ expect(result.subscribersSent).toBe(1);
575
+ expect(result.subscribersSkipped).toBe(0);
576
+ expect(result.errors).toHaveLength(1);
577
+ expect(result.errors[0].subscriberName).toBe('subscriber-1');
578
+ });
579
+ it('should wrap non-Error throws as Error in TransportSendError', async () => {
580
+ const failingTransport = {
581
+ ...transport,
582
+ send: mock(async () => {
583
+ throw 'string error';
584
+ }),
585
+ };
586
+ const fanoutWithFailingTransport = new FanoutEngine({
587
+ transport: failingTransport,
588
+ schema,
589
+ hooks,
590
+ namespace: 'test',
591
+ defaultQueue: 'events',
592
+ });
593
+ const subscriber = createSubscriber({
594
+ name: 'handle-user',
595
+ description: 'Handles user events',
596
+ callback: async () => { },
597
+ });
598
+ schema.register(UserCreatedEvent, [subscriber]);
599
+ const event = new UserCreatedEvent({
600
+ userId: '123',
601
+ email: 'test@example.com',
602
+ });
603
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
604
+ expect(result.errors).toHaveLength(1);
605
+ expect(result.errors[0].error).toBeInstanceOf(TransportSendError);
606
+ expect(result.errors[0].error.message).toContain('test.events');
607
+ });
608
+ });
609
+ describe('eventsBeingEnqueuedCount tracking', () => {
610
+ it('should increment count during send', async () => {
611
+ let countDuringSend = 0;
612
+ // biome-ignore lint/style/useConst: Variable must be let to be assigned after closure definition
613
+ let fanoutWithTracking;
614
+ const trackingTransport = {
615
+ ...transport,
616
+ send: mock(async () => {
617
+ countDuringSend = fanoutWithTracking.eventsBeingEnqueuedCount;
618
+ return 'mock';
619
+ }),
620
+ };
621
+ fanoutWithTracking = new FanoutEngine({
622
+ transport: trackingTransport,
623
+ schema,
624
+ hooks,
625
+ namespace: 'test',
626
+ defaultQueue: 'events',
627
+ });
628
+ const subscriber = createSubscriber({
629
+ name: 'handle-user',
630
+ description: 'Handles user events',
631
+ callback: async () => { },
632
+ });
633
+ schema.register(UserCreatedEvent, [subscriber]);
634
+ const event = new UserCreatedEvent({
635
+ userId: '123',
636
+ email: 'test@example.com',
637
+ });
638
+ expect(fanoutWithTracking.eventsBeingEnqueuedCount).toBe(0);
639
+ await fanoutWithTracking.send(UserCreatedEvent, event);
640
+ expect(countDuringSend).toBe(1);
641
+ });
642
+ it('should decrement count after send completes', async () => {
643
+ const subscriber = createSubscriber({
644
+ name: 'handle-user',
645
+ description: 'Handles user events',
646
+ callback: async () => { },
647
+ });
648
+ schema.register(UserCreatedEvent, [subscriber]);
649
+ const event = new UserCreatedEvent({
650
+ userId: '123',
651
+ email: 'test@example.com',
652
+ });
653
+ expect(fanout.eventsBeingEnqueuedCount).toBe(0);
654
+ await fanout.send(UserCreatedEvent, event);
655
+ expect(fanout.eventsBeingEnqueuedCount).toBe(0);
656
+ });
657
+ it('should decrement count even when send fails', async () => {
658
+ const failingTransport = {
659
+ ...transport,
660
+ send: mock(async () => {
661
+ throw new Error('Send failed');
662
+ }),
663
+ };
664
+ const fanoutWithFailingTransport = new FanoutEngine({
665
+ transport: failingTransport,
666
+ schema,
667
+ hooks,
668
+ namespace: 'test',
669
+ defaultQueue: 'events',
670
+ });
671
+ const subscriber = createSubscriber({
672
+ name: 'handle-user',
673
+ description: 'Handles user events',
674
+ callback: async () => { },
675
+ });
676
+ schema.register(UserCreatedEvent, [subscriber]);
677
+ const event = new UserCreatedEvent({
678
+ userId: '123',
679
+ email: 'test@example.com',
680
+ });
681
+ expect(fanoutWithFailingTransport.eventsBeingEnqueuedCount).toBe(0);
682
+ await fanoutWithFailingTransport.send(UserCreatedEvent, event);
683
+ expect(fanoutWithFailingTransport.eventsBeingEnqueuedCount).toBe(0);
684
+ });
685
+ it('should track multiple sequential sends to subscribers', async () => {
686
+ const sub1 = createSubscriber({
687
+ name: 'subscriber-1',
688
+ description: 'Subscriber 1',
689
+ callback: async () => { },
690
+ });
691
+ const sub2 = createSubscriber({
692
+ name: 'subscriber-2',
693
+ description: 'Subscriber 2',
694
+ callback: async () => { },
695
+ });
696
+ schema.register(UserCreatedEvent, [sub1, sub2]);
697
+ let maxCount = 0;
698
+ const counts = [];
699
+ // biome-ignore lint/style/useConst: Variable must be let to be assigned after closure definition
700
+ let fanoutWithTracking;
701
+ const trackingTransport = {
702
+ ...transport,
703
+ send: mock(async () => {
704
+ const current = fanoutWithTracking.eventsBeingEnqueuedCount;
705
+ counts.push(current);
706
+ maxCount = Math.max(maxCount, current);
707
+ await new Promise((resolve) => setTimeout(resolve, 10));
708
+ return 'mock';
709
+ }),
710
+ };
711
+ fanoutWithTracking = new FanoutEngine({
712
+ transport: trackingTransport,
713
+ schema,
714
+ hooks,
715
+ namespace: 'test',
716
+ defaultQueue: 'events',
717
+ });
718
+ const event = new UserCreatedEvent({
719
+ userId: '123',
720
+ email: 'test@example.com',
721
+ });
722
+ await fanoutWithTracking.send(UserCreatedEvent, event);
723
+ expect(maxCount).toBe(1);
724
+ expect(counts).toEqual([1, 1]);
725
+ expect(fanoutWithTracking.eventsBeingEnqueuedCount).toBe(0);
726
+ });
727
+ });
728
+ describe('correlation ID propagation', () => {
729
+ it('should include correlation ID in envelope when provided', async () => {
730
+ const subscriber = createSubscriber({
731
+ name: 'handle-user',
732
+ description: 'Handles user events',
733
+ callback: async () => { },
734
+ });
735
+ schema.register(UserCreatedEvent, [subscriber]);
736
+ const event = new UserCreatedEvent({
737
+ userId: '123',
738
+ email: 'test@example.com',
739
+ });
740
+ const options = {
741
+ correlationId: 'corr-123',
742
+ };
743
+ await fanout.send(UserCreatedEvent, event, options);
744
+ const envelope = transport.send.mock
745
+ .calls[0][1];
746
+ expect(envelope.docket.correlationId).toBe('corr-123');
747
+ });
748
+ it('should propagate same correlation ID to all subscribers', async () => {
749
+ const sub1 = createSubscriber({
750
+ name: 'subscriber-1',
751
+ description: 'Subscriber 1',
752
+ callback: async () => { },
753
+ });
754
+ const sub2 = createSubscriber({
755
+ name: 'subscriber-2',
756
+ description: 'Subscriber 2',
757
+ callback: async () => { },
758
+ });
759
+ schema.register(UserCreatedEvent, [sub1, sub2]);
760
+ const event = new UserCreatedEvent({
761
+ userId: '123',
762
+ email: 'test@example.com',
763
+ });
764
+ const options = {
765
+ correlationId: 'corr-456',
766
+ };
767
+ await fanout.send(UserCreatedEvent, event, options);
768
+ const calls = transport.send.mock.calls;
769
+ const envelope1 = calls[0][1];
770
+ const envelope2 = calls[1][1];
771
+ expect(envelope1.docket.correlationId).toBe('corr-456');
772
+ expect(envelope2.docket.correlationId).toBe('corr-456');
773
+ });
774
+ it('should have undefined correlation ID when not provided', async () => {
775
+ const subscriber = createSubscriber({
776
+ name: 'handle-user',
777
+ description: 'Handles user events',
778
+ callback: async () => { },
779
+ });
780
+ schema.register(UserCreatedEvent, [subscriber]);
781
+ const event = new UserCreatedEvent({
782
+ userId: '123',
783
+ email: 'test@example.com',
784
+ });
785
+ await fanout.send(UserCreatedEvent, event);
786
+ const envelope = transport.send.mock
787
+ .calls[0][1];
788
+ expect(envelope.docket.correlationId).toBeUndefined();
789
+ });
790
+ });
791
+ describe('hook invocation', () => {
792
+ it('should call onEnqueueSuccess after successful send', async () => {
793
+ const onEnqueueSuccess = mock(async () => { });
794
+ const hooksWithSuccess = {
795
+ onEnqueueSuccess,
796
+ };
797
+ const hooksInstance = new SafeHooks(hooksWithSuccess);
798
+ const fanoutWithHooks = new FanoutEngine({
799
+ transport,
800
+ schema,
801
+ hooks: hooksInstance,
802
+ namespace: 'test',
803
+ defaultQueue: 'events',
804
+ });
805
+ const subscriber = createSubscriber({
806
+ name: 'handle-user',
807
+ description: 'Handles user events',
808
+ callback: async () => { },
809
+ });
810
+ schema.register(UserCreatedEvent, [subscriber]);
811
+ const event = new UserCreatedEvent({
812
+ userId: '123',
813
+ email: 'test@example.com',
814
+ });
815
+ await fanoutWithHooks.send(UserCreatedEvent, event);
816
+ expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
817
+ expect(onEnqueueSuccess).toHaveBeenCalledWith({
818
+ envelope: expect.any(Object),
819
+ queue: 'test.events',
820
+ transport: 'mock',
821
+ });
822
+ const calls = onEnqueueSuccess.mock.calls;
823
+ const callArgs = calls[0][0];
824
+ expect(callArgs.envelope.docket.eventKey).toBe('user.created');
825
+ expect(callArgs.envelope.docket.targetSubscriber).toBe('handle-user');
826
+ });
827
+ it('should call onEnqueueError after failed send', async () => {
828
+ const onEnqueueError = mock(async () => { });
829
+ const hooksWithError = {
830
+ onEnqueueError,
831
+ };
832
+ const hooksInstance = new SafeHooks(hooksWithError);
833
+ const failingTransport = {
834
+ ...transport,
835
+ send: mock(async () => {
836
+ throw new Error('Send failed');
837
+ }),
838
+ };
839
+ const fanoutWithHooks = new FanoutEngine({
840
+ transport: failingTransport,
841
+ schema,
842
+ hooks: hooksInstance,
843
+ namespace: 'test',
844
+ defaultQueue: 'events',
845
+ });
846
+ const subscriber = createSubscriber({
847
+ name: 'handle-user',
848
+ description: 'Handles user events',
849
+ callback: async () => { },
850
+ });
851
+ schema.register(UserCreatedEvent, [subscriber]);
852
+ const event = new UserCreatedEvent({
853
+ userId: '123',
854
+ email: 'test@example.com',
855
+ });
856
+ await fanoutWithHooks.send(UserCreatedEvent, event);
857
+ expect(onEnqueueError).toHaveBeenCalledTimes(1);
858
+ expect(onEnqueueError).toHaveBeenCalledWith({
859
+ envelope: expect.any(Object),
860
+ error: expect.any(TransportSendError),
861
+ transport: 'mock',
862
+ });
863
+ const calls = onEnqueueError.mock.calls;
864
+ const callArgs = calls[0][0];
865
+ expect(callArgs.envelope.docket.eventKey).toBe('user.created');
866
+ expect(callArgs.error).toBeInstanceOf(TransportSendError);
867
+ });
868
+ it('should call hooks for each subscriber', async () => {
869
+ const onEnqueueSuccess = mock(async () => { });
870
+ const hooksWithSuccess = {
871
+ onEnqueueSuccess,
872
+ };
873
+ const hooksInstance = new SafeHooks(hooksWithSuccess);
874
+ const fanoutWithHooks = new FanoutEngine({
875
+ transport,
876
+ schema,
877
+ hooks: hooksInstance,
878
+ namespace: 'test',
879
+ defaultQueue: 'events',
880
+ });
881
+ const sub1 = createSubscriber({
882
+ name: 'subscriber-1',
883
+ description: 'Subscriber 1',
884
+ callback: async () => { },
885
+ });
886
+ const sub2 = createSubscriber({
887
+ name: 'subscriber-2',
888
+ description: 'Subscriber 2',
889
+ callback: async () => { },
890
+ });
891
+ const sub3 = createSubscriber({
892
+ name: 'subscriber-3',
893
+ description: 'Subscriber 3',
894
+ callback: async () => { },
895
+ });
896
+ schema.register(UserCreatedEvent, [sub1, sub2, sub3]);
897
+ const event = new UserCreatedEvent({
898
+ userId: '123',
899
+ email: 'test@example.com',
900
+ });
901
+ await fanoutWithHooks.send(UserCreatedEvent, event);
902
+ expect(onEnqueueSuccess).toHaveBeenCalledTimes(3);
903
+ });
904
+ it('should not call onEnqueueSuccess when subscriber is disabled', async () => {
905
+ const onEnqueueSuccess = mock(async () => { });
906
+ const hooksWithSuccess = {
907
+ onEnqueueSuccess,
908
+ };
909
+ const hooksInstance = new SafeHooks(hooksWithSuccess);
910
+ const fanoutWithHooks = new FanoutEngine({
911
+ transport,
912
+ schema,
913
+ hooks: hooksInstance,
914
+ namespace: 'test',
915
+ defaultQueue: 'events',
916
+ });
917
+ const subscriber = createSubscriber({
918
+ name: 'handle-user',
919
+ description: 'Handles user events',
920
+ callback: async () => { },
921
+ enabled: () => false,
922
+ });
923
+ schema.register(UserCreatedEvent, [subscriber]);
924
+ const event = new UserCreatedEvent({
925
+ userId: '123',
926
+ email: 'test@example.com',
927
+ });
928
+ await fanoutWithHooks.send(UserCreatedEvent, event);
929
+ expect(onEnqueueSuccess).not.toHaveBeenCalled();
930
+ });
931
+ it('should continue processing even if hook throws error', async () => {
932
+ const onEnqueueSuccess = mock(async () => {
933
+ throw new Error('Hook error');
934
+ });
935
+ const hooksWithSuccess = {
936
+ onEnqueueSuccess,
937
+ };
938
+ const hooksInstance = new SafeHooks(hooksWithSuccess);
939
+ const fanoutWithHooks = new FanoutEngine({
940
+ transport,
941
+ schema,
942
+ hooks: hooksInstance,
943
+ namespace: 'test',
944
+ defaultQueue: 'events',
945
+ });
946
+ const subscriber = createSubscriber({
947
+ name: 'handle-user',
948
+ description: 'Handles user events',
949
+ callback: async () => { },
950
+ });
951
+ schema.register(UserCreatedEvent, [subscriber]);
952
+ const event = new UserCreatedEvent({
953
+ userId: '123',
954
+ email: 'test@example.com',
955
+ });
956
+ const result = await fanoutWithHooks.send(UserCreatedEvent, event);
957
+ expect(result.subscribersSent).toBe(1);
958
+ expect(result.errors).toHaveLength(0);
959
+ expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
960
+ });
961
+ });
962
+ describe('delay options', () => {
963
+ it('should pass delay option to transport', async () => {
964
+ const subscriber = createSubscriber({
965
+ name: 'handle-user',
966
+ description: 'Handles user events',
967
+ callback: async () => { },
968
+ });
969
+ schema.register(UserCreatedEvent, [subscriber]);
970
+ const event = new UserCreatedEvent({
971
+ userId: '123',
972
+ email: 'test@example.com',
973
+ });
974
+ const options = {
975
+ delayMs: 5000,
976
+ };
977
+ await fanout.send(UserCreatedEvent, event, options);
978
+ expect(transport.send).toHaveBeenCalledWith('test.events', expect.any(Object), { delay: 5000 });
979
+ });
980
+ it('should include delay in envelope', async () => {
981
+ const subscriber = createSubscriber({
982
+ name: 'handle-user',
983
+ description: 'Handles user events',
984
+ callback: async () => { },
985
+ });
986
+ schema.register(UserCreatedEvent, [subscriber]);
987
+ const event = new UserCreatedEvent({
988
+ userId: '123',
989
+ email: 'test@example.com',
990
+ });
991
+ const options = {
992
+ delayMs: 3000,
993
+ };
994
+ await fanout.send(UserCreatedEvent, event, options);
995
+ const envelope = transport.send.mock
996
+ .calls[0][1];
997
+ expect(envelope.docket.scheduledFor).toBeDefined();
998
+ if (envelope.docket.scheduledFor) {
999
+ const scheduledTime = new Date(envelope.docket.scheduledFor).getTime();
1000
+ const now = Date.now();
1001
+ expect(scheduledTime).toBeGreaterThan(now);
1002
+ expect(scheduledTime).toBeLessThan(now + 4000);
1003
+ }
1004
+ });
1005
+ it('should not pass delay option when delayMs is undefined', async () => {
1006
+ const subscriber = createSubscriber({
1007
+ name: 'handle-user',
1008
+ description: 'Handles user events',
1009
+ callback: async () => { },
1010
+ });
1011
+ schema.register(UserCreatedEvent, [subscriber]);
1012
+ const event = new UserCreatedEvent({
1013
+ userId: '123',
1014
+ email: 'test@example.com',
1015
+ });
1016
+ await fanout.send(UserCreatedEvent, event);
1017
+ expect(transport.send).toHaveBeenCalledWith('test.events', expect.any(Object), undefined);
1018
+ });
1019
+ });
1020
+ describe('subscriber importance', () => {
1021
+ it('should use subscriber importance when specified', async () => {
1022
+ const subscriber = createSubscriber({
1023
+ name: 'handle-user',
1024
+ description: 'Handles user events',
1025
+ callback: async () => { },
1026
+ importance: 'must-investigate',
1027
+ });
1028
+ schema.register(UserCreatedEvent, [subscriber]);
1029
+ const event = new UserCreatedEvent({
1030
+ userId: '123',
1031
+ email: 'test@example.com',
1032
+ });
1033
+ await fanout.send(UserCreatedEvent, event);
1034
+ const envelope = transport.send.mock
1035
+ .calls[0][1];
1036
+ expect(envelope.docket.importance).toBe('must-investigate');
1037
+ });
1038
+ it('should default to should-investigate when importance not specified', async () => {
1039
+ const subscriber = createSubscriber({
1040
+ name: 'handle-user',
1041
+ description: 'Handles user events',
1042
+ callback: async () => { },
1043
+ });
1044
+ schema.register(UserCreatedEvent, [subscriber]);
1045
+ const event = new UserCreatedEvent({
1046
+ userId: '123',
1047
+ email: 'test@example.com',
1048
+ });
1049
+ await fanout.send(UserCreatedEvent, event);
1050
+ const envelope = transport.send.mock
1051
+ .calls[0][1];
1052
+ expect(envelope.docket.importance).toBe('should-investigate');
1053
+ });
1054
+ });
1055
+ });