comq 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.15.2](https://github.com/toa-io/comq/compare/v0.15.1...v0.15.2) (2026-08-04)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * report and recover undeliverable replies ([b2d2d0f](https://github.com/toa-io/comq/commit/b2d2d0f11d7714c2d32d3d85894fa78e2203f8c6))
7
+ * report and recover undeliverable replies ([#241](https://github.com/toa-io/comq/issues/241)) ([73e5592](https://github.com/toa-io/comq/commit/73e5592762634fbead29d825be68a3da3136729b))
8
+
9
+ ## [0.15.1](https://github.com/toa-io/comq/compare/v0.15.0...v0.15.1) (2026-08-02)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * skip end control when reply stream buffer overflows ([654e046](https://github.com/toa-io/comq/commit/654e046bdcbfbbbbbb789e5bb032bc16564221d1)), closes [#control](https://github.com/toa-io/comq/issues/control) [#reply](https://github.com/toa-io/comq/issues/reply)
15
+ * skip end control when reply stream buffer overflows ([#237](https://github.com/toa-io/comq/issues/237)) ([c34d87f](https://github.com/toa-io/comq/commit/c34d87f6d219ce6c8509d989117eadd55a33ca86))
16
+
1
17
  # [0.15.0](https://github.com/toa-io/comq/compare/v0.14.0...v0.15.0) (2026-08-01)
2
18
 
3
19
 
@@ -0,0 +1,25 @@
1
+ Feature: Sharded subscriptions
2
+
3
+ A subscription is only complete once every shard has it, otherwise a message
4
+ published to a randomly picked shard has nowhere to be routed.
5
+
6
+ Background:
7
+ Given an active sharded connection
8
+
9
+ Scenario: Events published right after subscribing
10
+ Given events are exclusively consumed from the `numbers_added` exchange
11
+ When a stream of 100 events is emitted to the `numbers_added` exchange
12
+ Then 100 events have been received
13
+
14
+ Scenario: Requesting streams repeatedly
15
+ Given a generator replying `get_numbers` queue:
16
+ """
17
+ function * ({ amount }) {
18
+ for (let i = 0; i < amount; i++) yield i
19
+ }
20
+ """
21
+ When the consumer requests 20 streams with the following request to the `get_numbers` queue:
22
+ """yaml
23
+ amount: 5
24
+ """
25
+ Then every stream has been received
@@ -167,6 +167,38 @@ When('the consumer{number} requests a stream with request to the {token} queue',
167
167
  await fetch.call(this, queue, null, number)
168
168
  })
169
169
 
170
+ When('the consumer requests {number} streams with the following request to the {token} queue:',
171
+ /**
172
+ * @param {number} amount
173
+ * @param {string} queue
174
+ * @param {string} yaml
175
+ * @this {comq.features.Context}
176
+ */
177
+ async function (amount, queue, yaml) {
178
+ const payload = parse(yaml)
179
+
180
+ for (let number = 0; number < amount; number++) {
181
+ await fetch.call(this, queue, payload, number)
182
+
183
+ for await (const value of this.streams[number]) this.streamsValues[number].push(value)
184
+
185
+ this.streamsEnded[number] = true
186
+ }
187
+ })
188
+
189
+ Then('every stream has been received',
190
+ /**
191
+ * @this {comq.features.Context}
192
+ */
193
+ async function () {
194
+ for (const number of Object.keys(this.streams)) {
195
+ assert.equal(this.streamsEnded[number], true, `Stream ${number} was not closed`)
196
+
197
+ assert.notEqual(this.streamsValues[number].length, 0,
198
+ `Stream ${number} is empty`)
199
+ }
200
+ })
201
+
170
202
  Then('the consumer receives the reply:',
171
203
  /**
172
204
  * @param {string} yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -26,6 +26,8 @@ class ReplyStream extends Readable {
26
26
 
27
27
  #buffered = 0
28
28
 
29
+ #maxBufferSize
30
+
29
31
  /** @type {Map<number, unknown>} */
30
32
  #queue = new Map()
31
33
 
@@ -39,16 +41,27 @@ class ReplyStream extends Readable {
39
41
  this.#emitter = request.emitter
40
42
  this.#correlationId = request.properties.correlationId
41
43
  this.#idleInterval = global['COMQ_TESTING_IDLE_INTERVAL'] || IDLE_INTERVAL
44
+ this.#maxBufferSize = global['COMQ_TESTING_MAX_BUFFER_SIZE'] || MAX_BUFFER_SIZE
42
45
  this.#reply = reply
43
46
 
47
+ this.confirmation.catch(noop) // it is not awaited until the stream is handed over
48
+
44
49
  this.#emitter.on(this.#correlationId, this.arrange.bind(this))
50
+
51
+ // control.ok may never arrive, hence the watchdog is armed before the first message
52
+ this._heartbeat()
45
53
  }
46
54
 
47
55
  _destroy (error, callback) {
48
56
  this._clear()
49
57
  this.push(null)
50
58
 
51
- void this.#reply(this.#control, control.end)
59
+ // a no-op once control.ok has been received
60
+ this.confirmation.reject(error ?? new Error(UNCONFIRMED))
61
+
62
+ if (this.#control !== undefined)
63
+ void this.#reply(this.#control, control.end)
64
+
52
65
  super._destroy(error, callback)
53
66
  }
54
67
 
@@ -95,7 +108,11 @@ class ReplyStream extends Readable {
95
108
  }
96
109
 
97
110
  _buffer (payload, properties) {
98
- if (this.#buffered > MAX_BUFFER_SIZE) this.destroy()
111
+ if (this.#buffered > this.#maxBufferSize) {
112
+ this.destroy()
113
+
114
+ return
115
+ }
99
116
 
100
117
  this.#buffered++
101
118
  this.#queue.set(properties.headers.index, { payload, properties })
@@ -136,4 +153,9 @@ class ReplyStream extends Readable {
136
153
 
137
154
  const MAX_BUFFER_SIZE = 1000
138
155
 
156
+ const UNCONFIRMED = 'Reply stream has been destroyed before confirmation'
157
+
158
+ function noop () {}
159
+
139
160
  exports.ReplyStream = ReplyStream
161
+ exports.UNCONFIRMED = UNCONFIRMED
@@ -46,6 +46,10 @@ class Connection {
46
46
  this.#diagnostics.on('error', noop)
47
47
  }
48
48
 
49
+ get connected () {
50
+ return this.#connection !== undefined
51
+ }
52
+
49
53
  async open () {
50
54
  if (this.#opening !== null) return this.#opening
51
55
 
package/source/events.js CHANGED
@@ -4,4 +4,4 @@
4
4
  exports.connection = ['open', 'close', 'error']
5
5
 
6
6
  /** @type {comq.diagnostics.Event[]} */
7
- exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume']
7
+ exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return']
package/source/io.js CHANGED
@@ -349,7 +349,12 @@ class IO {
349
349
  if (isStream) {
350
350
  const stream = this.#createReplyStream(request, payload, properties)
351
351
 
352
- await stream.confirmation
352
+ try {
353
+ await stream.confirmation
354
+ } catch {
355
+ // the stream has never started, hence the request is re-sent
356
+ return reply.reject(RETRANSMISSION)
357
+ }
353
358
 
354
359
  reply.resolve(stream)
355
360
  } else {
@@ -439,6 +444,9 @@ class IO {
439
444
  properties.contentType = contentType
440
445
  properties.correlationId = request.properties.correlationId
441
446
 
447
+ // an unroutable reply must be returned by the broker rather than dropped
448
+ properties.mandatory = true
449
+
442
450
  return await this.#replies.fire(replyTo, buffer, properties)
443
451
  }
444
452
 
@@ -19,8 +19,11 @@ class Channel {
19
19
  /** @type {comq.Channel[]} */
20
20
  #pool
21
21
 
22
- /** @type {Set<Promise<comq.Channel>>} */
23
- #pending = new Set()
22
+ /** @type {Map<Promise<comq.Channel>, number>} */
23
+ #pending = new Map()
24
+
25
+ /** @type {Promex[]} */
26
+ #down = []
24
27
 
25
28
  /** @type {Map<comq.Channel, Promex>} */
26
29
  #bench = new Map()
@@ -48,11 +51,11 @@ class Channel {
48
51
  }
49
52
 
50
53
  async consume (queue, consumer) {
51
- return await this.#any((channel) => channel.consume(queue, consumer))
54
+ return await this.#every((channel) => channel.consume(queue, consumer))
52
55
  }
53
56
 
54
57
  async subscribe (queue, group, consumer) {
55
- await this.#any((channel) => channel.subscribe(queue, group, consumer))
58
+ await this.#every((channel) => channel.subscribe(queue, group, consumer))
56
59
  }
57
60
 
58
61
  async send (queue, buffer, options) {
@@ -86,8 +89,10 @@ class Channel {
86
89
  * @return {Promise<void>}
87
90
  */
88
91
  #create = async (connection, index) => {
92
+ this.#watch(connection, index)
93
+
89
94
  const pending = connection.createChannel(this.#type, index)
90
- const channel = await this.#pend(pending)
95
+ const channel = await this.#pend(pending, index)
91
96
 
92
97
  this.#add(channel)
93
98
  this.#pipe(channel)
@@ -97,12 +102,31 @@ class Channel {
97
102
  channel.diagnose('recover', () => this.#recover(channel))
98
103
  }
99
104
 
105
+ /**
106
+ * Tracks whether a shard is reachable, so that subscribing does not wait for
107
+ * one that is not. The pool itself is left alone: a channel is only benched
108
+ * when it actually fails to publish, otherwise a lost connection would
109
+ * interrupt the streams it is carrying.
110
+ *
111
+ * @param {comq.Connection} connection
112
+ * @param {number} index
113
+ */
114
+ #watch (connection, index) {
115
+ this.#down[index] = new Promex()
116
+
117
+ if (connection.connected === false) this.#down[index].resolve()
118
+
119
+ connection.diagnose('close', () => this.#down[index].resolve())
120
+ connection.diagnose('open', () => (this.#down[index] = new Promex()))
121
+ }
122
+
100
123
  /**
101
124
  * @param {Promise<comq.Channel>} pending
125
+ * @param {number} index
102
126
  * @return {Promise<comq.Channel>}
103
127
  */
104
- async #pend (pending) {
105
- this.#pending.add(pending)
128
+ async #pend (pending, index) {
129
+ this.#pending.set(pending, index)
106
130
 
107
131
  const channel = await pending
108
132
 
@@ -116,8 +140,43 @@ class Channel {
116
140
  */
117
141
  #pipe (channel) {
118
142
  for (const event of events.channel) {
143
+ if (event === RETURN) continue // returns are retried before being reported
144
+
119
145
  channel.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, channel.index))
120
146
  }
147
+
148
+ channel.diagnose(RETURN, (message) => this.#returned(message, channel))
149
+ }
150
+
151
+ /**
152
+ * An unroutable message is retried on the shards that have not seen it yet,
153
+ * since a queue may be declared on some of them only. The return is reported
154
+ * once every shard has rejected the message.
155
+ *
156
+ * @param {comq.amqp.Message} message
157
+ * @param {comq.Channel} channel
158
+ */
159
+ #returned (message, channel) {
160
+ const report = () => this.#diagnostics.emit(RETURN, message, channel.index)
161
+ const attempt = (message.properties.headers?.[RETURN_HEADER] ?? 0) + 1
162
+ const rest = this.#pool.filter((one) => one !== channel)
163
+
164
+ // only replies are published to the default exchange, and only they are mandatory
165
+ const exhausted = message.fields.exchange !== DEFAULT ||
166
+ attempt >= this.#connections.length ||
167
+ rest.length === 0
168
+
169
+ if (exhausted) return report()
170
+
171
+ const properties = {
172
+ ...message.properties,
173
+ mandatory: true,
174
+ headers: { ...message.properties.headers, [RETURN_HEADER]: attempt }
175
+ }
176
+
177
+ const next = rest[Math.floor(Math.random() * rest.length)]
178
+
179
+ next.fire(message.fields.routingKey, message.content, properties).catch(report)
121
180
  }
122
181
 
123
182
  /**
@@ -171,12 +230,49 @@ class Channel {
171
230
  }
172
231
 
173
232
  /**
233
+ * Resolves once every available shard has settled, requiring at least one to
234
+ * succeed. Benched shards are applied when they come back, which must not
235
+ * hold up the caller.
236
+ *
174
237
  * @param {(channel: comq.Channel) => void} fn
238
+ * @return {Promise<any>}
175
239
  */
176
- async #any (fn) {
177
- const promises = this.#apply(fn)
240
+ async #every (fn) {
241
+ const promises = []
242
+
243
+ for (const channel of this.#channels) {
244
+ promises.push(this.#unless(fn(channel), channel.index))
245
+ }
246
+
247
+ for (const [pending, index] of this.#pending) {
248
+ promises.push(this.#unless(pending.then(fn), index))
249
+ }
250
+
251
+ for (const recover of this.#bench.values()) recover.then(fn).catch(noop)
252
+
253
+ const results = await Promise.allSettled(promises)
254
+ const fulfilled = results.find((result) => result.status === 'fulfilled')
255
+
256
+ if (fulfilled === undefined) {
257
+ const reasons = results.map((result) => result.reason)
178
258
 
179
- return await Promise.any(promises)
259
+ throw new AggregateError(reasons, 'No shard is available')
260
+ }
261
+
262
+ return fulfilled.value
263
+ }
264
+
265
+ /**
266
+ * Gives up on a shard as soon as its connection is lost.
267
+ *
268
+ * @param {Promise<any>} promise
269
+ * @param {number} index
270
+ * @return {Promise<any>}
271
+ */
272
+ async #unless (promise, index) {
273
+ const down = this.#down[index]
274
+
275
+ return down === undefined ? await promise : await Promise.race([promise, down])
180
276
  }
181
277
 
182
278
  /**
@@ -197,7 +293,7 @@ class Channel {
197
293
  const promises = []
198
294
 
199
295
  for (const channel of this.#channels) promises.push(fn(channel))
200
- for (const pending of this.#pending) promises.push(pending.then(fn))
296
+ for (const pending of this.#pending.keys()) promises.push(pending.then(fn))
201
297
  for (const recover of this.#bench.values()) promises.push(recover.then(fn))
202
298
 
203
299
  return promises
@@ -234,4 +330,10 @@ async function create (connections, type) {
234
330
  return channel
235
331
  }
236
332
 
333
+ const DEFAULT = ''
334
+ const RETURN = 'return'
335
+ const RETURN_HEADER = 'x-return'
336
+
337
+ function noop () {}
338
+
237
339
  exports.create = create
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ const { EventEmitter } = require('node:events')
4
+ const { once } = require('node:events')
5
+ const { ReplyStream, UNCONFIRMED } = require('../source/.io/ReplyStream')
6
+ const { control } = require('../source/.io/const')
7
+
8
+ /** @type {jest.Mock} */
9
+ let reply
10
+
11
+ /** @type {ReplyStream} */
12
+ let stream
13
+
14
+ beforeEach(() => {
15
+ global.COMQ_TESTING_MAX_BUFFER_SIZE = 3
16
+ global.COMQ_TESTING_IDLE_INTERVAL = 60_000
17
+
18
+ reply = jest.fn().mockResolvedValue(true)
19
+
20
+ const request = {
21
+ emitter: new EventEmitter(),
22
+ properties: { correlationId: 'test-correlation' }
23
+ }
24
+
25
+ stream = new ReplyStream(request, reply)
26
+ })
27
+
28
+ afterEach(() => {
29
+ delete global.COMQ_TESTING_MAX_BUFFER_SIZE
30
+ delete global.COMQ_TESTING_IDLE_INTERVAL
31
+
32
+ stream.destroy()
33
+ })
34
+
35
+ it('should destroy without calling reply when buffer overflows before control.ok', async () => {
36
+ const closed = once(stream, 'close')
37
+
38
+ for (let index = 1; index <= 5; index++) {
39
+ stream.arrange(index, { headers: { index } })
40
+ }
41
+
42
+ await closed
43
+
44
+ expect(stream.destroyed).toBe(true)
45
+ expect(reply).not.toHaveBeenCalled()
46
+ })
47
+
48
+ it('should reject confirmation when buffer overflows before control.ok', async () => {
49
+ for (let index = 1; index <= 5; index++) {
50
+ stream.arrange(index, { headers: { index } })
51
+ }
52
+
53
+ await expect(stream.confirmation).rejects.toThrow(UNCONFIRMED)
54
+ })
55
+
56
+ it('should reject confirmation when destroyed before control.ok', async () => {
57
+ stream.destroy()
58
+
59
+ await expect(stream.confirmation).rejects.toThrow(UNCONFIRMED)
60
+ })
61
+
62
+ it('should reject confirmation when nothing arrives within the idle interval', async () => {
63
+ global.COMQ_TESTING_IDLE_INTERVAL = 10
64
+
65
+ const request = {
66
+ emitter: new EventEmitter(),
67
+ properties: { correlationId: 'idle-correlation' }
68
+ }
69
+
70
+ const idle = new ReplyStream(request, reply)
71
+
72
+ await expect(idle.confirmation).rejects.toThrow(UNCONFIRMED)
73
+ })
74
+
75
+ it('should keep confirmation resolved after control.ok', async () => {
76
+ stream.arrange(control.ok, { headers: { index: 0 }, type: 'control' })
77
+
78
+ await expect(stream.confirmation).resolves.toBeUndefined()
79
+
80
+ stream.destroy()
81
+
82
+ await expect(stream.confirmation).resolves.toBeUndefined()
83
+ })
@@ -19,6 +19,7 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
19
19
  * @returns {jest.MockedObject<comq.Connection>}
20
20
  */
21
21
  const connection = (sharded = false) => (/** @type {jest.MockedObject<comq.Connection>} */ {
22
+ connected: true,
22
23
  createChannel: jest.fn(async (type, index) => channel(sharded, index)),
23
24
  open: jest.fn(async () => undefined),
24
25
  close: jest.fn(async () => undefined),
@@ -185,7 +185,8 @@ describe('encoding', () => {
185
185
 
186
186
  const props = {
187
187
  correlationId: properties.correlationId,
188
- contentType: properties.contentType
188
+ contentType: properties.contentType,
189
+ mandatory: true
189
190
  }
190
191
 
191
192
  expect(replies.fire).toHaveBeenCalledWith(properties.replyTo, reply, props)
@@ -237,6 +237,30 @@ describe('reply', () => {
237
237
 
238
238
  expect(output).toStrictEqual(value)
239
239
  })
240
+
241
+ it('should re-send the Request if a reply stream is never confirmed', async () => {
242
+ global.COMQ_TESTING_MAX_BUFFER_SIZE = 3
243
+
244
+ const correlationId = requests.send.mock.calls[0][2].correlationId
245
+ const callback = replies.consume.mock.calls[0][1]
246
+
247
+ try {
248
+ // out of order chunks overflow the buffer, so the stream dies before control.ok
249
+ for (let index = 1; index <= 5; index++) {
250
+ const properties = { correlationId, headers: { index } }
251
+ const message = /** @type {comq.amqp.Message} */ { content: randomBytes(8), properties }
252
+
253
+ await callback(message)
254
+ }
255
+
256
+ await immediate()
257
+ await immediate()
258
+
259
+ expect(requests.send).toHaveBeenCalledTimes(2)
260
+ } finally {
261
+ delete global.COMQ_TESTING_MAX_BUFFER_SIZE
262
+ }
263
+ })
240
264
  })
241
265
 
242
266
  const reply = async (content = randomBytes(8), contentType = undefined) => {
@@ -87,28 +87,75 @@ describe.each(['consume', 'subscribe'])('%s', (method) => {
87
87
  for (const chan of channels) expect(chan[method]).toHaveBeenCalledWith(...args)
88
88
  })
89
89
 
90
- it(`should should ${method} using available and pending channels`, async () => {
90
+ it(`should not resolve ${method} until pending channels are created`, async () => {
91
91
  const promise = promex()
92
92
 
93
93
  connections[0].createChannel.mockImplementationOnce(() => promise)
94
94
 
95
95
  channel = await create(connections, type)
96
96
 
97
- await channel[method](...args)
97
+ let resolved = false
98
+
99
+ const subscription = channel[method](...args).then(() => { resolved = true })
100
+
101
+ await immediate()
98
102
 
99
103
  /** @type {jest.MockedObject<comq.Channel>} */
100
104
  const chan1 = await connections[1].createChannel.mock.results[0].value
101
105
 
102
106
  expect(chan1[method]).toHaveBeenCalled()
107
+ expect(resolved).toStrictEqual(false)
103
108
 
104
109
  /** @type {jest.MockedObject<comq.Channel>} */
105
110
  const chan0 = mock.channel()
106
111
 
107
112
  promise.resolve(chan0)
108
113
 
109
- await immediate()
114
+ await subscription
110
115
 
111
116
  expect(chan0[method]).toHaveBeenCalled()
117
+ expect(resolved).toStrictEqual(true)
118
+ })
119
+
120
+ it(`should resolve ${method} without a disconnected shard`, async () => {
121
+ const promise = promex()
122
+
123
+ connections[0].createChannel.mockImplementationOnce(() => promise)
124
+ connections[0].connected = false
125
+
126
+ try {
127
+ channel = await create(connections, type)
128
+
129
+ // must not wait for the channel that is never going to be created
130
+ await channel[method](...args)
131
+
132
+ /** @type {jest.MockedObject<comq.Channel>} */
133
+ const chan1 = await connections[1].createChannel.mock.results[0].value
134
+
135
+ expect(chan1[method]).toHaveBeenCalled()
136
+ } finally {
137
+ connections[0].connected = true
138
+ promise.resolve(mock.channel())
139
+ }
140
+ })
141
+
142
+ it(`should resolve ${method} when a shard disconnects`, async () => {
143
+ const promise = promex()
144
+
145
+ connections[0].createChannel.mockImplementationOnce(() => promise)
146
+
147
+ channel = await create(connections, type)
148
+
149
+ const subscription = channel[method](...args)
150
+ const calls = connections[0].diagnose.mock.calls.filter((call) => call[0] === 'close')
151
+
152
+ expect(calls.length).toBeGreaterThan(0)
153
+
154
+ for (const call of calls) call[1]()
155
+
156
+ await subscription
157
+
158
+ promise.resolve(mock.channel())
112
159
  })
113
160
 
114
161
  it(`should ${method} using a channel removed from the pool once it is recovered`, async () => {
@@ -339,6 +386,43 @@ describe('diagnose', () => {
339
386
  expect(listener).toHaveBeenCalledWith(expect.any(Number))
340
387
  })
341
388
 
389
+ it('should re-fire a returned message on another shard', async () => {
390
+ const listener = /** @type {jest.MockedFunction} */ jest.fn()
391
+ const index = random(channels.length)
392
+ const chan = channels[index]
393
+ const message = returned()
394
+
395
+ channel.diagnose('return', listener)
396
+
397
+ emitReturn(chan, message)
398
+
399
+ const rest = channels.filter((one) => one !== chan)
400
+
401
+ const properties = expect.objectContaining(
402
+ { mandatory: true, headers: { 'x-return': 1 } })
403
+
404
+ for (const one of rest) {
405
+ expect(one.fire).toHaveBeenCalledWith(message.fields.routingKey, message.content, properties)
406
+ }
407
+
408
+ expect(listener).not.toHaveBeenCalled()
409
+ })
410
+
411
+ it('should emit `return` event once every shard has returned the message', async () => {
412
+ const listener = /** @type {jest.MockedFunction} */ jest.fn()
413
+ const index = random(channels.length)
414
+ const chan = channels[index]
415
+ const message = returned({ 'x-return': channels.length - 1 })
416
+
417
+ channel.diagnose('return', listener)
418
+
419
+ emitReturn(chan, message)
420
+
421
+ for (const one of channels) expect(one.fire).not.toHaveBeenCalled()
422
+
423
+ expect(listener).toHaveBeenCalledWith(message, index)
424
+ })
425
+
342
426
  it('should emit `pause` and `unpause` events', async () => {
343
427
  const queue = generate()
344
428
  const buffer = randomBytes(8)
@@ -428,6 +512,30 @@ it('should remove channel with back pressure from the pool', async () => {
428
512
  expect(chan.send).toHaveBeenCalled()
429
513
  })
430
514
 
515
+ /**
516
+ * @param {Record<string, any>} [headers]
517
+ * @return {comq.amqp.Message}
518
+ */
519
+ function returned (headers = {}) {
520
+ return /** @type {comq.amqp.Message} */ {
521
+ content: randomBytes(8),
522
+ fields: { exchange: '', routingKey: generate() },
523
+ properties: { correlationId: generate(), headers }
524
+ }
525
+ }
526
+
527
+ /**
528
+ * @param {jest.MockedObject<comq.Channel>} channel
529
+ * @param {comq.amqp.Message} message
530
+ */
531
+ function emitReturn (channel, message) {
532
+ const calls = channel.diagnose.mock.calls.filter((call) => call[0] === 'return')
533
+
534
+ expect(calls.length).toBeGreaterThan(0)
535
+
536
+ for (const call of calls) call[1](message)
537
+ }
538
+
431
539
  /**
432
540
  * @return {Promise<jest.MockedObject<comq.Channel>[]>}
433
541
  */
@@ -6,6 +6,8 @@ import * as _topology from './topology'
6
6
  declare namespace comq {
7
7
 
8
8
  interface Connection {
9
+ readonly connected?: boolean
10
+
9
11
  open(): Promise<void>
10
12
 
11
13
  close(): Promise<void>