@open-mercato/events 0.6.8-develop.6917.1.af45bc96e2 → 0.6.8-develop.6924.1.a8d208fcdc

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.
@@ -1,8 +1,8 @@
1
- import { registerCliModules, getCliModules } from '@open-mercato/shared/modules/registry'
2
- import type { Module } from '@open-mercato/shared/modules/registry'
3
1
  import type { QueuedJob, JobContext } from '@open-mercato/queue'
4
2
  import { createLogger } from '@open-mercato/shared/lib/logger'
5
- import handle, { metadata, EVENTS_QUEUE_NAME, clearListenerCache } from '../events.worker'
3
+ import { createEventBus } from '../../../../bus'
4
+ import type { EventBus, SubscriberDescriptor } from '../../../../types'
5
+ import handle, { metadata, EVENTS_QUEUE_NAME } from '../events.worker'
6
6
 
7
7
  jest.mock('@open-mercato/shared/lib/logger', () => {
8
8
  const mocked = {
@@ -17,23 +17,76 @@ jest.mock('@open-mercato/shared/lib/logger', () => {
17
17
  })
18
18
 
19
19
  const workerLoggerError = createLogger('events').error as jest.Mock
20
+ const workerLoggerWarn = createLogger('events').warn as jest.Mock
21
+ const workerLoggerDebug = createLogger('events').debug as jest.Mock
22
+
23
+ type WorkerJobPayload = {
24
+ event: string
25
+ payload: unknown
26
+ options?: { tenantId?: string | null; organizationId?: string | null }
27
+ persistentDeliveredInline?: boolean
28
+ }
29
+
30
+ type WorkerContext = JobContext & { resolve: <T = unknown>(name: string) => T }
31
+
32
+ const createMockJob = (
33
+ event: string,
34
+ payload: unknown,
35
+ options?: { tenantId?: string | null; organizationId?: string | null },
36
+ extra?: { persistentDeliveredInline?: boolean },
37
+ ): QueuedJob<WorkerJobPayload> => ({
38
+ id: 'test-job-id',
39
+ payload: { event, payload, options, ...extra },
40
+ createdAt: new Date().toISOString(),
41
+ })
42
+
43
+ /**
44
+ * The worker resolves its subscribers from the DI event bus, so the fixture is a
45
+ * real bus with subscribers registered on it - exactly what
46
+ * `createRequestContainer` hands a worker job. The CLI module registry is never
47
+ * touched here; that it is not needed IS the regression this suite pins.
48
+ */
49
+ function createBusWith(subs: SubscriberDescriptor[]): EventBus {
50
+ const bus = createEventBus({ resolve: <T = unknown>(name: string): T => {
51
+ throw new Error(`No mock for ${name}`)
52
+ } })
53
+ bus.registerModuleSubscribers(subs)
54
+ return bus
55
+ }
20
56
 
21
- // Clear modules and listener cache before each test
22
- function clearModules() {
23
- // Re-register with empty array to clear
24
- registerCliModules([])
25
- // Clear the listener cache so tests don't affect each other
26
- clearListenerCache()
57
+ function createMockContext(bus?: EventBus): WorkerContext {
58
+ return {
59
+ jobId: 'test-job-id',
60
+ attemptNumber: 1,
61
+ queueName: 'events',
62
+ resolve: <T = unknown>(name: string): T => {
63
+ if (name === 'eventBus' && bus) {
64
+ return bus as unknown as T
65
+ }
66
+ throw new Error(`No mock for ${name}`)
67
+ },
68
+ }
27
69
  }
28
70
 
29
71
  describe('Events Worker', () => {
72
+ const ORIG_SINGLE_DELIVERY = process.env.OM_EVENTS_SINGLE_DELIVERY
73
+
30
74
  beforeEach(() => {
31
- clearModules()
32
75
  workerLoggerError.mockClear()
76
+ workerLoggerWarn.mockClear()
77
+ workerLoggerDebug.mockClear()
33
78
  })
34
79
 
80
+ function restoreEnv(name: string, original: string | undefined): void {
81
+ if (original === undefined) {
82
+ delete process.env[name]
83
+ return
84
+ }
85
+ process.env[name] = original
86
+ }
87
+
35
88
  afterEach(() => {
36
- clearModules()
89
+ restoreEnv('OM_EVENTS_SINGLE_DELIVERY', ORIG_SINGLE_DELIVERY)
37
90
  })
38
91
 
39
92
  describe('metadata', () => {
@@ -43,277 +96,258 @@ describe('Events Worker', () => {
43
96
  })
44
97
 
45
98
  it('should have default concurrency of 1', () => {
46
- // When no env var is set, should default to 1
47
99
  expect(metadata.concurrency).toBe(1)
48
100
  })
49
101
  })
50
102
 
51
- describe('handle', () => {
52
- // These tests exercise the legacy exact-match dispatch (the worker runs every
53
- // matching subscriber, persistent or not). Single-delivery (now default-on)
54
- // dispatches only persistent subscribers in the worker, so pin the legacy
55
- // path here; single-delivery semantics are covered in the sibling describe.
56
- const ORIG_SINGLE_DELIVERY = process.env.OM_EVENTS_SINGLE_DELIVERY
57
- beforeEach(() => {
58
- process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
103
+ describe('subscriber registry resolution', () => {
104
+ it('dispatches subscribers registered only on the bus, with no CLI module registry', async () => {
105
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
106
+ const calls: unknown[] = []
107
+ const bus = createBusWith([
108
+ {
109
+ id: 'webhooks:outbound-dispatch',
110
+ event: '*',
111
+ persistent: true,
112
+ handler: (payload) => { calls.push(payload) },
113
+ },
114
+ ])
115
+
116
+ await handle(createMockJob('customers.deal.won', { id: 'deal-1' }), createMockContext(bus))
117
+
118
+ expect(calls).toEqual([{ id: 'deal-1' }])
59
119
  })
60
- afterEach(() => {
61
- if (ORIG_SINGLE_DELIVERY === undefined) delete process.env.OM_EVENTS_SINGLE_DELIVERY
62
- else process.env.OM_EVENTS_SINGLE_DELIVERY = ORIG_SINGLE_DELIVERY
120
+
121
+ it('throws an actionable error when the job container has no event bus', async () => {
122
+ const job = createMockJob('user.created', {})
123
+
124
+ await expect(handle(job, createMockContext())).rejects.toThrow(
125
+ /no "eventBus" in the job container/,
126
+ )
127
+ await expect(handle(job, createMockContext())).rejects.toThrow(
128
+ /mercato queue worker events/,
129
+ )
63
130
  })
64
131
 
65
- const createMockJob = (
66
- event: string,
67
- payload: unknown,
68
- options?: { tenantId?: string | null; organizationId?: string | null },
69
- ): QueuedJob<{ event: string; payload: unknown; options?: { tenantId?: string | null; organizationId?: string | null } }> => ({
70
- id: 'test-job-id',
71
- payload: { event, payload, options },
72
- createdAt: new Date().toISOString(),
132
+ it('throws when the resolved bus predates dispatchQueued', async () => {
133
+ const staleBus = { emit: jest.fn(), on: jest.fn() } as unknown as EventBus
134
+
135
+ await expect(handle(createMockJob('user.created', {}), createMockContext(staleBus))).rejects.toThrow(
136
+ /has no dispatchQueued/,
137
+ )
138
+ })
139
+
140
+ it('picks up subscribers registered after an earlier job was dispatched', async () => {
141
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
142
+ const calls: string[] = []
143
+ const bus = createBusWith([])
144
+
145
+ await handle(createMockJob('user.created', {}), createMockContext(bus))
146
+ expect(calls).toEqual([])
147
+
148
+ bus.registerModuleSubscribers([
149
+ { id: 'late', event: 'user.created', persistent: true, handler: () => { calls.push('late') } },
150
+ ])
151
+
152
+ await handle(createMockJob('user.created', {}), createMockContext(bus))
153
+ expect(calls).toEqual(['late'])
73
154
  })
155
+ })
74
156
 
75
- const createMockContext = (): JobContext & { resolve: <T = unknown>(name: string) => T } => ({
76
- jobId: 'test-job-id',
77
- attemptNumber: 1,
78
- queueName: 'events',
79
- resolve: <T = unknown>(name: string): T => {
80
- throw new Error(`No mock for ${name}`)
157
+ describe('producer stamp (persistentDeliveredInline)', () => {
158
+ it.each(['true', 'false'])(
159
+ 'dispatches nothing when the producer already delivered inline (flag=%s)',
160
+ async (flag) => {
161
+ process.env.OM_EVENTS_SINGLE_DELIVERY = flag
162
+ const calls: string[] = []
163
+ const bus = createBusWith([
164
+ { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
165
+ { id: 'e', event: 'user.created', persistent: false, handler: () => { calls.push('e') } },
166
+ ])
167
+
168
+ await handle(
169
+ createMockJob('user.created', {}, undefined, { persistentDeliveredInline: true }),
170
+ createMockContext(bus),
171
+ )
172
+
173
+ expect(calls).toEqual([])
81
174
  },
175
+ )
176
+
177
+ it('dispatches normally when the stamp is absent', async () => {
178
+ process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
179
+ const calls: string[] = []
180
+ const bus = createBusWith([
181
+ { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
182
+ ])
183
+
184
+ await handle(createMockJob('user.created', {}), createMockContext(bus))
185
+
186
+ expect(calls).toEqual(['p'])
82
187
  })
188
+ })
189
+
190
+ describe('handle', () => {
191
+ // The worker only ever dispatches `persistent` subscribers, by pattern, and
192
+ // that selection does not depend on OM_EVENTS_SINGLE_DELIVERY - the job's
193
+ // stamp carries the producer's decision instead. These fixtures are therefore
194
+ // all persistent.
83
195
 
84
196
  it('should do nothing when no subscribers are registered', async () => {
85
197
  const job = createMockJob('test.event', { data: 'test' })
86
- const ctx = createMockContext()
87
198
 
88
- // Should not throw
89
- await expect(handle(job, ctx)).resolves.toBeUndefined()
199
+ await expect(handle(job, createMockContext(createBusWith([])))).resolves.toBeUndefined()
90
200
  })
91
201
 
92
202
  it('should dispatch event to matching subscribers', async () => {
93
203
  const receivedPayloads: unknown[] = []
204
+ const bus = createBusWith([
205
+ {
206
+ id: 'test:subscriber1',
207
+ event: 'user.created',
208
+ persistent: true,
209
+ handler: (payload) => { receivedPayloads.push(payload) },
210
+ },
211
+ ])
94
212
 
95
- const mockModule: Module = {
96
- id: 'test-module',
97
- subscribers: [
98
- {
99
- id: 'test:subscriber1',
100
- event: 'user.created',
101
- handler: async (payload: unknown) => {
102
- receivedPayloads.push(payload)
103
- },
104
- },
105
- ],
106
- }
107
-
108
- registerCliModules([mockModule])
109
-
110
- const job = createMockJob('user.created', { userId: '123', name: 'Test User' })
111
- const ctx = createMockContext()
112
-
113
- await handle(job, ctx)
213
+ await handle(
214
+ createMockJob('user.created', { userId: '123', name: 'Test User' }),
215
+ createMockContext(bus),
216
+ )
114
217
 
115
- expect(receivedPayloads.length).toBe(1)
116
- expect(receivedPayloads[0]).toEqual({ userId: '123', name: 'Test User' })
218
+ expect(receivedPayloads).toEqual([{ userId: '123', name: 'Test User' }])
117
219
  })
118
220
 
119
221
  it('should dispatch to multiple subscribers for same event', async () => {
120
222
  const subscriber1Calls: unknown[] = []
121
223
  const subscriber2Calls: unknown[] = []
224
+ const bus = createBusWith([
225
+ { id: 'a:subscriber', event: 'order.placed', persistent: true, handler: (p) => { subscriber1Calls.push(p) } },
226
+ { id: 'b:subscriber', event: 'order.placed', persistent: true, handler: (p) => { subscriber2Calls.push(p) } },
227
+ ])
122
228
 
123
- const mockModules: Module[] = [
124
- {
125
- id: 'module-a',
126
- subscribers: [
127
- {
128
- id: 'a:subscriber',
129
- event: 'order.placed',
130
- handler: async (payload: unknown) => {
131
- subscriber1Calls.push(payload)
132
- },
133
- },
134
- ],
135
- },
136
- {
137
- id: 'module-b',
138
- subscribers: [
139
- {
140
- id: 'b:subscriber',
141
- event: 'order.placed',
142
- handler: async (payload: unknown) => {
143
- subscriber2Calls.push(payload)
144
- },
145
- },
146
- ],
147
- },
148
- ]
149
-
150
- registerCliModules(mockModules)
151
-
152
- const job = createMockJob('order.placed', { orderId: '456' })
153
- const ctx = createMockContext()
154
-
155
- await handle(job, ctx)
229
+ await handle(createMockJob('order.placed', { orderId: '456' }), createMockContext(bus))
156
230
 
157
- expect(subscriber1Calls.length).toBe(1)
158
- expect(subscriber2Calls.length).toBe(1)
159
- expect(subscriber1Calls[0]).toEqual({ orderId: '456' })
160
- expect(subscriber2Calls[0]).toEqual({ orderId: '456' })
231
+ expect(subscriber1Calls).toEqual([{ orderId: '456' }])
232
+ expect(subscriber2Calls).toEqual([{ orderId: '456' }])
161
233
  })
162
234
 
163
235
  it('should not dispatch to non-matching event subscribers', async () => {
164
236
  const receivedPayloads: unknown[] = []
237
+ const bus = createBusWith([
238
+ { id: 'test:subscriber', event: 'user.created', persistent: true, handler: (p) => { receivedPayloads.push(p) } },
239
+ ])
165
240
 
166
- const mockModule: Module = {
167
- id: 'test-module',
168
- subscribers: [
169
- {
170
- id: 'test:subscriber',
171
- event: 'user.created',
172
- handler: async (payload: unknown) => {
173
- receivedPayloads.push(payload)
174
- },
175
- },
176
- ],
177
- }
178
-
179
- registerCliModules([mockModule])
241
+ await handle(createMockJob('user.deleted', { userId: '123' }), createMockContext(bus))
180
242
 
181
- const job = createMockJob('user.deleted', { userId: '123' })
182
- const ctx = createMockContext()
183
-
184
- await handle(job, ctx)
185
-
186
- expect(receivedPayloads.length).toBe(0)
243
+ expect(receivedPayloads).toEqual([])
187
244
  })
188
245
 
189
246
  it('should pass resolve function to subscriber context', async () => {
190
247
  let capturedContext: unknown = null
248
+ const bus = createBusWith([
249
+ { id: 'test:subscriber', event: 'test.event', persistent: true, handler: (_p, ctx) => { capturedContext = ctx } },
250
+ ])
191
251
 
192
- const mockModule: Module = {
193
- id: 'test-module',
194
- subscribers: [
195
- {
196
- id: 'test:subscriber',
197
- event: 'test.event',
198
- handler: async (_payload: unknown, ctx: unknown) => {
199
- capturedContext = ctx
200
- },
201
- },
202
- ],
203
- }
252
+ await handle(createMockJob('test.event', {}), createMockContext(bus))
204
253
 
205
- registerCliModules([mockModule])
254
+ expect(capturedContext).toBeDefined()
255
+ expect((capturedContext as { resolve: unknown }).resolve).toBeDefined()
256
+ })
206
257
 
207
- const mockResolve = jest.fn().mockReturnValue('resolved-service')
208
- const job = createMockJob('test.event', {})
209
- const ctx = {
210
- ...createMockContext(),
211
- resolve: mockResolve,
258
+ it('should hand subscribers the JOB container resolver, not the bus creation one', async () => {
259
+ // `toBeDefined()` above passes whichever resolver is threaded, so it cannot
260
+ // catch the two being swapped. Pin the identity with sentinels that differ:
261
+ // the worker must pass its own ctx.resolve, which is the container
262
+ // createPerJobWorkerHandler built for this job. Under OM_BOOTSTRAP_CACHE the
263
+ // bus is replayed across jobs while its captured resolver stays bound to the
264
+ // first container, so resolving `em` from it would share one EntityManager
265
+ // across concurrent jobs - the interleaving of issue #2970.
266
+ const busEm = { id: 'em-from-the-container-that-built-the-bus' }
267
+ const perJobEm = { id: 'em-for-this-job' }
268
+ let resolvedEm: unknown = null
269
+
270
+ const bus = createEventBus({
271
+ resolve: (<T = unknown>(name: string): T => {
272
+ if (name === 'em') {
273
+ return busEm as unknown as T
274
+ }
275
+ throw new Error(`No mock for ${name}`)
276
+ }),
277
+ })
278
+ bus.registerModuleSubscribers([
279
+ { id: 'test:subscriber', event: 'test.event', persistent: true, handler: (_p, ctx) => { resolvedEm = ctx.resolve('em') } },
280
+ ])
281
+
282
+ const ctx: WorkerContext = {
283
+ jobId: 'test-job-id',
284
+ attemptNumber: 1,
285
+ queueName: 'events',
286
+ resolve: <T = unknown>(name: string): T => {
287
+ if (name === 'eventBus') {
288
+ return bus as unknown as T
289
+ }
290
+ if (name === 'em') {
291
+ return perJobEm as unknown as T
292
+ }
293
+ throw new Error(`No mock for ${name}`)
294
+ },
212
295
  }
213
296
 
214
- await handle(job, ctx)
297
+ await handle(createMockJob('test.event', {}), ctx)
215
298
 
216
- expect(capturedContext).toBeDefined()
217
- expect((capturedContext as { resolve: unknown }).resolve).toBeDefined()
299
+ expect(resolvedEm).toBe(perJobEm)
218
300
  })
219
301
 
220
302
  it('should pass trusted tenant and organization scope to subscriber context', async () => {
221
303
  let capturedContext: { tenantId?: string | null; organizationId?: string | null } | null = null
222
-
223
- const mockModule: Module = {
224
- id: 'test-module',
225
- subscribers: [
226
- {
227
- id: 'test:subscriber',
228
- event: 'test.event',
229
- handler: async (_payload: unknown, ctx: unknown) => {
230
- const typed = ctx as { tenantId?: string | null; organizationId?: string | null }
231
- capturedContext = {
232
- tenantId: typed.tenantId,
233
- organizationId: typed.organizationId,
234
- }
235
- },
304
+ const bus = createBusWith([
305
+ {
306
+ id: 'test:subscriber',
307
+ event: 'test.event',
308
+ persistent: true,
309
+ handler: (_p, ctx) => {
310
+ capturedContext = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
236
311
  },
237
- ],
238
- }
239
-
240
- registerCliModules([mockModule])
241
-
242
- const job = createMockJob('test.event', {}, { tenantId: 'tenant-1', organizationId: 'org-1' })
243
- const ctx = createMockContext()
312
+ },
313
+ ])
244
314
 
245
- await handle(job, ctx)
315
+ await handle(
316
+ createMockJob('test.event', {}, { tenantId: 'tenant-1', organizationId: 'org-1' }),
317
+ createMockContext(bus),
318
+ )
246
319
 
247
320
  expect(capturedContext).toEqual({ tenantId: 'tenant-1', organizationId: 'org-1' })
248
321
  })
249
322
 
250
323
  it('should not trust payload scope when trusted scope is omitted', async () => {
251
324
  let capturedContext: { tenantId?: string | null; organizationId?: string | null } | null = null
252
-
253
- const mockModule: Module = {
254
- id: 'test-module',
255
- subscribers: [
256
- {
257
- id: 'test:subscriber',
258
- event: 'test.event',
259
- handler: async (_payload: unknown, ctx: unknown) => {
260
- const typed = ctx as { tenantId?: string | null; organizationId?: string | null }
261
- capturedContext = {
262
- tenantId: typed.tenantId,
263
- organizationId: typed.organizationId,
264
- }
265
- },
325
+ const bus = createBusWith([
326
+ {
327
+ id: 'test:subscriber',
328
+ event: 'test.event',
329
+ persistent: true,
330
+ handler: (_p, ctx) => {
331
+ capturedContext = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
266
332
  },
267
- ],
268
- }
269
-
270
- registerCliModules([mockModule])
271
-
272
- const job = createMockJob('test.event', { tenantId: 'payload-tenant', organizationId: 'payload-org' })
273
- const ctx = createMockContext()
333
+ },
334
+ ])
274
335
 
275
- await handle(job, ctx)
336
+ await handle(
337
+ createMockJob('test.event', { tenantId: 'payload-tenant', organizationId: 'payload-org' }),
338
+ createMockContext(bus),
339
+ )
276
340
 
277
341
  expect(capturedContext).toEqual({ tenantId: null, organizationId: null })
278
342
  })
279
343
 
280
- it('should handle modules without subscribers', async () => {
281
- const mockModule: Module = {
282
- id: 'module-without-subscribers',
283
- // No subscribers property
284
- }
285
-
286
- registerCliModules([mockModule])
287
-
288
- const job = createMockJob('any.event', {})
289
- const ctx = createMockContext()
290
-
291
- // Should not throw
292
- await expect(handle(job, ctx)).resolves.toBeUndefined()
293
- })
294
-
295
344
  it('should handle synchronous handlers', async () => {
296
345
  let called = false
346
+ const bus = createBusWith([
347
+ { id: 'test:sync-subscriber', event: 'sync.event', persistent: true, handler: () => { called = true } },
348
+ ])
297
349
 
298
- const mockModule: Module = {
299
- id: 'test-module',
300
- subscribers: [
301
- {
302
- id: 'test:sync-subscriber',
303
- event: 'sync.event',
304
- handler: () => {
305
- called = true
306
- },
307
- },
308
- ],
309
- }
310
-
311
- registerCliModules([mockModule])
312
-
313
- const job = createMockJob('sync.event', {})
314
- const ctx = createMockContext()
315
-
316
- await handle(job, ctx)
350
+ await handle(createMockJob('sync.event', {}), createMockContext(bus))
317
351
 
318
352
  expect(called).toBe(true)
319
353
  })
@@ -321,47 +355,30 @@ describe('Events Worker', () => {
321
355
  it('should run all subscribers even when one fails, then throw to trigger retry', async () => {
322
356
  const subscriber1Calls: unknown[] = []
323
357
  const subscriber2Calls: unknown[] = []
324
-
325
- const mockModules: Module[] = [
358
+ const bus = createBusWith([
326
359
  {
327
- id: 'module-a',
328
- subscribers: [
329
- {
330
- id: 'a:failing-subscriber',
331
- event: 'test.event',
332
- handler: async () => {
333
- subscriber1Calls.push('called')
334
- throw new Error('Subscriber A failed')
335
- },
336
- },
337
- ],
360
+ id: 'a:failing-subscriber',
361
+ event: 'test.event',
362
+ persistent: true,
363
+ handler: async () => {
364
+ subscriber1Calls.push('called')
365
+ throw new Error('Subscriber A failed')
366
+ },
338
367
  },
339
368
  {
340
- id: 'module-b',
341
- subscribers: [
342
- {
343
- id: 'b:working-subscriber',
344
- event: 'test.event',
345
- handler: async (payload: unknown) => {
346
- subscriber2Calls.push(payload)
347
- },
348
- },
349
- ],
369
+ id: 'b:working-subscriber',
370
+ event: 'test.event',
371
+ persistent: true,
372
+ handler: async (payload) => { subscriber2Calls.push(payload) },
350
373
  },
351
- ]
352
-
353
- registerCliModules(mockModules)
374
+ ])
354
375
 
355
- const job = createMockJob('test.event', { data: 'test' })
356
- const ctx = createMockContext()
357
-
358
- await expect(handle(job, ctx)).rejects.toThrow(
359
- '1/2 subscriber(s) failed for event "test.event": a:failing-subscriber'
376
+ await expect(handle(createMockJob('test.event', { data: 'test' }), createMockContext(bus))).rejects.toThrow(
377
+ '[internal] 1/2 subscriber(s) failed for event "test.event": a:failing-subscriber'
360
378
  )
361
379
 
362
380
  expect(subscriber1Calls.length).toBe(1)
363
- expect(subscriber2Calls.length).toBe(1)
364
- expect(subscriber2Calls[0]).toEqual({ data: 'test' })
381
+ expect(subscriber2Calls).toEqual([{ data: 'test' }])
365
382
 
366
383
  expect(workerLoggerError).toHaveBeenCalledTimes(1)
367
384
  expect(workerLoggerError).toHaveBeenCalledWith('Subscriber failed for event', {
@@ -380,33 +397,13 @@ describe('Events Worker', () => {
380
397
  executionLog.push({ id, phase: 'end', time: Date.now() })
381
398
  }
382
399
 
383
- const mockModules: Module[] = [
384
- {
385
- id: 'module-a',
386
- subscribers: [
387
- { id: 'a:slow', event: 'test.parallel', handler: createDelayedHandler('a:slow', 100) },
388
- ],
389
- },
390
- {
391
- id: 'module-b',
392
- subscribers: [
393
- { id: 'b:slow', event: 'test.parallel', handler: createDelayedHandler('b:slow', 100) },
394
- ],
395
- },
396
- {
397
- id: 'module-c',
398
- subscribers: [
399
- { id: 'c:slow', event: 'test.parallel', handler: createDelayedHandler('c:slow', 100) },
400
- ],
401
- },
402
- ]
403
-
404
- registerCliModules(mockModules)
400
+ const bus = createBusWith([
401
+ { id: 'a:slow', event: 'test.parallel', persistent: true, handler: createDelayedHandler('a:slow', 100) },
402
+ { id: 'b:slow', event: 'test.parallel', persistent: true, handler: createDelayedHandler('b:slow', 100) },
403
+ { id: 'c:slow', event: 'test.parallel', persistent: true, handler: createDelayedHandler('c:slow', 100) },
404
+ ])
405
405
 
406
- const job = createMockJob('test.parallel', {})
407
- const ctx = createMockContext()
408
-
409
- await handle(job, ctx)
406
+ await handle(createMockJob('test.parallel', {}), createMockContext(bus))
410
407
 
411
408
  const starts = executionLog.filter((e) => e.phase === 'start')
412
409
  const ends = executionLog.filter((e) => e.phase === 'end')
@@ -422,40 +419,23 @@ describe('Events Worker', () => {
422
419
  })
423
420
 
424
421
  it('should throw when all subscribers fail', async () => {
425
- const mockModules: Module[] = [
422
+ const bus = createBusWith([
426
423
  {
427
- id: 'module-a',
428
- subscribers: [
429
- {
430
- id: 'a:failing-subscriber',
431
- event: 'test.event',
432
- handler: async () => {
433
- throw new Error('Subscriber A failed')
434
- },
435
- },
436
- ],
424
+ id: 'a:failing-subscriber',
425
+ event: 'test.event',
426
+ persistent: true,
427
+ handler: async () => { throw new Error('Subscriber A failed') },
437
428
  },
438
429
  {
439
- id: 'module-b',
440
- subscribers: [
441
- {
442
- id: 'b:failing-subscriber',
443
- event: 'test.event',
444
- handler: async () => {
445
- throw new Error('Subscriber B failed')
446
- },
447
- },
448
- ],
430
+ id: 'b:failing-subscriber',
431
+ event: 'test.event',
432
+ persistent: true,
433
+ handler: async () => { throw new Error('Subscriber B failed') },
449
434
  },
450
- ]
451
-
452
- registerCliModules(mockModules)
453
-
454
- const job = createMockJob('test.event', { data: 'test' })
455
- const ctx = createMockContext()
435
+ ])
456
436
 
457
- await expect(handle(job, ctx)).rejects.toThrow(
458
- '2/2 subscriber(s) failed for event "test.event": a:failing-subscriber, b:failing-subscriber'
437
+ await expect(handle(createMockJob('test.event', { data: 'test' }), createMockContext(bus))).rejects.toThrow(
438
+ '[internal] 2/2 subscriber(s) failed for event "test.event": a:failing-subscriber, b:failing-subscriber'
459
439
  )
460
440
 
461
441
  expect(workerLoggerError).toHaveBeenCalledTimes(2)
@@ -463,111 +443,67 @@ describe('Events Worker', () => {
463
443
  })
464
444
 
465
445
  describe('single-delivery dispatch (OM_EVENTS_SINGLE_DELIVERY) — issue #2960', () => {
466
- const origFlag = process.env.OM_EVENTS_SINGLE_DELIVERY
467
-
468
- afterEach(() => {
469
- if (origFlag === undefined) delete process.env.OM_EVENTS_SINGLE_DELIVERY
470
- else process.env.OM_EVENTS_SINGLE_DELIVERY = origFlag
471
- })
472
-
473
- const createMockJob = (
474
- event: string,
475
- payload: unknown,
476
- options?: { tenantId?: string | null; organizationId?: string | null },
477
- ): QueuedJob<{
478
- event: string
479
- payload: unknown
480
- options?: { tenantId?: string | null; organizationId?: string | null }
481
- }> => ({
482
- id: 'test-job-id',
483
- payload: { event, payload, options },
484
- createdAt: new Date().toISOString(),
485
- })
486
-
487
- const createMockContext = (): JobContext & { resolve: <T = unknown>(name: string) => T } => ({
488
- jobId: 'test-job-id',
489
- attemptNumber: 1,
490
- queueName: 'events',
491
- resolve: <T = unknown>(name: string): T => { throw new Error(`No mock for ${name}`) },
492
- })
493
-
494
446
  it('flag ON: dispatches wildcard persistent subscribers that exact-match never reached', async () => {
495
447
  process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
496
- clearListenerCache()
497
448
  const calls: string[] = []
498
- registerCliModules([{
499
- id: 'm',
500
- subscribers: [
501
- { id: 'wildcard:persistent', event: '*', persistent: true, handler: () => { calls.push('wild') } },
502
- ],
503
- }])
449
+ const bus = createBusWith([
450
+ { id: 'wildcard:persistent', event: '*', persistent: true, handler: () => { calls.push('wild') } },
451
+ ])
504
452
 
505
- await handle(createMockJob('any.event', {}), createMockContext())
453
+ await handle(createMockJob('any.event', {}), createMockContext(bus))
506
454
 
507
455
  expect(calls).toEqual(['wild'])
508
456
  })
509
457
 
510
458
  it('flag ON: excludes non-persistent subscribers from worker dispatch', async () => {
511
459
  process.env.OM_EVENTS_SINGLE_DELIVERY = 'true'
512
- clearListenerCache()
513
460
  const calls: string[] = []
514
- registerCliModules([{
515
- id: 'm',
516
- subscribers: [
517
- { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
518
- { id: 'e', event: 'user.created', persistent: false, handler: () => { calls.push('e') } },
519
- ],
520
- }])
461
+ const bus = createBusWith([
462
+ { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
463
+ { id: 'e', event: 'user.created', persistent: false, handler: () => { calls.push('e') } },
464
+ ])
521
465
 
522
- await handle(createMockJob('user.created', {}), createMockContext())
466
+ await handle(createMockJob('user.created', {}), createMockContext(bus))
523
467
 
524
468
  expect(calls).toEqual(['p'])
525
469
  })
526
470
 
527
- it('default (unset): dispatches wildcard persistent subscribers (single-delivery is default-on)', async () => {
471
+ it('worker selection ignores the flag: wildcard persistent subscribers are always reached', async () => {
528
472
  delete process.env.OM_EVENTS_SINGLE_DELIVERY
529
- clearListenerCache()
530
473
  const calls: string[] = []
531
- registerCliModules([{
532
- id: 'm',
533
- subscribers: [
534
- { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
535
- { id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
536
- ],
537
- }])
474
+ const bus = createBusWith([
475
+ { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
476
+ { id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
477
+ ])
538
478
 
539
- await handle(createMockJob('user.created', {}), createMockContext())
479
+ await handle(createMockJob('user.created', {}), createMockContext(bus))
540
480
 
541
481
  // Default-on: pattern dispatch reaches both the exact-match and the wildcard
542
482
  // persistent subscriber.
543
483
  expect(calls.sort()).toEqual(['p', 'w'])
544
484
  })
545
485
 
546
- it('default (unset): forwards eventName and trusted scope to persistent wildcard subscribers', async () => {
486
+ it('forwards eventName and trusted scope to persistent wildcard subscribers', async () => {
547
487
  delete process.env.OM_EVENTS_SINGLE_DELIVERY
548
- clearListenerCache()
549
488
  const contexts: Array<{
550
489
  eventName?: string
551
490
  tenantId?: string | null
552
491
  organizationId?: string | null
553
492
  }> = []
554
- registerCliModules([{
555
- id: 'm',
556
- subscribers: [
557
- {
558
- id: 'workflow:event-trigger',
559
- event: '*',
560
- persistent: true,
561
- handler: (_payload, ctx) => {
562
- contexts.push({
563
- eventName: ctx.eventName,
564
- tenantId: ctx.tenantId,
565
- organizationId: ctx.organizationId,
566
- })
567
- },
493
+ const bus = createBusWith([
494
+ {
495
+ id: 'workflow:event-trigger',
496
+ event: '*',
497
+ persistent: true,
498
+ handler: (_payload, ctx) => {
499
+ contexts.push({
500
+ eventName: ctx.eventName,
501
+ tenantId: ctx.tenantId,
502
+ organizationId: ctx.organizationId,
503
+ })
568
504
  },
569
- ],
570
- }])
505
+ },
506
+ ])
571
507
 
572
508
  await handle(
573
509
  createMockJob(
@@ -575,7 +511,7 @@ describe('Events Worker', () => {
575
511
  { id: 'deal-1', tenantId: 'payload-tenant', organizationId: 'payload-org' },
576
512
  { tenantId: 'trusted-tenant', organizationId: 'trusted-org' },
577
513
  ),
578
- createMockContext(),
514
+ createMockContext(bus),
579
515
  )
580
516
 
581
517
  expect(contexts).toEqual([{
@@ -585,22 +521,55 @@ describe('Events Worker', () => {
585
521
  }])
586
522
  })
587
523
 
588
- it('flag explicitly OFF (legacy opt-out): preserves exact-match dispatch and never reaches wildcards', async () => {
524
+ // Producer/worker env skew: the worker's own flag must not change selection.
525
+ // A stamp-less job reaching a worker with the flag OFF used to fall back to
526
+ // exact-match, re-running ephemerals and missing wildcards.
527
+ it('flag explicitly OFF on the worker still selects persistent subscribers by pattern', async () => {
589
528
  process.env.OM_EVENTS_SINGLE_DELIVERY = 'false'
590
- clearListenerCache()
591
529
  const calls: string[] = []
592
- registerCliModules([{
593
- id: 'm',
594
- subscribers: [
595
- { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
596
- { id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
597
- ],
598
- }])
530
+ const bus = createBusWith([
531
+ { id: 'p', event: 'user.created', persistent: true, handler: () => { calls.push('p') } },
532
+ { id: 'w', event: '*', persistent: true, handler: () => { calls.push('w') } },
533
+ ])
599
534
 
600
- await handle(createMockJob('user.created', {}), createMockContext())
535
+ await handle(createMockJob('user.created', {}), createMockContext(bus))
601
536
 
602
- // Legacy behavior: exact-match only, so the wildcard subscriber is not reached here.
603
- expect(calls).toEqual(['p'])
537
+ expect(calls.sort()).toEqual(['p', 'w'])
538
+ })
539
+ })
540
+
541
+ describe('zero-subscriber reporting', () => {
542
+ it('warns once per event name, then drops to debug', async () => {
543
+ // This line is the last remaining visibility into the silent-loss path the
544
+ // whole change exists to close, so it has to stay findable - but an install
545
+ // carrying none of the wildcard persistent subscribers would otherwise get
546
+ // one `warn` per queued event forever, and a warning that fires in steady
547
+ // state is one operators learn to skip past.
548
+ //
549
+ // The event name is unique to this test on purpose: the suppression set is
550
+ // module state shared across the whole file, so a name another test already
551
+ // dispatched would have consumed the first-call `warn`.
552
+ const bus = createBusWith([
553
+ { id: 'unrelated', event: 'other.event', persistent: true, handler: () => {} },
554
+ ])
555
+
556
+ await handle(createMockJob('nobody.listens.here', {}), createMockContext(bus))
557
+
558
+ expect(workerLoggerWarn).toHaveBeenCalledTimes(1)
559
+ expect(workerLoggerWarn).toHaveBeenCalledWith('Queued event dispatched to zero subscribers', {
560
+ event: 'nobody.listens.here',
561
+ jobId: 'test-job-id',
562
+ })
563
+ expect(workerLoggerDebug).not.toHaveBeenCalled()
564
+
565
+ await handle(createMockJob('nobody.listens.here', {}), createMockContext(bus))
566
+
567
+ expect(workerLoggerWarn).toHaveBeenCalledTimes(1)
568
+ expect(workerLoggerDebug).toHaveBeenCalledTimes(1)
569
+ expect(workerLoggerDebug).toHaveBeenCalledWith('Queued event dispatched to zero subscribers', {
570
+ event: 'nobody.listens.here',
571
+ jobId: 'test-job-id',
572
+ })
604
573
  })
605
574
  })
606
575
  })