@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,1065 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import { CodecDecodeError } from '../codec/index.js';
3
+ import { SubscriberIsStubError, SubscriberNotRegisteredError, } from '../errors/index.js';
4
+ import { StandardRetryPolicy } from '../retry/index.js';
5
+ import { createEnvelope } from '../types/index.js';
6
+ import { ProcessingPipeline } from './pipeline.js';
7
+ describe('ProcessingPipeline', () => {
8
+ describe('creation', () => {
9
+ it('should create via static factory method', () => {
10
+ const config = createMockConfig();
11
+ const pipeline = new ProcessingPipeline(config);
12
+ expect(pipeline).toBeInstanceOf(ProcessingPipeline);
13
+ });
14
+ it('should create via constructor', () => {
15
+ const config = createMockConfig();
16
+ const pipeline = new ProcessingPipeline(config);
17
+ expect(pipeline).toBeInstanceOf(ProcessingPipeline);
18
+ });
19
+ });
20
+ describe('decode operation', () => {
21
+ it('should decode raw message via codec', async () => {
22
+ const envelope = createTestEnvelope();
23
+ const rawMessage = new TextEncoder().encode(JSON.stringify(envelope));
24
+ const config = createMockConfig({
25
+ codec: {
26
+ decode: mock(() => envelope),
27
+ },
28
+ schema: {
29
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
30
+ getExecutableSubscriber: mock(() => ({
31
+ name: 'test-subscriber',
32
+ description: 'Test subscriber',
33
+ idempotent: 'unknown',
34
+ callback: mock(async () => { }),
35
+ })),
36
+ },
37
+ });
38
+ const pipeline = new ProcessingPipeline(config);
39
+ const receipt = createReceipt();
40
+ await pipeline.process(rawMessage, receipt);
41
+ expect(config.codec.decode).toHaveBeenCalledWith(rawMessage);
42
+ });
43
+ it('should handle decode errors and complete message', async () => {
44
+ const rawMessage = new Uint8Array([1, 2, 3]);
45
+ const decodeError = new CodecDecodeError('Invalid JSON');
46
+ const completeMock = mock(async () => { });
47
+ const onDecodeErrorMock = mock(async () => { });
48
+ const config = createMockConfig({
49
+ codec: {
50
+ decode: mock(() => {
51
+ throw decodeError;
52
+ }),
53
+ },
54
+ transport: {
55
+ complete: completeMock,
56
+ },
57
+ hooks: {
58
+ onDecodeError: onDecodeErrorMock,
59
+ },
60
+ });
61
+ const pipeline = new ProcessingPipeline(config);
62
+ const receipt = createReceipt({ sourceQueue: 'test-queue' });
63
+ const result = await pipeline.process(rawMessage, receipt);
64
+ expect(result.success).toBe(false);
65
+ expect(result.error).toBeInstanceOf(CodecDecodeError);
66
+ expect(completeMock).toHaveBeenCalledWith(receipt);
67
+ expect(onDecodeErrorMock).toHaveBeenCalledWith({
68
+ error: decodeError,
69
+ rawMessage,
70
+ sourceQueue: 'test-queue',
71
+ transport: 'mock',
72
+ });
73
+ });
74
+ it('should wrap non-CodecDecodeError exceptions', async () => {
75
+ const rawMessage = new Uint8Array([1, 2, 3]);
76
+ const originalError = new Error('Something went wrong');
77
+ const completeMock = mock(async () => { });
78
+ const onDecodeErrorMock = mock(async () => { });
79
+ const config = createMockConfig({
80
+ codec: {
81
+ decode: mock(() => {
82
+ throw originalError;
83
+ }),
84
+ },
85
+ transport: {
86
+ complete: completeMock,
87
+ },
88
+ hooks: {
89
+ onDecodeError: onDecodeErrorMock,
90
+ },
91
+ });
92
+ const pipeline = new ProcessingPipeline(config);
93
+ const receipt = createReceipt();
94
+ const result = await pipeline.process(rawMessage, receipt);
95
+ expect(result.success).toBe(false);
96
+ expect(result.error).toBeInstanceOf(CodecDecodeError);
97
+ expect(result.error.message).toBe('Unknown decode error');
98
+ expect(result.error.cause).toBe(originalError);
99
+ expect(completeMock).toHaveBeenCalled();
100
+ expect(onDecodeErrorMock).toHaveBeenCalled();
101
+ });
102
+ });
103
+ describe('subscriber lookup', () => {
104
+ it('should lookup subscriber from registry', async () => {
105
+ const envelope = createTestEnvelope();
106
+ const subscriberDef = createSubscriberDefinition();
107
+ const getSubscriberDefinitionMock = mock(() => subscriberDef);
108
+ const getExecutableSubscriberMock = mock(() => ({
109
+ name: 'test-subscriber',
110
+ description: 'Test subscriber',
111
+ idempotent: 'unknown',
112
+ callback: mock(async () => { }),
113
+ }));
114
+ const config = createMockConfig({
115
+ codec: {
116
+ decode: mock(() => envelope),
117
+ },
118
+ schema: {
119
+ getSubscriberDefinition: getSubscriberDefinitionMock,
120
+ getExecutableSubscriber: getExecutableSubscriberMock,
121
+ },
122
+ });
123
+ const pipeline = new ProcessingPipeline(config);
124
+ const receipt = createReceipt();
125
+ await pipeline.process(new Uint8Array(), receipt);
126
+ expect(getSubscriberDefinitionMock).toHaveBeenCalledWith(envelope.docket.eventKey, envelope.docket.targetSubscriber);
127
+ expect(getExecutableSubscriberMock).toHaveBeenCalledWith(envelope.docket.eventKey, envelope.docket.targetSubscriber);
128
+ });
129
+ it('should retry missing subscriber definition (deployment timing)', async () => {
130
+ const envelope = createTestEnvelope();
131
+ const sendMock = mock(async () => 'mock-id');
132
+ const completeMock = mock(async () => { });
133
+ const config = createMockConfig({
134
+ codec: {
135
+ decode: mock(() => envelope),
136
+ },
137
+ schema: {
138
+ getSubscriberDefinition: mock(() => undefined),
139
+ },
140
+ transport: {
141
+ send: sendMock,
142
+ complete: completeMock,
143
+ },
144
+ retryPolicy: new StandardRetryPolicy(),
145
+ });
146
+ const pipeline = new ProcessingPipeline(config);
147
+ const receipt = createReceipt();
148
+ const result = await pipeline.process(new Uint8Array(), receipt);
149
+ expect(result.success).toBe(false);
150
+ expect(result.error).toBeInstanceOf(SubscriberNotRegisteredError);
151
+ expect(result.envelope).toBeDefined();
152
+ expect(result.decision?.action).toBe('retry');
153
+ expect(sendMock).toHaveBeenCalled();
154
+ expect(completeMock).toHaveBeenCalled();
155
+ });
156
+ it('should dead-letter missing subscriber after max attempts', async () => {
157
+ const envelope = createTestEnvelope();
158
+ const sendToDeadLetterMock = mock(async () => { });
159
+ const config = createMockConfig({
160
+ codec: {
161
+ decode: mock(() => envelope),
162
+ },
163
+ schema: {
164
+ getSubscriberDefinition: mock(() => undefined),
165
+ },
166
+ transport: {
167
+ sendToDeadLetter: sendToDeadLetterMock,
168
+ complete: mock(async () => { }),
169
+ },
170
+ retryPolicy: new StandardRetryPolicy(),
171
+ });
172
+ const pipeline = new ProcessingPipeline(config);
173
+ const receipt = createReceipt({ attemptNumber: 3 }); // At max attempts
174
+ const result = await pipeline.process(new Uint8Array(), receipt);
175
+ expect(result.success).toBe(false);
176
+ expect(result.error).toBeInstanceOf(SubscriberNotRegisteredError);
177
+ expect(result.decision?.action).toBe('dead-letter');
178
+ expect(sendToDeadLetterMock).toHaveBeenCalled();
179
+ });
180
+ it('should retry subscriber stub (deployment timing)', async () => {
181
+ const envelope = createTestEnvelope();
182
+ const subscriberDef = createSubscriberDefinition();
183
+ const sendMock = mock(async () => 'mock-id');
184
+ const completeMock = mock(async () => { });
185
+ const config = createMockConfig({
186
+ codec: {
187
+ decode: mock(() => envelope),
188
+ },
189
+ schema: {
190
+ getSubscriberDefinition: mock(() => subscriberDef),
191
+ getExecutableSubscriber: mock(() => undefined),
192
+ },
193
+ transport: {
194
+ send: sendMock,
195
+ complete: completeMock,
196
+ },
197
+ retryPolicy: new StandardRetryPolicy(),
198
+ });
199
+ const pipeline = new ProcessingPipeline(config);
200
+ const receipt = createReceipt();
201
+ const result = await pipeline.process(new Uint8Array(), receipt);
202
+ expect(result.success).toBe(false);
203
+ expect(result.error).toBeInstanceOf(SubscriberIsStubError);
204
+ expect(result.subscriber).toEqual(subscriberDef);
205
+ expect(result.decision?.action).toBe('retry');
206
+ expect(sendMock).toHaveBeenCalled();
207
+ expect(completeMock).toHaveBeenCalled();
208
+ });
209
+ it('should dead-letter subscriber stub after max attempts', async () => {
210
+ const envelope = createTestEnvelope();
211
+ const subscriberDef = createSubscriberDefinition();
212
+ const sendToDeadLetterMock = mock(async () => { });
213
+ const config = createMockConfig({
214
+ codec: {
215
+ decode: mock(() => envelope),
216
+ },
217
+ schema: {
218
+ getSubscriberDefinition: mock(() => subscriberDef),
219
+ getExecutableSubscriber: mock(() => undefined),
220
+ },
221
+ transport: {
222
+ sendToDeadLetter: sendToDeadLetterMock,
223
+ complete: mock(async () => { }),
224
+ },
225
+ retryPolicy: new StandardRetryPolicy(),
226
+ });
227
+ const pipeline = new ProcessingPipeline(config);
228
+ const receipt = createReceipt({ attemptNumber: 3 }); // At max attempts
229
+ const result = await pipeline.process(new Uint8Array(), receipt);
230
+ expect(result.success).toBe(false);
231
+ expect(result.error).toBeInstanceOf(SubscriberIsStubError);
232
+ expect(result.decision?.action).toBe('dead-letter');
233
+ expect(sendToDeadLetterMock).toHaveBeenCalled();
234
+ });
235
+ });
236
+ describe('callback execution', () => {
237
+ it('should execute subscriber callback successfully', async () => {
238
+ const envelope = createTestEnvelope();
239
+ const callbackMock = mock(async () => { });
240
+ const config = createMockConfig({
241
+ codec: {
242
+ decode: mock(() => envelope),
243
+ },
244
+ schema: {
245
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
246
+ getExecutableSubscriber: mock(() => ({
247
+ name: 'test-subscriber',
248
+ description: 'Test subscriber',
249
+ idempotent: 'unknown',
250
+ callback: callbackMock,
251
+ })),
252
+ },
253
+ });
254
+ const pipeline = new ProcessingPipeline(config);
255
+ const receipt = createReceipt();
256
+ const result = await pipeline.process(new Uint8Array(), receipt);
257
+ expect(result.success).toBe(true);
258
+ expect(callbackMock).toHaveBeenCalledWith(envelope);
259
+ });
260
+ it('should handle callback throwing error', async () => {
261
+ const envelope = createTestEnvelope();
262
+ const error = new Error('Processing failed');
263
+ const callbackMock = mock(async () => {
264
+ throw error;
265
+ });
266
+ const config = createMockConfig({
267
+ codec: {
268
+ decode: mock(() => envelope),
269
+ },
270
+ schema: {
271
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
272
+ getExecutableSubscriber: mock(() => ({
273
+ name: 'test-subscriber',
274
+ description: 'Test subscriber',
275
+ idempotent: 'unknown',
276
+ callback: callbackMock,
277
+ })),
278
+ },
279
+ retryPolicy: {
280
+ shouldRetry: mock(() => ({
281
+ action: 'dead-letter',
282
+ queue: 'undeliverable',
283
+ reason: 'error',
284
+ })),
285
+ },
286
+ });
287
+ const pipeline = new ProcessingPipeline(config);
288
+ const receipt = createReceipt();
289
+ const result = await pipeline.process(new Uint8Array(), receipt);
290
+ expect(result.success).toBe(false);
291
+ expect(result.error).toBe(error);
292
+ });
293
+ it('should wrap non-Error exceptions as Error', async () => {
294
+ const envelope = createTestEnvelope();
295
+ const callbackMock = mock(async () => {
296
+ throw 'string error';
297
+ });
298
+ const config = createMockConfig({
299
+ codec: {
300
+ decode: mock(() => envelope),
301
+ },
302
+ schema: {
303
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
304
+ getExecutableSubscriber: mock(() => ({
305
+ name: 'test-subscriber',
306
+ description: 'Test subscriber',
307
+ idempotent: 'unknown',
308
+ callback: callbackMock,
309
+ })),
310
+ },
311
+ retryPolicy: {
312
+ shouldRetry: mock(() => ({
313
+ action: 'discard',
314
+ reason: 'error',
315
+ })),
316
+ },
317
+ });
318
+ const pipeline = new ProcessingPipeline(config);
319
+ const receipt = createReceipt();
320
+ const result = await pipeline.process(new Uint8Array(), receipt);
321
+ expect(result.success).toBe(false);
322
+ expect(result.error).toBeInstanceOf(Error);
323
+ expect(result.error?.message).toBe('string error');
324
+ });
325
+ });
326
+ describe('hook invocation', () => {
327
+ it('should call onWorkerWrap with execute function', async () => {
328
+ const envelope = createTestEnvelope();
329
+ const subscriberDef = createSubscriberDefinition();
330
+ const callbackMock = mock(async () => { });
331
+ let executeCalled = false;
332
+ const onWorkerWrapMock = mock(async (_envelope, _subscriber, execute) => {
333
+ await execute();
334
+ executeCalled = true;
335
+ });
336
+ const config = createMockConfig({
337
+ codec: {
338
+ decode: mock(() => envelope),
339
+ },
340
+ schema: {
341
+ getSubscriberDefinition: mock(() => subscriberDef),
342
+ getExecutableSubscriber: mock(() => ({
343
+ name: 'test-subscriber',
344
+ description: 'Test subscriber',
345
+ idempotent: 'unknown',
346
+ callback: callbackMock,
347
+ })),
348
+ },
349
+ hooks: {
350
+ onWorkerWrap: onWorkerWrapMock,
351
+ },
352
+ });
353
+ const pipeline = new ProcessingPipeline(config);
354
+ const receipt = createReceipt();
355
+ await pipeline.process(new Uint8Array(), receipt);
356
+ expect(onWorkerWrapMock).toHaveBeenCalledWith(envelope, subscriberDef, expect.any(Function));
357
+ expect(executeCalled).toBe(true);
358
+ expect(callbackMock).toHaveBeenCalled();
359
+ });
360
+ it('should call onWorkerBeforeProcess before callback', async () => {
361
+ const envelope = createTestEnvelope();
362
+ const subscriberDef = createSubscriberDefinition();
363
+ const callOrder = [];
364
+ const onWorkerBeforeProcessMock = mock(async () => {
365
+ callOrder.push('before');
366
+ });
367
+ const callbackMock = mock(async () => {
368
+ callOrder.push('callback');
369
+ });
370
+ const config = createMockConfig({
371
+ codec: {
372
+ decode: mock(() => envelope),
373
+ },
374
+ schema: {
375
+ getSubscriberDefinition: mock(() => subscriberDef),
376
+ getExecutableSubscriber: mock(() => ({
377
+ name: 'test-subscriber',
378
+ description: 'Test subscriber',
379
+ idempotent: 'unknown',
380
+ callback: callbackMock,
381
+ })),
382
+ },
383
+ hooks: {
384
+ onWorkerBeforeProcess: onWorkerBeforeProcessMock,
385
+ },
386
+ });
387
+ const pipeline = new ProcessingPipeline(config);
388
+ const receipt = createReceipt();
389
+ await pipeline.process(new Uint8Array(), receipt);
390
+ expect(onWorkerBeforeProcessMock).toHaveBeenCalledWith(envelope, subscriberDef);
391
+ expect(callOrder).toEqual(['before', 'callback']);
392
+ });
393
+ it('should call onWorkerSuccess after successful callback', async () => {
394
+ const envelope = createTestEnvelope();
395
+ const subscriberDef = createSubscriberDefinition();
396
+ const onWorkerSuccessMock = mock(async () => { });
397
+ const config = createMockConfig({
398
+ codec: {
399
+ decode: mock(() => envelope),
400
+ },
401
+ schema: {
402
+ getSubscriberDefinition: mock(() => subscriberDef),
403
+ getExecutableSubscriber: mock(() => ({
404
+ name: 'test-subscriber',
405
+ description: 'Test subscriber',
406
+ idempotent: 'unknown',
407
+ callback: mock(async () => { }),
408
+ })),
409
+ },
410
+ hooks: {
411
+ onWorkerSuccess: onWorkerSuccessMock,
412
+ },
413
+ });
414
+ const pipeline = new ProcessingPipeline(config);
415
+ const receipt = createReceipt();
416
+ await pipeline.process(new Uint8Array(), receipt);
417
+ expect(onWorkerSuccessMock).toHaveBeenCalledWith({
418
+ envelope,
419
+ subscriber: subscriberDef,
420
+ result: undefined,
421
+ durationMs: expect.any(Number),
422
+ transport: 'mock',
423
+ });
424
+ });
425
+ it('should call onWorkerError after failed callback', async () => {
426
+ const envelope = createTestEnvelope();
427
+ const subscriberDef = createSubscriberDefinition();
428
+ const error = new Error('Processing failed');
429
+ const decision = {
430
+ action: 'retry',
431
+ delay: 1000,
432
+ };
433
+ const onWorkerErrorMock = mock(async () => { });
434
+ const config = createMockConfig({
435
+ codec: {
436
+ decode: mock(() => envelope),
437
+ },
438
+ schema: {
439
+ getSubscriberDefinition: mock(() => subscriberDef),
440
+ getExecutableSubscriber: mock(() => ({
441
+ name: 'test-subscriber',
442
+ description: 'Test subscriber',
443
+ idempotent: 'unknown',
444
+ callback: mock(async () => {
445
+ throw error;
446
+ }),
447
+ })),
448
+ },
449
+ retryPolicy: {
450
+ shouldRetry: mock(() => decision),
451
+ },
452
+ hooks: {
453
+ onWorkerError: onWorkerErrorMock,
454
+ },
455
+ });
456
+ const pipeline = new ProcessingPipeline(config);
457
+ const receipt = createReceipt();
458
+ await pipeline.process(new Uint8Array(), receipt);
459
+ expect(onWorkerErrorMock).toHaveBeenCalledWith({
460
+ envelope,
461
+ subscriber: subscriberDef,
462
+ error,
463
+ durationMs: expect.any(Number),
464
+ decision,
465
+ transport: 'mock',
466
+ });
467
+ });
468
+ });
469
+ describe('message completion', () => {
470
+ it('should complete message after successful processing', async () => {
471
+ const envelope = createTestEnvelope();
472
+ const completeMock = mock(async () => { });
473
+ const config = createMockConfig({
474
+ codec: {
475
+ decode: mock(() => envelope),
476
+ },
477
+ schema: {
478
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
479
+ getExecutableSubscriber: mock(() => ({
480
+ name: 'test-subscriber',
481
+ description: 'Test subscriber',
482
+ idempotent: 'unknown',
483
+ callback: mock(async () => { }),
484
+ })),
485
+ },
486
+ transport: {
487
+ complete: completeMock,
488
+ },
489
+ });
490
+ const pipeline = new ProcessingPipeline(config);
491
+ const receipt = createReceipt();
492
+ await pipeline.process(new Uint8Array(), receipt);
493
+ expect(completeMock).toHaveBeenCalledWith(receipt);
494
+ });
495
+ it('should not complete message before handling retry decision', async () => {
496
+ const envelope = createTestEnvelope();
497
+ const completeMock = mock(async () => { });
498
+ const sendMock = mock(async () => 'mock');
499
+ let completeCalledAfterSend = false;
500
+ sendMock.mockImplementation(async () => {
501
+ if (completeMock.mock.calls.length > 0) {
502
+ completeCalledAfterSend = false;
503
+ }
504
+ else {
505
+ completeCalledAfterSend = true;
506
+ }
507
+ return 'mock';
508
+ });
509
+ const config = createMockConfig({
510
+ codec: {
511
+ decode: mock(() => envelope),
512
+ },
513
+ schema: {
514
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
515
+ getExecutableSubscriber: mock(() => ({
516
+ name: 'test-subscriber',
517
+ description: 'Test subscriber',
518
+ idempotent: 'unknown',
519
+ callback: mock(async () => {
520
+ throw new Error('Test error');
521
+ }),
522
+ })),
523
+ },
524
+ retryPolicy: {
525
+ shouldRetry: mock(() => ({
526
+ action: 'retry',
527
+ delay: 1000,
528
+ })),
529
+ },
530
+ transport: {
531
+ complete: completeMock,
532
+ send: sendMock,
533
+ },
534
+ });
535
+ const pipeline = new ProcessingPipeline(config);
536
+ const receipt = createReceipt({ sourceQueue: 'test-queue' });
537
+ await pipeline.process(new Uint8Array(), receipt);
538
+ expect(sendMock).toHaveBeenCalled();
539
+ expect(completeMock).toHaveBeenCalled();
540
+ expect(completeCalledAfterSend).toBe(true);
541
+ });
542
+ });
543
+ describe('retry decision handling', () => {
544
+ it('should consult retry policy on error', async () => {
545
+ const envelope = createTestEnvelope();
546
+ const subscriberDef = createSubscriberDefinition();
547
+ const error = new Error('Test error');
548
+ const shouldRetryMock = mock(() => ({
549
+ action: 'retry',
550
+ delay: 1000,
551
+ }));
552
+ const config = createMockConfig({
553
+ codec: {
554
+ decode: mock(() => envelope),
555
+ },
556
+ schema: {
557
+ getSubscriberDefinition: mock(() => subscriberDef),
558
+ getExecutableSubscriber: mock(() => ({
559
+ name: 'test-subscriber',
560
+ description: 'Test subscriber',
561
+ idempotent: 'unknown',
562
+ callback: mock(async () => {
563
+ throw error;
564
+ }),
565
+ })),
566
+ },
567
+ retryPolicy: {
568
+ shouldRetry: shouldRetryMock,
569
+ },
570
+ });
571
+ const pipeline = new ProcessingPipeline(config);
572
+ const receipt = createReceipt();
573
+ await pipeline.process(new Uint8Array(), receipt);
574
+ expect(shouldRetryMock).toHaveBeenCalledWith({
575
+ envelope,
576
+ error,
577
+ subscriber: subscriberDef,
578
+ receipt,
579
+ });
580
+ });
581
+ it('should handle retry decision by sending to queue with delay', async () => {
582
+ const envelope = createTestEnvelope();
583
+ const sendMock = mock(async () => 'mock');
584
+ const completeMock = mock(async () => { });
585
+ const config = createMockConfig({
586
+ codec: {
587
+ decode: mock(() => envelope),
588
+ },
589
+ schema: {
590
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
591
+ getExecutableSubscriber: mock(() => ({
592
+ name: 'test-subscriber',
593
+ description: 'Test subscriber',
594
+ idempotent: 'unknown',
595
+ callback: mock(async () => {
596
+ throw new Error('Test error');
597
+ }),
598
+ })),
599
+ },
600
+ retryPolicy: {
601
+ shouldRetry: mock(() => ({
602
+ action: 'retry',
603
+ delay: 2000,
604
+ })),
605
+ },
606
+ transport: {
607
+ send: sendMock,
608
+ complete: completeMock,
609
+ },
610
+ });
611
+ const pipeline = new ProcessingPipeline(config);
612
+ const receipt = createReceipt({ sourceQueue: 'test-queue' });
613
+ await pipeline.process(new Uint8Array(), receipt);
614
+ expect(sendMock).toHaveBeenCalledWith('test-queue', expect.objectContaining({
615
+ id: envelope.id,
616
+ docket: expect.objectContaining({
617
+ attempts: 2,
618
+ scheduledFor: expect.any(String),
619
+ }),
620
+ }), { delay: 2000 });
621
+ expect(completeMock).toHaveBeenCalledWith(receipt);
622
+ });
623
+ it('should update envelope error fields on retry', async () => {
624
+ const envelope = createTestEnvelope();
625
+ const sendMock = mock(async () => 'mock');
626
+ const config = createMockConfig({
627
+ codec: {
628
+ decode: mock(() => envelope),
629
+ },
630
+ schema: {
631
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
632
+ getExecutableSubscriber: mock(() => ({
633
+ name: 'test-subscriber',
634
+ description: 'Test subscriber',
635
+ idempotent: 'unknown',
636
+ callback: mock(async () => {
637
+ throw new Error('Test error message');
638
+ }),
639
+ })),
640
+ },
641
+ retryPolicy: {
642
+ shouldRetry: mock(() => ({
643
+ action: 'retry',
644
+ delay: 1000,
645
+ })),
646
+ },
647
+ transport: {
648
+ send: sendMock,
649
+ complete: mock(async () => { }),
650
+ },
651
+ });
652
+ const pipeline = new ProcessingPipeline(config);
653
+ const receipt = createReceipt({ sourceQueue: 'test-queue' });
654
+ await pipeline.process(new Uint8Array(), receipt);
655
+ const calls = sendMock.mock.calls;
656
+ const sentEnvelope = calls[0][1];
657
+ expect(sentEnvelope.docket.lastError).toBe('Test error message');
658
+ expect(sentEnvelope.docket.firstError).toBe('Test error message');
659
+ });
660
+ it('should handle dead-letter decision', async () => {
661
+ const envelope = createTestEnvelope();
662
+ const sendToDeadLetterMock = mock(async () => { });
663
+ const config = createMockConfig({
664
+ codec: {
665
+ decode: mock(() => envelope),
666
+ },
667
+ schema: {
668
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
669
+ getExecutableSubscriber: mock(() => ({
670
+ name: 'test-subscriber',
671
+ description: 'Test subscriber',
672
+ idempotent: 'unknown',
673
+ callback: mock(async () => {
674
+ throw new Error('Fatal error');
675
+ }),
676
+ })),
677
+ },
678
+ retryPolicy: {
679
+ shouldRetry: mock(() => ({
680
+ action: 'dead-letter',
681
+ queue: 'undeliverable',
682
+ reason: 'Max attempts exceeded',
683
+ })),
684
+ },
685
+ transport: {
686
+ sendToDeadLetter: sendToDeadLetterMock,
687
+ complete: mock(async () => { }),
688
+ },
689
+ });
690
+ const pipeline = new ProcessingPipeline(config);
691
+ const receipt = createReceipt({ sourceQueue: 'test-queue' });
692
+ await pipeline.process(new Uint8Array(), receipt);
693
+ expect(sendToDeadLetterMock).toHaveBeenCalledWith(receipt, 'undeliverable', envelope, 'Max attempts exceeded');
694
+ });
695
+ it('should handle dead-letter manually when transport lacks support', async () => {
696
+ const envelope = createTestEnvelope();
697
+ const sendMock = mock(async () => 'mock');
698
+ const completeMock = mock(async () => { });
699
+ const config = createMockConfig({
700
+ codec: {
701
+ decode: mock(() => envelope),
702
+ },
703
+ schema: {
704
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
705
+ getExecutableSubscriber: mock(() => ({
706
+ name: 'test-subscriber',
707
+ description: 'Test subscriber',
708
+ idempotent: 'unknown',
709
+ callback: mock(async () => {
710
+ throw new Error('Fatal error');
711
+ }),
712
+ })),
713
+ },
714
+ retryPolicy: {
715
+ shouldRetry: mock(() => ({
716
+ action: 'dead-letter',
717
+ queue: 'undeliverable',
718
+ reason: 'Max attempts exceeded',
719
+ })),
720
+ },
721
+ transport: {
722
+ send: sendMock,
723
+ complete: completeMock,
724
+ },
725
+ });
726
+ // Remove sendToDeadLetter after merging
727
+ config.transport.sendToDeadLetter =
728
+ undefined;
729
+ const pipeline = new ProcessingPipeline(config);
730
+ const receipt = createReceipt({ sourceQueue: 'test-queue' });
731
+ await pipeline.process(new Uint8Array(), receipt);
732
+ expect(sendMock).toHaveBeenCalledWith('test-queue.undeliverable', envelope);
733
+ expect(completeMock).toHaveBeenCalledWith(receipt);
734
+ });
735
+ it('should set originalQueue when sending to dead-letter', async () => {
736
+ const envelope = createTestEnvelope();
737
+ const sendToDeadLetterMock = mock(async () => { });
738
+ const config = createMockConfig({
739
+ codec: {
740
+ decode: mock(() => envelope),
741
+ },
742
+ schema: {
743
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
744
+ getExecutableSubscriber: mock(() => ({
745
+ name: 'test-subscriber',
746
+ description: 'Test subscriber',
747
+ idempotent: 'unknown',
748
+ callback: mock(async () => {
749
+ throw new Error('Fatal error');
750
+ }),
751
+ })),
752
+ },
753
+ retryPolicy: {
754
+ shouldRetry: mock(() => ({
755
+ action: 'dead-letter',
756
+ queue: 'undeliverable',
757
+ reason: 'Max attempts exceeded',
758
+ })),
759
+ },
760
+ transport: {
761
+ sendToDeadLetter: sendToDeadLetterMock,
762
+ complete: mock(async () => { }),
763
+ },
764
+ });
765
+ const pipeline = new ProcessingPipeline(config);
766
+ const receipt = createReceipt({ sourceQueue: 'original-queue' });
767
+ await pipeline.process(new Uint8Array(), receipt);
768
+ const calls = sendToDeadLetterMock.mock.calls;
769
+ const sentEnvelope = calls[0][2];
770
+ expect(sentEnvelope.docket.originalQueue).toBe('original-queue');
771
+ });
772
+ it('should handle discard decision', async () => {
773
+ const envelope = createTestEnvelope();
774
+ const completeMock = mock(async () => { });
775
+ const config = createMockConfig({
776
+ codec: {
777
+ decode: mock(() => envelope),
778
+ },
779
+ schema: {
780
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
781
+ getExecutableSubscriber: mock(() => ({
782
+ name: 'test-subscriber',
783
+ description: 'Test subscriber',
784
+ idempotent: 'unknown',
785
+ callback: mock(async () => {
786
+ throw new Error('Discard me');
787
+ }),
788
+ })),
789
+ },
790
+ retryPolicy: {
791
+ shouldRetry: mock(() => ({
792
+ action: 'discard',
793
+ reason: 'Discarding message',
794
+ })),
795
+ },
796
+ transport: {
797
+ complete: completeMock,
798
+ },
799
+ });
800
+ const pipeline = new ProcessingPipeline(config);
801
+ const receipt = createReceipt();
802
+ await pipeline.process(new Uint8Array(), receipt);
803
+ expect(completeMock).toHaveBeenCalledWith(receipt);
804
+ });
805
+ });
806
+ describe('process result', () => {
807
+ it('should return success result with duration', async () => {
808
+ const envelope = createTestEnvelope();
809
+ const subscriberDef = createSubscriberDefinition();
810
+ const config = createMockConfig({
811
+ codec: {
812
+ decode: mock(() => envelope),
813
+ },
814
+ schema: {
815
+ getSubscriberDefinition: mock(() => subscriberDef),
816
+ getExecutableSubscriber: mock(() => ({
817
+ name: 'test-subscriber',
818
+ description: 'Test subscriber',
819
+ idempotent: 'unknown',
820
+ callback: mock(async () => { }),
821
+ })),
822
+ },
823
+ });
824
+ const pipeline = new ProcessingPipeline(config);
825
+ const receipt = createReceipt();
826
+ const result = await pipeline.process(new Uint8Array(), receipt);
827
+ expect(result.success).toBe(true);
828
+ expect(result.envelope).toEqual(envelope);
829
+ expect(result.subscriber).toEqual(subscriberDef);
830
+ expect(result.durationMs).toBeGreaterThanOrEqual(0);
831
+ expect(result.error).toBeUndefined();
832
+ expect(result.decision).toBeUndefined();
833
+ });
834
+ it('should return failure result with error and decision', async () => {
835
+ const envelope = createTestEnvelope();
836
+ const subscriberDef = createSubscriberDefinition();
837
+ const error = new Error('Processing failed');
838
+ const decision = { action: 'retry', delay: 1000 };
839
+ const config = createMockConfig({
840
+ codec: {
841
+ decode: mock(() => envelope),
842
+ },
843
+ schema: {
844
+ getSubscriberDefinition: mock(() => subscriberDef),
845
+ getExecutableSubscriber: mock(() => ({
846
+ name: 'test-subscriber',
847
+ description: 'Test subscriber',
848
+ idempotent: 'unknown',
849
+ callback: mock(async () => {
850
+ throw error;
851
+ }),
852
+ })),
853
+ },
854
+ retryPolicy: {
855
+ shouldRetry: mock(() => decision),
856
+ },
857
+ });
858
+ const pipeline = new ProcessingPipeline(config);
859
+ const receipt = createReceipt();
860
+ const result = await pipeline.process(new Uint8Array(), receipt);
861
+ expect(result.success).toBe(false);
862
+ expect(result.envelope).toEqual(envelope);
863
+ expect(result.subscriber).toEqual(subscriberDef);
864
+ expect(result.error).toBe(error);
865
+ expect(result.decision).toEqual(decision);
866
+ expect(result.durationMs).toBeGreaterThanOrEqual(0);
867
+ });
868
+ it('should return result with only error on decode failure', async () => {
869
+ const error = new CodecDecodeError('Invalid message');
870
+ const config = createMockConfig({
871
+ codec: {
872
+ decode: mock(() => {
873
+ throw error;
874
+ }),
875
+ },
876
+ });
877
+ const pipeline = new ProcessingPipeline(config);
878
+ const receipt = createReceipt();
879
+ const result = await pipeline.process(new Uint8Array(), receipt);
880
+ expect(result.success).toBe(false);
881
+ expect(result.error).toBe(error);
882
+ expect(result.envelope).toBeUndefined();
883
+ expect(result.subscriber).toBeUndefined();
884
+ expect(result.decision).toBeUndefined();
885
+ expect(result.durationMs).toBeGreaterThanOrEqual(0);
886
+ });
887
+ });
888
+ describe('envelope error tracking', () => {
889
+ it('should set firstError on first failure', async () => {
890
+ const envelope = createTestEnvelope();
891
+ const sendMock = mock(async () => 'mock');
892
+ const config = createMockConfig({
893
+ codec: {
894
+ decode: mock(() => envelope),
895
+ },
896
+ schema: {
897
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
898
+ getExecutableSubscriber: mock(() => ({
899
+ name: 'test-subscriber',
900
+ description: 'Test subscriber',
901
+ idempotent: 'unknown',
902
+ callback: mock(async () => {
903
+ throw new Error('First failure');
904
+ }),
905
+ })),
906
+ },
907
+ retryPolicy: {
908
+ shouldRetry: mock(() => ({
909
+ action: 'retry',
910
+ delay: 1000,
911
+ })),
912
+ },
913
+ transport: {
914
+ send: sendMock,
915
+ complete: mock(async () => { }),
916
+ },
917
+ });
918
+ const pipeline = new ProcessingPipeline(config);
919
+ const receipt = createReceipt({ sourceQueue: 'test-queue' });
920
+ await pipeline.process(new Uint8Array(), receipt);
921
+ const calls = sendMock.mock.calls;
922
+ const sentEnvelope = calls[0][1];
923
+ expect(sentEnvelope.docket.firstError).toBe('First failure');
924
+ expect(sentEnvelope.docket.lastError).toBe('First failure');
925
+ });
926
+ it('should preserve firstError on subsequent failures', async () => {
927
+ const envelope = createTestEnvelope();
928
+ envelope.docket.firstError = 'Original error';
929
+ const sendMock = mock(async () => 'mock');
930
+ const config = createMockConfig({
931
+ codec: {
932
+ decode: mock(() => envelope),
933
+ },
934
+ schema: {
935
+ getSubscriberDefinition: mock(() => createSubscriberDefinition()),
936
+ getExecutableSubscriber: mock(() => ({
937
+ name: 'test-subscriber',
938
+ description: 'Test subscriber',
939
+ idempotent: 'unknown',
940
+ callback: mock(async () => {
941
+ throw new Error('Second failure');
942
+ }),
943
+ })),
944
+ },
945
+ retryPolicy: {
946
+ shouldRetry: mock(() => ({
947
+ action: 'retry',
948
+ delay: 1000,
949
+ })),
950
+ },
951
+ transport: {
952
+ send: sendMock,
953
+ complete: mock(async () => { }),
954
+ },
955
+ });
956
+ const pipeline = new ProcessingPipeline(config);
957
+ const receipt = createReceipt({ sourceQueue: 'test-queue' });
958
+ await pipeline.process(new Uint8Array(), receipt);
959
+ const calls = sendMock.mock.calls;
960
+ const sentEnvelope = calls[0][1];
961
+ expect(sentEnvelope.docket.firstError).toBe('Original error');
962
+ expect(sentEnvelope.docket.lastError).toBe('Second failure');
963
+ });
964
+ });
965
+ });
966
+ function createMockConfig(overrides = {}) {
967
+ const defaultTransport = {
968
+ name: 'mock',
969
+ capabilities: {
970
+ deliveryModes: ['at-least-once'],
971
+ delayedMessages: true,
972
+ deadLetterRouting: 'manual',
973
+ attemptTracking: true,
974
+ concurrencyModel: 'none',
975
+ ordering: 'none',
976
+ priorities: false,
977
+ },
978
+ connect: mock(async () => { }),
979
+ disconnect: mock(async () => { }),
980
+ isConnected: mock(() => true),
981
+ applyTopology: mock(async () => { }),
982
+ send: mock(async () => 'mock'),
983
+ subscribe: mock(async () => ({
984
+ unsubscribe: async () => { },
985
+ isActive: true,
986
+ })),
987
+ complete: mock(async () => { }),
988
+ sendToDeadLetter: mock(async () => { }),
989
+ ...overrides.transport,
990
+ };
991
+ const defaultSchema = {
992
+ getSubscriberDefinition: mock(() => undefined),
993
+ getExecutableSubscriber: mock(() => undefined),
994
+ };
995
+ const defaultCodec = {
996
+ encode: mock(() => new Uint8Array()),
997
+ decode: mock(() => createTestEnvelope()),
998
+ contentType: 'application/json',
999
+ ...overrides.codec,
1000
+ };
1001
+ // If retryPolicy is a full implementation (has both methods), use it directly
1002
+ // Otherwise, merge with defaults
1003
+ const isFullRetryPolicy = (p) => {
1004
+ return (p !== undefined &&
1005
+ 'shouldRetry' in p &&
1006
+ 'getDelay' in p &&
1007
+ typeof p.shouldRetry === 'function' &&
1008
+ typeof p.getDelay === 'function');
1009
+ };
1010
+ const defaultRetryPolicy = isFullRetryPolicy(overrides.retryPolicy)
1011
+ ? overrides.retryPolicy
1012
+ : {
1013
+ shouldRetry: mock(() => ({
1014
+ action: 'discard',
1015
+ reason: 'default',
1016
+ })),
1017
+ getDelay: mock(() => 1000),
1018
+ ...overrides.retryPolicy,
1019
+ };
1020
+ const defaultHooks = {
1021
+ onWorkerWrap: async (_envelope, _subscriber, execute) => {
1022
+ await execute();
1023
+ },
1024
+ onWorkerBeforeProcess: async () => { },
1025
+ onWorkerSuccess: async () => { },
1026
+ onWorkerError: async () => { },
1027
+ onDecodeError: async () => { },
1028
+ ...overrides.hooks,
1029
+ };
1030
+ return {
1031
+ transport: defaultTransport,
1032
+ schema: { ...defaultSchema, ...overrides.schema },
1033
+ codec: defaultCodec,
1034
+ retryPolicy: defaultRetryPolicy,
1035
+ hooks: defaultHooks,
1036
+ };
1037
+ }
1038
+ function createTestEnvelope() {
1039
+ return createEnvelope({
1040
+ eventKey: 'test.event',
1041
+ targetSubscriber: 'test-subscriber',
1042
+ data: { test: 'data' },
1043
+ importance: 'should-investigate',
1044
+ });
1045
+ }
1046
+ function createReceipt(overrides = {}) {
1047
+ return {
1048
+ handle: {},
1049
+ redelivered: false,
1050
+ attemptNumber: 1,
1051
+ deliveryCount: 1,
1052
+ sourceQueue: 'test-queue',
1053
+ sourceTransport: 'mock',
1054
+ ...overrides,
1055
+ };
1056
+ }
1057
+ function createSubscriberDefinition(overrides = {}) {
1058
+ return {
1059
+ name: 'test-subscriber',
1060
+ description: 'Test subscriber',
1061
+ idempotent: 'unknown',
1062
+ importance: 'should-investigate',
1063
+ ...overrides,
1064
+ };
1065
+ }