comq 0.4.0-dev.6 → 0.4.1-dev.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/docs/notes.md CHANGED
@@ -35,3 +35,13 @@ next Request is consumed from the Request queue*.
35
35
  A lightweight Producer (one that produces responses quickly) can result in being "overloaded"
36
36
  from the Consumer's perspective (that is, does not consume messages with an expected rate), even
37
37
  though the Producer being idle while waiting for response publication confirmations.
38
+
39
+ ## `replyTo` property prefix `direct:///`
40
+
41
+ `IO.request` accepts `replyTo` property modifier function `(replyTo): string` as fourth argument. It
42
+ is the temporary solution to provide an option for compatibility with
43
+ official [RabbitMQ client for .NET](https://github.com/rabbitmq/rabbitmq-dotnet-client/blob/feceedccd6b81d4b461795390432dc3330fdf596/projects/Unit/TestPublicationAddress.cs).
44
+
45
+ It is yet to be clarified and implemented correctly. [#6](https://github.com/toa-io/comq/issues/6)
46
+
47
+ See [Direct Reply-to](https://www.rabbitmq.com/direct-reply-to.html).
@@ -5,12 +5,17 @@ Feature: Events (pub/sub)
5
5
 
6
6
  Scenario: Sending and receiving an event
7
7
  Given that `checker` is consuming events from the `numbers_added` exchange
8
- When I emit an event to the `numbers_added` exchange
8
+ When an event is emitted to the `numbers_added` exchange
9
9
  Then `checker` receives the event
10
10
 
11
11
  Scenario: Receiving an event by two consumer groups
12
12
  Given that `first` is consuming events from the `numbers_added` exchange
13
13
  And `second` is consuming events from the `numbers_added` exchange
14
- When I emit an event to the `numbers_added` exchange
14
+ When an event is emitted to the `numbers_added` exchange
15
15
  Then `first` receives the event
16
16
  And `second` receives the event
17
+
18
+ Scenario: Receiving an event exclusively
19
+ Given that events are exclusively consumed from the `numbers_added` exchange
20
+ When an event is emitted to the `numbers_added` exchange
21
+ Then the event is received
@@ -87,14 +87,14 @@ Feature: Request-Reply Topology Recovery
87
87
  Given the broker is down
88
88
  And `logger` consuming events from the `numbers_added` exchange is expected
89
89
  When the broker is up
90
- And I emit an event to the `numbers_added` exchange
90
+ And an event is emitted to the `numbers_added` exchange
91
91
  Then `logger` receives the event
92
92
 
93
93
  Scenario: Defining emitter while broker crashed
94
94
  Given `someone` consuming events from the `numbers_added` exchange is expected
95
95
  And the broker has crashed
96
96
  When the broker is up
97
- Then I emit an event to the `numbers_added` exchange
97
+ Then an event is emitted to the `numbers_added` exchange
98
98
  And `someone` receives the event
99
99
 
100
100
  Scenario: Start consuming events while broker is down
@@ -105,5 +105,5 @@ Feature: Request-Reply Topology Recovery
105
105
  And `second` consuming events from the `numbers_added` exchange is expected
106
106
  When the broker has crashed
107
107
  And the broker is up
108
- Then I emit an event to the `numbers_added` exchange
108
+ Then an event is emitted to the `numbers_added` exchange
109
109
  And `second` receives the event
@@ -125,23 +125,24 @@ describe('When I attempt to connect to the broker as {string} with password {str
125
125
  })
126
126
  })
127
127
 
128
- describe.each([['', true], [' not', false]])('Then the connection is%s established', (not, defined) => {
129
- const step = tomato.steps.Th(`the connection is${not} established`)
128
+ describe.each([['', true], [' not', false]])('Then the connection is%s established',
129
+ (not, connected) => {
130
+ const step = tomato.steps.Th(`the connection is${not} established`)
130
131
 
131
- it('should be', async () => undefined)
132
+ it('should be', async () => undefined)
132
133
 
133
- it(`should fail if io is${defined ? 'n\'t' : ''} defined`, async () => {
134
- context.io = defined ? undefined : generate()
134
+ it(`should fail if is${connected ? 'n\'t' : ''} connected`, async () => {
135
+ context.connected = !connected
135
136
 
136
- await expect(step.call(context)).rejects.toThrow(AssertionError)
137
- })
137
+ await expect(step.call(context)).rejects.toThrow(AssertionError)
138
+ })
138
139
 
139
- it(`should pass if io is${defined ? '' : 'n\'t'} defined`, async () => {
140
- context.io = defined ? generate() : undefined
140
+ it(`should pass if is${connected ? '' : 'n\'t'} connected`, async () => {
141
+ context.connected = connected
141
142
 
142
- await expect(step.call(context)).resolves.not.toThrow()
143
+ await expect(step.call(context)).resolves.not.toThrow()
144
+ })
143
145
  })
144
- })
145
146
 
146
147
  describe('Then no exceptions are thrown', () => {
147
148
  const step = tomato.steps.Th('no exceptions are thrown')
@@ -3,5 +3,12 @@
3
3
  const { generate } = require('randomstring')
4
4
 
5
5
  const connect = jest.fn(async () => generate())
6
+ const disconnect = jest.fn(async () => undefined)
6
7
 
7
- exports.context = () => ({ connect })
8
+ /**
9
+ * @return {jest.MockedObject<comq.features.Context>}
10
+ */
11
+ exports.context = () => (/** @type {jest.MockedObject<comq.features.Context>} */{
12
+ connect,
13
+ disconnect
14
+ })
@@ -40,21 +40,12 @@ describe('connect', () => {
40
40
  expect(comq.connect).toHaveBeenCalledWith('amqp://developer:secret@localhost:5673')
41
41
  })
42
42
 
43
- it('should not connect twice', async () => {
44
- await context.connect()
45
-
46
- expect(comq.connect).toHaveBeenCalledTimes(1)
47
- })
48
-
49
- it('should reconnect with credentials', async () => {
43
+ it('should connect with credentials', async () => {
50
44
  const user = generate()
51
45
  const password = generate()
52
46
 
53
47
  await context.connect(user, password)
54
48
 
55
- expect(io.close).toHaveBeenCalled()
56
-
57
- expect(comq.connect).toHaveBeenCalledTimes(2)
58
49
  expect(comq.connect).toHaveBeenCalledWith(`amqp://${user}:${password}@localhost:5673`)
59
50
  })
60
51
 
@@ -57,8 +57,14 @@ describe('Given {token} consuming events from the {token} exchange is expected',
57
57
  it('should be', async () => undefined)
58
58
  })
59
59
 
60
- describe('When I emit an event to the {token} exchange', () => {
61
- const step = tomato.steps.Wh('I emit an event to the {token} exchange')
60
+ describe('Given that events are exclusively consumed from the {token} exchange', () => {
61
+ tomato.steps.Gi('that events are exclusively consumed from the {token} exchange')
62
+
63
+ it('should be', async () => undefined)
64
+ })
65
+
66
+ describe('When an event is emitted to the {token} exchange', () => {
67
+ const step = tomato.steps.Wh('an event is emitted to the {token} exchange')
62
68
 
63
69
  it('should be', async () => undefined)
64
70
 
@@ -121,3 +127,28 @@ describe('Then {token} receives the event', () => {
121
127
  await expect(step.call(context, group)).resolves.not.toThrow()
122
128
  })
123
129
  })
130
+
131
+ describe('Then the event is received', () => {
132
+ const step = tomato.steps.Th('the event is received')
133
+
134
+ const payload = generate()
135
+ const group = undefined
136
+
137
+ beforeEach(() => {
138
+ context.consumed = { [group]: payload }
139
+ })
140
+
141
+ it('should be', async () => undefined)
142
+
143
+ it('should throw if not consumed', async () => {
144
+ context.published = generate()
145
+
146
+ await expect(step.call(context, group)).rejects.toThrow(AssertionError)
147
+ })
148
+
149
+ it('should pass if consumed', async () => {
150
+ context.published = payload
151
+
152
+ await expect(step.call(context, group)).resolves.not.toThrow()
153
+ })
154
+ })
@@ -4,16 +4,17 @@ import * as _io from '../../../types/io'
4
4
  declare namespace comq.features {
5
5
 
6
6
  interface Context {
7
- url?: string
8
7
  io?: _io.IO
8
+ connected?: boolean
9
9
  reply?: Promise<any>
10
10
  consumed?: Record<string, any>
11
11
  published?: any
12
12
  events?: { [K in _diagnostics.event]?: boolean }
13
13
  exception?: Error
14
- expected: Promise<any>
14
+ expected?: Promise<any>
15
15
 
16
16
  connect(user?: string, password?: string): Promise<void>
17
+ disconnect(): Promise<void>
17
18
  }
18
19
 
19
20
  }
@@ -51,7 +51,7 @@ Then('the connection is not established',
51
51
  * @this {comq.features.Context}
52
52
  */
53
53
  async function () {
54
- assert.equal(this.io, undefined, 'connection is established contrary to expectations')
54
+ assert.equal(this.connected, false, 'connection is established contrary to expectations')
55
55
  })
56
56
 
57
57
  Then('the connection is established',
@@ -59,9 +59,9 @@ Then('the connection is established',
59
59
  * @this {comq.features.Context}
60
60
  */
61
61
  async function () {
62
- if (this.connecting) await this.connecting
62
+ if (this.connecting !== undefined) await this.connecting
63
63
 
64
- assert.notEqual(this.io, undefined, 'connection is not established')
64
+ assert.equal(this.connected, true, 'connection is not established')
65
65
  })
66
66
 
67
67
  Then('no exceptions are thrown',
@@ -7,11 +7,8 @@ const { connect } = require('../../')
7
7
  * @implements {comq.features.Context}
8
8
  */
9
9
  class Context extends World {
10
- /** @type {comq.IO} */
11
- static io
12
- static url
13
-
14
10
  io
11
+ connected = false
15
12
  connecting
16
13
  reply
17
14
  consumed
@@ -23,26 +20,33 @@ class Context extends World {
23
20
  async connect (user, password) {
24
21
  const url = locator(user, password)
25
22
 
26
- if (url !== Context.url) await this.#connect(url)
23
+ await this.#connect(url)
24
+ }
25
+
26
+ async disconnect () {
27
+ if (this.io === undefined) return
27
28
 
28
- this.io = Context.io
29
+ await this.io.close()
29
30
 
30
- for (const event of EVENTS) this.io.diagnose(event, () => (this.events[event] = true))
31
+ this.io = undefined
32
+ this.connected = false
33
+ this.events = {}
31
34
  }
32
35
 
36
+ /**
37
+ * @param {string} url
38
+ * @return {Promise<void>}
39
+ */
33
40
  async #connect (url) {
34
- if (Context.url !== undefined) await Context.disconnect()
41
+ if (this.io !== undefined) await this.disconnect()
35
42
 
36
- Context.url = url
37
- Context.io = await connect(url)
38
- }
39
-
40
- static async disconnect () {
41
- if (Context.io === undefined) return
43
+ this.io = await connect(url)
44
+ this.connected = true
42
45
 
43
- await Context.io.close()
46
+ for (const event of EVENTS) this.io.diagnose(event, () => (this.events[event] = true))
44
47
 
45
- Context.io = undefined
48
+ this.io.diagnose('close', () => (this.connected = false))
49
+ this.io.diagnose('open', () => (this.connected = true))
46
50
  }
47
51
  }
48
52
 
@@ -12,7 +12,7 @@ Given('(that ){token} is consuming events from the {token} exchange',
12
12
  * @this {comq.features.Context}
13
13
  */
14
14
  async function (group, exchange) {
15
- await consume(this, group, exchange)
15
+ await consume.call(this, group, exchange)
16
16
  })
17
17
 
18
18
  Given('{token} consuming events from the {token} exchange is expected',
@@ -24,10 +24,19 @@ Given('{token} consuming events from the {token} exchange is expected',
24
24
  async function (group, exchange) {
25
25
  await timeout(500) // let it crash
26
26
 
27
- this.expected = consume(this, group, exchange)
27
+ this.expected = consume.call(this, group, exchange)
28
28
  })
29
29
 
30
- When('I emit an event to the {token} exchange',
30
+ Given('that events are exclusively consumed from the {token} exchange',
31
+ /**
32
+ * @param {string} exchange
33
+ * @this {comq.features.Context}
34
+ */
35
+ async function (exchange) {
36
+ this.expected = consume.call(this, undefined, exchange)
37
+ })
38
+
39
+ When('an event is emitted to the {token} exchange',
31
40
  /**
32
41
  * @param {string} exchange
33
42
  * @this {comq.features.Context}
@@ -48,22 +57,38 @@ Then('{token} receives the event',
48
57
  * @this {comq.features.Context}
49
58
  */
50
59
  async function (group) {
51
- await timeout(100)
60
+ await consumed.call(this, group)
61
+ })
52
62
 
53
- assert.notEqual(this.published, undefined, 'No event has been published')
54
- assert.equal(this.published, this.consumed[group], `'${group}' haven't consumed event`)
63
+ Then('the event is received',
64
+ /**
65
+ * @this {comq.features.Context}
66
+ */
67
+ async function () {
68
+ await consumed.call(this)
55
69
  })
56
70
 
57
71
  /**
58
- * @param {comq.features.Context} context
72
+ * @this {comq.features.Context} context
59
73
  * @param {string} group
60
74
  * @param {string} exchange
61
75
  * @return {Promise<void>}
62
76
  */
63
- const consume = async (context, group, exchange) => {
64
- context.consumed ??= {}
77
+ async function consume (group, exchange) {
78
+ this.consumed ??= {}
65
79
 
66
- return context.io.consume(exchange, group, async (payload) => {
67
- context.consumed[group] = payload
80
+ return this.io.consume(exchange, group, async (payload) => {
81
+ this.consumed[group] = payload
68
82
  })
69
83
  }
84
+
85
+ /**
86
+ * @param {string} group
87
+ * @return {Promise<void>}
88
+ */
89
+ async function consumed (group) {
90
+ await timeout(100) // let it consume
91
+
92
+ assert.notEqual(this.published, undefined, 'No event has been published')
93
+ assert.equal(this.published, this.consumed[group], 'Event hasn\'t been exclusively consumed')
94
+ }
@@ -1,12 +1,11 @@
1
1
  'use strict'
2
2
 
3
- const { AfterAll } = require('@cucumber/cucumber')
4
- const { Context } = require('./context')
3
+ const { After } = require('@cucumber/cucumber')
5
4
 
6
- AfterAll(
5
+ After(
7
6
  /**
8
7
  * @this {comq.features.Context}
9
8
  */
10
9
  async function () {
11
- await Context.disconnect()
10
+ await this.disconnect()
12
11
  })
package/lerna.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "useWorkspaces": true,
3
- "version": "0.4.0-dev.6",
3
+ "version": "0.4.1-dev.0",
4
4
  "conventionalCommits": true
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.4.0-dev.6",
3
+ "version": "0.4.1-dev.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -37,7 +37,7 @@
37
37
  "standard": "17.0.0"
38
38
  },
39
39
  "dependencies": {
40
- "@toa.io/generic": "0.3.0",
40
+ "@toa.io/generic": "0.4.0-dev.3",
41
41
  "amqplib": "0.10.3",
42
42
  "msgpackr": "1.8.5"
43
43
  },
package/readme.md CHANGED
@@ -10,7 +10,7 @@ Node.js.
10
10
  3. Events (pub/sub)
11
11
  4. Content encoding
12
12
  5. Flow control and back pressure handling
13
- 6. Consumer acknowledgements and publisher confirms
13
+ 6. Consumer acknowledgments and publisher confirms
14
14
  7. Poison message handling
15
15
  8. Connection tolerance
16
16
  9. Broker restart resilience
@@ -109,19 +109,22 @@ const sum = await io.request('add_numbers', { a: 1, b: 2 })
109
109
 
110
110
  ## Consumption
111
111
 
112
- `async IO.consume(exchange: string, group: string, consumer)`
112
+ `async IO.consume(exchange: string, [group: string], consumer)`
113
113
 
114
114
  `consumer` function's signature is `async? (payload: any): void`
115
115
 
116
116
  Start consuming decoded Events.
117
117
 
118
- Asserts fanout `exchange` (once per unique `exchange`) and queue for the Consumer `group` (once per
119
- unique `exchange` and `group` pair), and then binds the queue to the exchange. That is, one Event
120
- message is delivered to a single Consumer within *each group*.
118
+ Asserts fanout `exchange` (once per unique `exchange`) and the queue for the Consumer `group` (once
119
+ per unique `exchange` and `group` pair), and then binds the queue to the exchange. That is, one
120
+ Event message is delivered to a single Consumer within *each group*.
121
121
 
122
122
  > Typically, the value of `group` refers to the name of a microservice running in multiple
123
123
  > instances.
124
124
 
125
+ If the `group` is `undefined` or omitted, a unique value is generated and the queue is declared as
126
+ exclusive.
127
+
125
128
  ### Example
126
129
 
127
130
  ```javascript
@@ -222,7 +225,7 @@ requests and are expecting replies.
222
225
  *persistent* ([delivery mode 2](https://www.rabbitmq.com/publishers.html#message-properties)),
223
226
  while Requests and Replies are not (mode 1).
224
227
  - Events and Requests are consumed using
225
- manual [acknowledgment mode](https://www.rabbitmq.com/confirms.html#acknowledgement-modes),
228
+ manual [acknowledgment mode](https://www.rabbitmq.com/confirms.html#acknowledgment-modes),
226
229
  and Replies are consumed using automatic mode.
227
230
 
228
231
  If incoming message causes an exception, then it is "negative acknowledged" and requeued. If it
@@ -234,13 +237,13 @@ causes an exception again, it will be discarded.
234
237
 
235
238
  See:
236
239
 
237
- - [Consumer Acknowledgements and Publisher Confirms](https://www.rabbitmq.com/confirms.html).
238
- - [Negative Acknowledgement and Requeuing of Deliveries](https://www.rabbitmq.com/confirms.html#consumer-nacks-requeue)
240
+ - [Consumer Acknowledgments and Publisher Confirms](https://www.rabbitmq.com/confirms.html).
241
+ - [Negative Acknowledgment and Requeuing of Deliveries](https://www.rabbitmq.com/confirms.html#consumer-nacks-requeue)
239
242
  - [Dead Letter Exchanges](https://www.rabbitmq.com/dlx.html)
240
243
 
241
244
  ### Cheatsheet
242
245
 
243
- | Message | Prefetch | Confirms | Queue | Acknowledgement | Persistent |
246
+ | Message | Prefetch | Confirms | Queue | Acknowledgment | Persistent |
244
247
  |---------|-----------|----------|-----------|-----------------|------------|
245
248
  | Request | limited | no | durable | manual | no |
246
249
  | Reply | unlimited | no | exclusive | automatic | no |
package/source/channel.js CHANGED
@@ -126,12 +126,15 @@ class Channel {
126
126
 
127
127
  /**
128
128
  * @param {string} name
129
- * @returns {Promise<void>}
129
+ * @returns {Promise<string[]>}
130
130
  */
131
131
  async #assertQueue (name) {
132
- const options = this.#topology.durable ? DURABLE : EXCLUSIVE
132
+ const passed = arguments.length === 2 ? arguments[1] : undefined
133
+ const options = passed ?? (this.#topology.durable ? DURABLE : EXCLUSIVE)
134
+
135
+ const { queue } = await this.#channel.assertQueue(name, options)
133
136
 
134
- await this.#channel.assertQueue(name, options)
137
+ return [queue]
135
138
  }
136
139
 
137
140
  /**
@@ -149,11 +152,18 @@ class Channel {
149
152
  *
150
153
  * @param {string} exchange
151
154
  * @param {string} queue
152
- * @returns {Promise<void>}
155
+ * @returns {Promise<string[]>}
153
156
  */
154
157
  async #assertBoundQueue (exchange, queue) {
155
- await this.#assertQueue(queue)
158
+ /** @type {comq.amqp.options.Consume} */
159
+ let options
160
+
161
+ if (queue === undefined) options = { exclusive: true }
162
+
163
+ queue = (await this.#assertQueue(queue, options))[0]
156
164
  await this.#channel.bindQueue(queue, exchange, '')
165
+
166
+ return [exchange, queue]
157
167
  }
158
168
 
159
169
  // endregion
@@ -201,7 +211,7 @@ class Channel {
201
211
  /** @type {import('amqplib').Options.Consume} */
202
212
  const options = {}
203
213
 
204
- if (this.#topology.acknowledgements) consumer = this.#getAcknowledgingConsumer(consumer)
214
+ if (this.#topology.acknowledgments) consumer = this.#getAcknowledgingConsumer(consumer)
205
215
  else options.noAck = true
206
216
 
207
217
  const response = await this.#channel.consume(queue, consumer, options)
@@ -220,6 +230,8 @@ class Channel {
220
230
 
221
231
  this.#channel.ack(message)
222
232
  } catch (exception) {
233
+ if (exception.message === 'Channel closed') return // message will be requeued by the broker
234
+
223
235
  if (message.fields.redelivered) this.#discard(message, exception)
224
236
  else this.#requeue(message)
225
237
  }
@@ -37,7 +37,11 @@ class Connection {
37
37
  }
38
38
 
39
39
  async close () {
40
- await this.#connection.close()
40
+ try {
41
+ await this.#connection.close()
42
+ } catch (e) {
43
+ // connection already closed
44
+ }
41
45
  }
42
46
 
43
47
  createChannel = failsafe(this, this.#recover,
package/source/io.js CHANGED
@@ -81,7 +81,8 @@ class IO {
81
81
 
82
82
  consume = lazy(this, this.#createEventChannel,
83
83
  async (exchange, group, callback) => {
84
- const queue = io.concat(exchange, group)
84
+ const exclusive = group === undefined || callback === undefined // 2 arguments passed
85
+ const queue = exclusive ? undefined : io.concat(exchange, group)
85
86
  const consumer = this.#getEventConsumer(callback)
86
87
 
87
88
  await this.#events.subscribe(exchange, queue, consumer)
@@ -2,6 +2,6 @@
2
2
  "prefetch": 300,
3
3
  "confirms": true,
4
4
  "durable": true,
5
- "acknowledgements": true,
5
+ "acknowledgments": true,
6
6
  "persistent": true
7
7
  }
@@ -2,6 +2,6 @@
2
2
  "prefetch": 0,
3
3
  "confirms": false,
4
4
  "durable": false,
5
- "acknowledgements": false,
5
+ "acknowledgments": false,
6
6
  "persistent": false
7
7
  }
@@ -2,6 +2,6 @@
2
2
  "prefetch": 300,
3
3
  "confirms": false,
4
4
  "durable": true,
5
- "acknowledgements": true,
5
+ "acknowledgments": true,
6
6
  "persistent": false
7
7
  }
@@ -11,7 +11,7 @@ class Channel extends EventEmitter {
11
11
  cancel = jest.fn(async () => undefined)
12
12
  ack = jest.fn(() => undefined)
13
13
  nack = jest.fn(() => undefined)
14
- assertQueue = jest.fn(async () => undefined)
14
+ assertQueue = jest.fn(async (name) => ({ queue: name ?? generate() }))
15
15
  assertExchange = jest.fn(async () => undefined)
16
16
  bindQueue = jest.fn(async () => undefined)
17
17
  publish = jest.fn((_0, _1, _2, _3, resolve) => resolve?.(null))
@@ -6,7 +6,7 @@ const preset = () => ({
6
6
  prefetch: random(10),
7
7
  confirms: flip(),
8
8
  durable: flip(),
9
- acknowledgements: flip(),
9
+ acknowledgments: flip(),
10
10
  persistent: flip()
11
11
  })
12
12
 
@@ -92,7 +92,7 @@ describe('consume', () => {
92
92
  })
93
93
  })
94
94
 
95
- describe('acknowledgements', () => {
95
+ describe('acknowledgments', () => {
96
96
  const consumer = /** @type {comq.channel.consumer} */ jest.fn(async () => undefined)
97
97
  const queue = generate()
98
98
 
@@ -100,7 +100,7 @@ describe('acknowledgements', () => {
100
100
  ['', true],
101
101
  ['not ', false]
102
102
  ])('should %sack incoming messages', async (_, ack) => {
103
- topology.acknowledgements = ack
103
+ topology.acknowledgments = ack
104
104
  channel = await create(connection, topology)
105
105
  chan = await getCreatedChannel()
106
106
 
@@ -119,8 +119,8 @@ describe('acknowledgements', () => {
119
119
  it.each([
120
120
  ['manual', true],
121
121
  ['automatic', false]
122
- ])('should create consumer with %s acknowledgements', async (_, ack) => {
123
- topology.acknowledgements = ack
122
+ ])('should create consumer with %s acknowledgments', async (_, ack) => {
123
+ topology.acknowledgments = ack
124
124
  channel = await create(connection, topology)
125
125
  chan = await getCreatedChannel()
126
126
 
@@ -136,7 +136,7 @@ describe('acknowledgements', () => {
136
136
  ['nack', true],
137
137
  ['discard', false]
138
138
  ])('should %s the message caused an exception', async (_, requeue) => {
139
- topology.acknowledgements = true
139
+ topology.acknowledgments = true
140
140
 
141
141
  channel = await create(connection, topology)
142
142
  chan = await getCreatedChannel()
@@ -157,6 +157,28 @@ describe('acknowledgements', () => {
157
157
 
158
158
  expect(chan.nack).toHaveBeenCalledWith(message, false, requeue)
159
159
  })
160
+
161
+ it('should ignore Channel ended exception', async () => {
162
+ topology.acknowledgments = true
163
+
164
+ channel = await create(connection, topology)
165
+ chan = await getCreatedChannel()
166
+
167
+ const consumer = /** @type {Function} */ jest.fn(async () => generate())
168
+
169
+ await channel.consume(queue, consumer)
170
+
171
+ const callback = chan.consume.mock.calls[0][1]
172
+ const content = randomBytes(8)
173
+ const properties = {}
174
+ const fields = {}
175
+ const message = /** @type {import('amqplib').ConsumeMessage} */ { content, properties, fields }
176
+
177
+ chan.ack.mockImplementation(() => { throw new Error('Channel closed') })
178
+ chan.nack.mockImplementation(() => { throw new Error('Channel closed') })
179
+
180
+ await expect(callback(message)).resolves.not.toThrow()
181
+ })
160
182
  })
161
183
 
162
184
  describe('send', () => {
@@ -293,9 +315,10 @@ describe('throw', () => {
293
315
  })
294
316
  })
295
317
 
296
- describe('subscribe', () => {
318
+ describe.each(['group', 'exclusive'])('%s subscribe', (option) => {
319
+ let queue = option === 'group' ? generate() : undefined
320
+
297
321
  const exchange = generate()
298
- const queue = generate()
299
322
  const consumer = /** @type {comq.channel.consumer} */ jest.fn(() => undefined)
300
323
 
301
324
  beforeEach(async () => {
@@ -319,22 +342,42 @@ describe('subscribe', () => {
319
342
  else expect(options).toMatchObject({ durable: false })
320
343
  })
321
344
 
322
- it('should assert queue', async () => {
323
- const options = topology.durable ? { durable: true } : { exclusive: true }
345
+ if (option === 'group') {
346
+ it('should assert queue', async () => {
347
+ const options = topology.durable ? { durable: true } : { exclusive: true }
324
348
 
325
- expect(chan.assertQueue).toHaveBeenCalledWith(queue, expect.objectContaining(options))
326
- })
349
+ expect(chan.assertQueue).toHaveBeenCalledWith(queue, expect.objectContaining(options))
350
+ })
351
+ } else {
352
+ it.each([true, false])('should assert exclusive queue (topology.durable: %s)', async (durable) => {
353
+ jest.clearAllMocks()
354
+
355
+ topology.durable = durable
356
+
357
+ channel = await create(connection, topology)
358
+ chan = await getCreatedChannel()
359
+
360
+ await channel.subscribe(exchange, undefined, consumer)
361
+
362
+ expect(chan.assertQueue).toHaveBeenCalledWith(
363
+ undefined,
364
+ expect.objectContaining({ exclusive: true })
365
+ )
366
+ })
367
+ }
327
368
 
328
369
  it('should bind queue to exchange', async () => {
370
+ const { queue } = await chan.assertQueue.mock.results[0].value
371
+
329
372
  expect(chan.bindQueue).toHaveBeenCalledTimes(1)
330
373
  expect(chan.bindQueue).toHaveBeenCalledWith(queue, exchange, '')
331
374
  })
332
375
 
333
376
  it.each([
334
- ['with acknowledgements', true],
335
- ['without acknowledgements', false]
377
+ ['with acknowledgments', true],
378
+ ['without acknowledgments', false]
336
379
  ])('should start consuming %s', async (_, ack) => {
337
- topology.acknowledgements = ack
380
+ topology.acknowledgments = ack
338
381
 
339
382
  jest.clearAllMocks()
340
383
 
@@ -345,6 +388,8 @@ describe('subscribe', () => {
345
388
 
346
389
  const options = ack ? {} : { noAck: true }
347
390
 
391
+ queue = (await chan.assertQueue.mock.results[0].value).queue
392
+
348
393
  expect(chan.consume).toHaveBeenCalledTimes(1)
349
394
  expect(chan.consume).toHaveBeenCalledWith(queue, expect.any(Function), expect.objectContaining(options))
350
395
 
@@ -741,7 +786,7 @@ describe('diagnostics', () => {
741
786
  ])('should%s emit `discard` event', async (_, redelivered) => {
742
787
  jest.clearAllMocks()
743
788
 
744
- topology.acknowledgements = true
789
+ topology.acknowledgments = true
745
790
  channel = await create(connection, topology)
746
791
  chan = await getCreatedChannel()
747
792
 
@@ -154,13 +154,26 @@ describe('create channel', () => {
154
154
  })
155
155
  })
156
156
 
157
- it('should close connection', async () => {
158
- await connection.open()
159
- await connection.close()
157
+ describe('close', () => {
158
+ it('should close connection', async () => {
159
+ await connection.open()
160
+ await connection.close()
161
+
162
+ const amqp = await amqplib.connect.mock.results[0].value
163
+
164
+ expect(amqp.close).toHaveBeenCalled()
165
+ })
160
166
 
161
- const amqp = await amqplib.connect.mock.results[0].value
167
+ it('should ignore exceptions', async () => {
168
+ await connection.open()
169
+
170
+ /** @type {jest.MockedObject<import('amqplib').Connection>} */
171
+ const amqp = await amqplib.connect.mock.results[0].value
162
172
 
163
- expect(amqp.close).toHaveBeenCalled()
173
+ amqp.close.mockImplementation(async () => { throw new Error() })
174
+
175
+ await expect(connection.close()).resolves.not.toThrow()
176
+ })
164
177
  })
165
178
 
166
179
  describe('diagnostics', () => {
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ // region setup
4
+
3
5
  const { generate } = require('randomstring')
4
6
  const { encode } = require('../source/encode')
5
7
 
@@ -21,6 +23,8 @@ beforeEach(async () => {
21
23
  io = new IO(connection)
22
24
  })
23
25
 
26
+ // endregion
27
+
24
28
  it('should be', async () => {
25
29
  expect(io.consume).toBeDefined()
26
30
  })
@@ -32,36 +36,54 @@ const consumer = jest.fn(() => undefined)
32
36
  /** @type {jest.MockedObject<comq.Channel>} */
33
37
  let events
34
38
 
35
- beforeEach(async () => {
39
+ beforeEach(() => {
36
40
  jest.clearAllMocks()
41
+ })
37
42
 
38
- await io.consume(exchange, group, consumer)
43
+ describe('group consumption', () => {
44
+ beforeEach(async () => {
45
+ await io.consume(exchange, group, consumer)
39
46
 
40
- events = await findChannel('event')
41
- })
47
+ events = await findChannel('event')
48
+ })
42
49
 
43
- it('should create events channel', async () => {
44
- expect(connection.createChannel).toHaveBeenCalledWith('event')
45
- })
50
+ it('should create events channel', async () => {
51
+ expect(connection.createChannel).toHaveBeenCalledWith('event')
52
+ })
53
+
54
+ it('should subscribe', async () => {
55
+ expect(events).toBeDefined()
56
+
57
+ const queue = exchange + '..' + group
58
+
59
+ expect(events.subscribe).toHaveBeenCalledWith(exchange, queue, expect.any(Function))
60
+ })
46
61
 
47
- it('should subscribe', async () => {
48
- expect(events).toBeDefined()
62
+ it.each(encodings)('should pass decoded event (%s)', async (contentType) => {
63
+ const payload = generate()
64
+ const content = encode(payload, contentType)
65
+ const properties = { contentType }
66
+ const message = /** @type {import('amqplib').ConsumeMessage} */ { content, properties }
67
+ const callback = events.subscribe.mock.calls[0][2]
49
68
 
50
- const queue = exchange + '..' + group
69
+ await callback(message)
51
70
 
52
- expect(events.subscribe).toHaveBeenCalledWith(exchange, queue, expect.any(Function))
71
+ expect(consumer).toHaveBeenCalledWith(payload)
72
+ })
53
73
  })
54
74
 
55
- it.each(encodings)('should pass decoded event (%s)', async (contentType) => {
56
- const payload = generate()
57
- const content = encode(payload, contentType)
58
- const properties = { contentType }
59
- const message = /** @type {import('amqplib').ConsumeMessage} */ { content, properties }
60
- const callback = events.subscribe.mock.calls[0][2]
75
+ describe.each(['omitted', 'undefined'])('exclusive consumption (group is %s)', (option) => {
76
+ beforeEach(async () => {
77
+ if (option === 'omitted') await io.consume(exchange, consumer)
78
+ else await io.consume(exchange, undefined, consumer)
61
79
 
62
- await callback(message)
80
+ events = await findChannel('event')
81
+ })
63
82
 
64
- expect(consumer).toHaveBeenCalledWith(payload)
83
+ it('should subscribe', async () => {
84
+ expect(events).toBeDefined()
85
+ expect(events.subscribe).toHaveBeenCalledWith(exchange, undefined, expect.any(Function))
86
+ })
65
87
  })
66
88
 
67
89
  /**
@@ -7,7 +7,7 @@ it('should define request preset', async () => {
7
7
  prefetch: 300,
8
8
  confirms: false,
9
9
  durable: true,
10
- acknowledgements: true,
10
+ acknowledgments: true,
11
11
  persistent: false
12
12
  })
13
13
  })
@@ -17,7 +17,7 @@ it('should define reply preset', async () => {
17
17
  prefetch: 0,
18
18
  confirms: false,
19
19
  durable: false,
20
- acknowledgements: false,
20
+ acknowledgments: false,
21
21
  persistent: false
22
22
  })
23
23
  })
@@ -27,7 +27,7 @@ it('should define event preset', async () => {
27
27
  prefetch: 300,
28
28
  confirms: true,
29
29
  durable: true,
30
- acknowledgements: true,
30
+ acknowledgments: true,
31
31
  persistent: true
32
32
  })
33
33
  })
package/types/io.d.ts CHANGED
@@ -30,6 +30,8 @@ declare namespace comq {
30
30
 
31
31
  consume(exchange: string, group: string, consumer: consumer): Promise<void>
32
32
 
33
+ consume(exchange: string, consumer: consumer): Promise<void>
34
+
33
35
  emit(exchange: string, payload: any, encoding?: _encoding.encoding): Promise<void>
34
36
 
35
37
  seal(): Promise<void>
@@ -10,7 +10,7 @@ declare namespace comq {
10
10
  prefetch: number
11
11
  confirms: boolean
12
12
  durable: boolean
13
- acknowledgements: boolean
13
+ acknowledgments: boolean
14
14
  persistent: boolean
15
15
  }
16
16