comq 0.16.1 → 0.17.0

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/source/io.js CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  const stream = require('node:stream')
4
- const { randomBytes } = require('node:crypto')
5
4
  const { setTimeout } = require('node:timers/promises')
6
5
  const { Promex } = require('promex')
7
6
  const { memo, failsafe, lazy, track } = require('./attributes')
@@ -38,6 +37,9 @@ class IO {
38
37
  /** @type {Map<Promex, comq.Request>} */
39
38
  #pendingReplies = new Map()
40
39
 
40
+ /** @type {[comq.diagnostics.Event, Function][]} */
41
+ #forwarders = []
42
+
41
43
  /** @type {Set<comq.Destroyable>} */
42
44
  #replyStreams = new Set()
43
45
 
@@ -53,7 +55,10 @@ class IO {
53
55
  this.#connection = connection
54
56
 
55
57
  for (const event of events.connection) {
56
- this.#connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args))
58
+ const forwarder = (...args) => this.#diagnostics.emit(event, ...args)
59
+
60
+ this.#connection.diagnose(event, forwarder)
61
+ this.#forwarders.push([event, forwarder])
57
62
  }
58
63
  }
59
64
 
@@ -87,10 +92,11 @@ class IO {
87
92
  )
88
93
  }
89
94
 
90
- const request = this.#createRequest(queue, payload, encoding)
95
+ const [buffer, contentType] = this.#encode(payload, encoding)
96
+ const request = this.#createRequest(queue, contentType)
91
97
  const reply = this.#createReply(request)
92
98
 
93
- await this.#requests.send(queue, request.buffer, request.properties)
99
+ await this.#requests.send(queue, buffer, request.properties)
94
100
 
95
101
  return reply
96
102
  }))
@@ -126,20 +132,46 @@ class IO {
126
132
  )
127
133
  }
128
134
 
129
- /** @type {comq.amqp.options.Publish} */
130
- const properties = {}
135
+ const [buffer, properties] = this.#properties(payload, encoding)
131
136
 
132
- if (typeof encoding === 'object') { // properties passed
133
- Object.assign(properties, encoding)
137
+ await this.#events[method](exchange, buffer, properties)
138
+ })
134
139
 
135
- encoding = /** @type {comq.Encoding} */ properties.contentType
136
- }
140
+ /**
141
+ * Publishes to a routed exchange under a key, where `emit` fans out. The exchange is
142
+ * `direct`, so a message reaches the queues bound under that key and no others.
143
+ */
144
+ route = lazy(this, this.#createEventChannel,
145
+ /**
146
+ * @param {string} exchange
147
+ * @param {string} key
148
+ * @param {any} payload
149
+ * @param {comq.Encoding | comq.amqp.options.Publish} [encoding]
150
+ * @returns {Promise<void>}
151
+ */
152
+ async (exchange, key, payload, encoding) => {
153
+ const [buffer, properties] = this.#properties(payload, encoding)
137
154
 
138
- const [buffer, contentType] = this.#encode(payload, encoding)
155
+ await this.#events.route(exchange, key, buffer, properties)
156
+ })
139
157
 
140
- properties.contentType = contentType
158
+ /**
159
+ * Consumes a named durable queue bound to a routed exchange under a key, where `consume`
160
+ * takes everything published to a fanout. The queue is named rather than derived, because
161
+ * what is bound to it is a key rather than an exchange.
162
+ */
163
+ subscribe = lazy(this, this.#createEventChannel,
164
+ /**
165
+ * @param {string} exchange
166
+ * @param {string} queue
167
+ * @param {string} key
168
+ * @param {comq.Consumer} callback
169
+ * @returns {Promise<void>}
170
+ */
171
+ async (exchange, queue, key, callback) => {
172
+ const consumer = this.#getEventConsumer(callback)
141
173
 
142
- await this.#events[method](exchange, buffer, properties)
174
+ await this.#events.bound(exchange, queue, key, consumer)
143
175
  })
144
176
 
145
177
  process = lazy(this, this.#createEventChannel,
@@ -169,6 +201,11 @@ class IO {
169
201
  await Promise.all([this.#requests, this.#replies, this.#events]
170
202
  .map((channel) => channel?.close()))
171
203
 
204
+ // a connection that outlives its IO must not keep it as a listener
205
+ for (const [event, forwarder] of this.#forwarders) this.#connection.forget(event, forwarder)
206
+
207
+ this.#forwarders = []
208
+
172
209
  await this.#connection.close()
173
210
  })
174
211
 
@@ -189,6 +226,30 @@ class IO {
189
226
  this.#events = await this.#createChannel('event')
190
227
  }
191
228
 
229
+ /**
230
+ * What a publication carries, from what its caller passed as an encoding or as properties.
231
+ *
232
+ * @param {any} payload
233
+ * @param {comq.Encoding | comq.amqp.options.Publish} [encoding]
234
+ * @returns {[Buffer, comq.amqp.options.Publish]}
235
+ */
236
+ #properties (payload, encoding) {
237
+ /** @type {comq.amqp.options.Publish} */
238
+ const properties = {}
239
+
240
+ if (typeof encoding === 'object') { // properties passed
241
+ Object.assign(properties, encoding)
242
+
243
+ encoding = /** @type {comq.Encoding} */ properties.contentType
244
+ }
245
+
246
+ const [buffer, contentType] = this.#encode(payload, encoding)
247
+
248
+ properties.contentType = contentType
249
+
250
+ return [buffer, properties]
251
+ }
252
+
192
253
  async #consumeReplies (queue) {
193
254
  const emitter = io.createReplyEmitter(queue)
194
255
  const consumer = this.#getReplyConsumer(queue, emitter)
@@ -274,7 +335,7 @@ class IO {
274
335
  (message) => {
275
336
  const payload = decode(message)
276
337
 
277
- emitter.emit(message.properties.correlationId, payload, message.properties)
338
+ emitter.emit(message.properties.correlationId, payload, message.properties, message.content.length)
278
339
  }
279
340
 
280
341
  /**
@@ -289,20 +350,21 @@ class IO {
289
350
  })
290
351
 
291
352
  /**
353
+ * The request holds no copy of what was sent: a retransmission encodes the
354
+ * payload anew, and an unanswered request would otherwise keep two of it.
355
+ *
292
356
  * @param {string} queue
293
- * @param {any} payload
294
- * @param {comq.Encoding} [encoding]
357
+ * @param {comq.Encoding} contentType
295
358
  * @return {comq.Request}
296
359
  */
297
- #createRequest (queue, payload, encoding) {
298
- const [buffer, contentType] = this.#encode(payload, encoding)
360
+ #createRequest (queue, contentType) {
299
361
  const emitter = this.#emitters.get(queue)
300
- const correlationId = randomBytes(8).toString('hex')
362
+ const correlationId = emitter.next()
301
363
 
302
364
  /** @type {comq.amqp.Properties} */
303
365
  const properties = { contentType, correlationId, replyTo: emitter.queue }
304
366
 
305
- return { buffer, emitter, properties }
367
+ return { emitter, properties }
306
368
  }
307
369
 
308
370
  /**
@@ -312,7 +374,7 @@ class IO {
312
374
  #createReply (request) {
313
375
  const reply = this.#createPendingReply(request)
314
376
 
315
- request.emitter.once(request.properties.correlationId, this.#getReplyResolver(request, reply))
377
+ request.emitter.on(request.properties.correlationId, this.#getReplyResolver(request, reply))
316
378
 
317
379
  return reply
318
380
  }
@@ -323,12 +385,12 @@ class IO {
323
385
  */
324
386
  #createPendingReply (request) {
325
387
  const reply = new Promex()
388
+ const settled = () => this.#pendingReplies.delete(reply)
326
389
 
327
390
  this.#pendingReplies.set(reply, request)
328
391
 
329
- reply
330
- .catch(noop)
331
- .finally(() => this.#pendingReplies.delete(reply))
392
+ // one derived promise per request, and a rejection handled with it
393
+ reply.then(settled, settled)
332
394
 
333
395
  return reply
334
396
  }
@@ -338,11 +400,14 @@ class IO {
338
400
  * @param reply
339
401
  */
340
402
  #getReplyResolver (request, reply) {
341
- return async (payload, properties) => {
403
+ return async (payload, properties, size) => {
342
404
  const isStream = properties.headers?.index !== undefined
343
405
 
406
+ // a reply is answered once; a reply stream takes the place of this resolver
407
+ request.emitter.off(request.properties.correlationId)
408
+
344
409
  if (isStream) {
345
- const stream = this.#createReplyStream(request, payload, properties)
410
+ const stream = this.#createReplyStream(request, payload, properties, size)
346
411
 
347
412
  try {
348
413
  await stream.confirmation
@@ -358,10 +423,10 @@ class IO {
358
423
  }
359
424
  }
360
425
 
361
- #createReplyStream (request, payload, properties) {
426
+ #createReplyStream (request, payload, properties, size) {
362
427
  const stream = new io.ReplyStream(request, this.#reply.bind(this))
363
428
 
364
- stream.arrange(payload, properties)
429
+ stream.arrange(payload, properties, size)
365
430
  this.#addReplyStream(/** @type {comq.Destroyable} */ stream)
366
431
 
367
432
  return stream
@@ -453,7 +518,7 @@ class IO {
453
518
  for (const [reply, request] of this.#pendingReplies) {
454
519
  // detaching this attempt alone leaves the listeners of the reply streams
455
520
  // that are still flowing over the other shards in place
456
- request.emitter.removeAllListeners(request.properties.correlationId)
521
+ request.emitter.off(request.properties.correlationId)
457
522
 
458
523
  // trigger failsafe attribute
459
524
  reply.reject(RETRANSMISSION)
@@ -484,6 +549,4 @@ const DEFAULT = 'application/json'
484
549
 
485
550
  const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
486
551
 
487
- function noop () {}
488
-
489
552
  exports.IO = IO
@@ -5,12 +5,20 @@ const stream = require('node:stream/promises')
5
5
 
6
6
  function pipeline (source, transform, channel) {
7
7
  const destination = new Pipeline(transform)
8
+ const pause = source.pause.bind(source)
9
+ const resume = source.resume.bind(source)
8
10
 
9
- // eslint-disable-next-line no-void
10
- void stream.pipeline(source, destination)
11
+ channel.diagnose('pause', pause)
12
+ channel.diagnose('resume', resume)
11
13
 
12
- channel.diagnose('pause', source.pause.bind(source))
13
- channel.diagnose('resume', source.resume.bind(source))
14
+ // the source must not outlive the pipeline as a listener of the channel;
15
+ // a failure is delivered through the destination, the promise says nothing new
16
+ const detach = () => {
17
+ channel.forget('pause', pause)
18
+ channel.forget('resume', resume)
19
+ }
20
+
21
+ stream.pipeline(source, destination).then(detach, detach)
14
22
 
15
23
  return destination
16
24
  }
@@ -46,6 +46,10 @@ class Connection {
46
46
  this.#diagnostics.on(event, listener)
47
47
  }
48
48
 
49
+ forget (event, listener) {
50
+ this.#diagnostics.off(event, listener)
51
+ }
52
+
49
53
  /**
50
54
  * @param {comq.Connection} connection
51
55
  * @param {number} index
@@ -0,0 +1,170 @@
1
+ 'use strict'
2
+
3
+ const { Readable } = require('node:stream')
4
+ const { Promex } = require('promex')
5
+ const { ReplyPipe } = require('../source/.io/ReplyPipe')
6
+ const { createReplyEmitter } = require('../source/.io/createReplyEmitter')
7
+ const { control, FLOW_HEADER } = require('../source/.io/const')
8
+ const { timeout } = require('./helpers')
9
+
10
+ /** @type {jest.Mock} */
11
+ let reply
12
+
13
+ /** @type {[any, comq.amqp.options.Publish][]} */
14
+ let sent
15
+
16
+ let channel
17
+
18
+ /** @type {comq.ReplyEmitter} */
19
+ let feedback
20
+
21
+ let request
22
+
23
+ beforeEach(() => {
24
+ global.COMQ_TESTING_HEARTBEAT_INTERVAL = 60_000
25
+
26
+ sent = []
27
+ reply = jest.fn(async (message, properties) => {
28
+ sent.push([message, properties])
29
+
30
+ return true
31
+ })
32
+
33
+ channel = { diagnose: jest.fn(), forget: jest.fn() }
34
+ feedback = createReplyEmitter('control')
35
+ request = { properties: { correlationId: 'test-correlation', replyTo: 'replies' } }
36
+ })
37
+
38
+ afterEach(() => {
39
+ delete global.COMQ_TESTING_HEARTBEAT_INTERVAL
40
+ })
41
+
42
+ const messages = () => sent.map(([message]) => message)
43
+
44
+ const closing = (pipe) => new Promise((resolve) => pipe.on('close', resolve))
45
+
46
+ it('should confirm, announcing flow control', async () => {
47
+ const pipe = await ReplyPipe.create(request, Readable.from([]), channel, feedback, reply)
48
+
49
+ expect(sent[0][0]).toStrictEqual(control.ok)
50
+ expect(sent[0][1].headers).toStrictEqual({ index: 0, [FLOW_HEADER]: true })
51
+
52
+ await closing(pipe)
53
+ })
54
+
55
+ it('should transmit values in order, then end', async () => {
56
+ const pipe = await ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
57
+
58
+ await closing(pipe)
59
+
60
+ expect(messages()).toStrictEqual([control.ok, 1, 2, 3, control.end])
61
+ expect(sent.map(([, properties]) => properties.headers.index)).toStrictEqual([0, 1, 2, 3, 4])
62
+ })
63
+
64
+ // a source listened to runs at its own pace, and its output piles up in front of a paused channel
65
+ it('should pull the source no faster than the channel takes', async () => {
66
+ const gate = new Promex()
67
+
68
+ let pulled = 0
69
+
70
+ reply.mockImplementation(async (message) => {
71
+ if (message !== control.ok) await gate
72
+
73
+ return true
74
+ })
75
+
76
+ function * source () {
77
+ for (let i = 0; i < 100; i++) {
78
+ pulled++
79
+
80
+ yield i
81
+ }
82
+ }
83
+
84
+ const pipe = await ReplyPipe.create(request, Readable.from(source()), channel, feedback, reply)
85
+
86
+ await timeout(10)
87
+
88
+ // a Readable reads ahead by no more than its high water mark
89
+ expect(pulled).toBeLessThanOrEqual(20)
90
+
91
+ gate.resolve()
92
+
93
+ await closing(pipe)
94
+
95
+ expect(pulled).toStrictEqual(100)
96
+ })
97
+
98
+ it('should hold the source while the consumer has asked for a pause', async () => {
99
+ const pipe = await ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
100
+
101
+ feedback.emit(request.properties.correlationId, control.pause, {})
102
+
103
+ await timeout(10)
104
+
105
+ expect(messages()).toStrictEqual([control.ok])
106
+
107
+ feedback.emit(request.properties.correlationId, control.resume, {})
108
+
109
+ await closing(pipe)
110
+
111
+ expect(messages()).toStrictEqual([control.ok, 1, 2, 3, control.end])
112
+ })
113
+
114
+ it('should stop once the consumer has ended the stream', async () => {
115
+ const gate = new Promex()
116
+
117
+ reply.mockImplementation(async (message, properties) => {
118
+ sent.push([message, properties])
119
+
120
+ if (message !== control.ok) await gate
121
+
122
+ return true
123
+ })
124
+
125
+ const pipe = await ReplyPipe.create(request, Readable.from([1, 2, 3]), channel, feedback, reply)
126
+ const closed = closing(pipe)
127
+
128
+ feedback.emit(request.properties.correlationId, control.end, {})
129
+
130
+ await closed
131
+
132
+ gate.resolve()
133
+
134
+ await timeout(10)
135
+
136
+ expect(messages()).not.toContain(control.end)
137
+ expect(messages()).not.toContain(3)
138
+ expect(channel.forget).toHaveBeenCalledWith('return', expect.any(Function))
139
+ expect(feedback.pending).toStrictEqual(0)
140
+ })
141
+
142
+ it('should not heartbeat once the confirmation could not be delivered', async () => {
143
+ global.COMQ_TESTING_HEARTBEAT_INTERVAL = 5
144
+
145
+ reply.mockImplementation(async (message, properties) => {
146
+ sent.push([message, properties])
147
+
148
+ return false
149
+ })
150
+
151
+ const pipe = new ReplyPipe(request, Readable.from([1]), channel, feedback, reply)
152
+
153
+ await pipe.pipe()
154
+ await timeout(30)
155
+
156
+ expect(messages()).toStrictEqual([control.ok])
157
+ })
158
+
159
+ it('should heartbeat while idle', async () => {
160
+ global.COMQ_TESTING_HEARTBEAT_INTERVAL = 5
161
+
162
+ const source = new Readable({ objectMode: true, read () {} })
163
+ const pipe = await ReplyPipe.create(request, source, channel, feedback, reply)
164
+
165
+ await timeout(30)
166
+
167
+ expect(messages()).toContain(control.heartbeat)
168
+
169
+ pipe.destroy()
170
+ })
@@ -1,9 +1,9 @@
1
1
  'use strict'
2
2
 
3
- const { EventEmitter } = require('node:events')
4
3
  const { once } = require('node:events')
5
4
  const { ReplyStream, UNCONFIRMED } = require('../source/.io/ReplyStream')
6
- const { control } = require('../source/.io/const')
5
+ const { createReplyEmitter } = require('../source/.io/createReplyEmitter')
6
+ const { control, FLOW_HEADER } = require('../source/.io/const')
7
7
 
8
8
  /** @type {jest.Mock} */
9
9
  let reply
@@ -18,7 +18,7 @@ beforeEach(() => {
18
18
  reply = jest.fn().mockResolvedValue(true)
19
19
 
20
20
  const request = {
21
- emitter: new EventEmitter(),
21
+ emitter: createReplyEmitter('test'),
22
22
  properties: { correlationId: 'test-correlation' }
23
23
  }
24
24
 
@@ -63,7 +63,7 @@ it('should reject confirmation when nothing arrives within the idle interval', a
63
63
  global.COMQ_TESTING_IDLE_INTERVAL = 10
64
64
 
65
65
  const request = {
66
- emitter: new EventEmitter(),
66
+ emitter: createReplyEmitter('test'),
67
67
  properties: { correlationId: 'idle-correlation' }
68
68
  }
69
69
 
@@ -81,3 +81,81 @@ it('should keep confirmation resolved after control.ok', async () => {
81
81
 
82
82
  await expect(stream.confirmation).resolves.toBeUndefined()
83
83
  })
84
+
85
+ describe('flow', () => {
86
+ const TOTAL = 40 // well beyond the 16 values a Readable holds in object mode
87
+
88
+ /**
89
+ * @param {boolean} flow whether the producer honours pause and resume
90
+ */
91
+ const ok = (flow) => ({ headers: { index: 0, [FLOW_HEADER]: flow }, type: 'control' })
92
+
93
+ const send = () => {
94
+ for (let index = 1; index <= TOTAL; index++) stream.arrange(index, { headers: { index } })
95
+
96
+ stream.arrange(control.end, { headers: { index: TOTAL + 1 }, type: 'control' })
97
+ }
98
+
99
+ // dropping the listener once behind would leave the stream waiting for values that have passed
100
+ it('should keep receiving while the consumer is behind', async () => {
101
+ stream.arrange(control.ok, ok(false))
102
+ send()
103
+
104
+ const received = []
105
+
106
+ for await (const value of stream) received.push(value)
107
+
108
+ expect(received).toHaveLength(TOTAL)
109
+ expect(received[TOTAL - 1]).toStrictEqual(TOTAL)
110
+ })
111
+
112
+ it('should not ask a producer that knows nothing of pause', async () => {
113
+ stream.arrange(control.ok, ok(false))
114
+ send()
115
+
116
+ expect(reply).not.toHaveBeenCalled()
117
+ })
118
+
119
+ it('should ask the producer to pause once behind, and to resume once caught up', async () => {
120
+ stream.arrange(control.ok, ok(true))
121
+
122
+ for (let index = 1; index <= TOTAL; index++) stream.arrange(index, { headers: { index } })
123
+
124
+ expect(reply).toHaveBeenCalledTimes(1)
125
+ expect(reply).toHaveBeenCalledWith({ properties: ok(true) }, control.pause)
126
+
127
+ const drained = new Promise((resolve) => stream.on('data', (value) => { if (value === TOTAL) resolve() }))
128
+
129
+ await drained
130
+
131
+ expect(reply).toHaveBeenCalledTimes(2)
132
+ expect(reply).toHaveBeenLastCalledWith({ properties: ok(true) }, control.resume)
133
+ })
134
+
135
+ it('should bound the values held out of order by their size', async () => {
136
+ global.COMQ_TESTING_MAX_BUFFER_SIZE = 1000
137
+ global.COMQ_TESTING_MAX_BUFFER_BYTES = 100
138
+
139
+ try {
140
+ const request = {
141
+ emitter: createReplyEmitter('test'),
142
+ properties: { correlationId: 'bytes-correlation' }
143
+ }
144
+
145
+ const held = new ReplyStream(request, reply)
146
+ const closed = once(held, 'close')
147
+
148
+ held.arrange(1, { headers: { index: 1 } }, 60)
149
+
150
+ expect(held.destroyed).toBe(false)
151
+
152
+ held.arrange(2, { headers: { index: 2 } }, 60)
153
+
154
+ await closed
155
+
156
+ expect(held.destroyed).toBe(true)
157
+ } finally {
158
+ delete global.COMQ_TESTING_MAX_BUFFER_BYTES
159
+ }
160
+ })
161
+ })
@@ -0,0 +1,119 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+
5
+ const { amqplib } = require('./amqplib.mock')
6
+ const fixtures = require('./channel.fixtures')
7
+ const { create } = require('../source/channel')
8
+
9
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
10
+ let connection
11
+
12
+ /** @type {comq.Topology} */
13
+ let topology
14
+
15
+ /** @type {comq.Channel} */
16
+ let channel
17
+
18
+ /** @type {jest.MockedObject<comq.amqp.Channel>} */
19
+ let chan
20
+
21
+ const exchange = generate()
22
+ const queue = generate()
23
+ const key = generate()
24
+
25
+ const getCreatedChannel = () => {
26
+ const method = `create${topology.confirms ? 'Confirm' : ''}Channel`
27
+
28
+ return connection[method].mock.results[0].value
29
+ }
30
+
31
+ beforeEach(async () => {
32
+ jest.clearAllMocks()
33
+
34
+ connection = await amqplib.connect()
35
+ topology = fixtures.preset()
36
+ channel = await create(connection, topology)
37
+ chan = await getCreatedChannel()
38
+ })
39
+
40
+ describe('route', () => {
41
+ const buffer = Buffer.from(generate())
42
+
43
+ it('should assert a direct exchange', async () => {
44
+ await channel.route(exchange, key, buffer)
45
+
46
+ expect(chan.assertExchange).toHaveBeenCalledTimes(1)
47
+
48
+ const [name, type] = chan.assertExchange.mock.calls[0]
49
+
50
+ expect(name).toStrictEqual(exchange)
51
+ expect(type).toStrictEqual('direct')
52
+ })
53
+
54
+ it('should publish with the key as the routing key', async () => {
55
+ await channel.route(exchange, key, buffer)
56
+
57
+ const [name, routingKey, published] = chan.publish.mock.calls[0]
58
+
59
+ expect(name).toStrictEqual(exchange)
60
+ expect(routingKey).toStrictEqual(key)
61
+ expect(published).toStrictEqual(buffer)
62
+ })
63
+
64
+ it('should assert the exchange once for many keys', async () => {
65
+ await channel.route(exchange, key, buffer)
66
+ await channel.route(exchange, generate(), buffer)
67
+
68
+ expect(chan.assertExchange).toHaveBeenCalledTimes(1)
69
+ expect(chan.publish).toHaveBeenCalledTimes(2)
70
+ })
71
+ })
72
+
73
+ describe('bound', () => {
74
+ const consumer = /** @type {comq.channels.Consumer} */ jest.fn(() => undefined)
75
+
76
+ it('should assert a direct exchange', async () => {
77
+ await channel.bound(exchange, queue, key, consumer)
78
+
79
+ const [name, type] = chan.assertExchange.mock.calls[0]
80
+
81
+ expect(name).toStrictEqual(exchange)
82
+ expect(type).toStrictEqual('direct')
83
+ })
84
+
85
+ it('should assert the queue by name', async () => {
86
+ await channel.bound(exchange, queue, key, consumer)
87
+
88
+ expect(chan.assertQueue).toHaveBeenCalledWith(queue, expect.anything())
89
+ })
90
+
91
+ it('should bind the queue under the key', async () => {
92
+ await channel.bound(exchange, queue, key, consumer)
93
+
94
+ const { queue: asserted } = await chan.assertQueue.mock.results[0].value
95
+
96
+ expect(chan.bindQueue).toHaveBeenCalledTimes(1)
97
+ expect(chan.bindQueue).toHaveBeenCalledWith(asserted, exchange, key)
98
+ })
99
+
100
+ it('should consume the queue', async () => {
101
+ await channel.bound(exchange, queue, key, consumer)
102
+
103
+ expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.anything())
104
+ })
105
+
106
+ it('should bind once per key', async () => {
107
+ await channel.bound(exchange, queue, key, consumer)
108
+ await channel.bound(exchange, queue, key, consumer)
109
+
110
+ expect(chan.bindQueue).toHaveBeenCalledTimes(1)
111
+ })
112
+
113
+ it('should bind again for another key', async () => {
114
+ await channel.bound(exchange, queue, key, consumer)
115
+ await channel.bound(exchange, generate(), generate(), consumer)
116
+
117
+ expect(chan.bindQueue).toHaveBeenCalledTimes(2)
118
+ })
119
+ })