@flowfuse/nr-mqtt-nodes 0.1.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.
@@ -0,0 +1,920 @@
1
+ /*
2
+ This is a fork of the MQTT nodes from Node-RED.
3
+ The original code can be found at:
4
+ https://github.com/node-red/node-red/blob/f43d4e946523a797e7489deb46fed9ca78b70c6d/test/nodes/core/network/21-mqtt_spec.js
5
+ ### Changes:
6
+ - lint errors fixed up
7
+ */
8
+
9
+ 'use strict'
10
+ const should = require('should')
11
+ const sinon = require('sinon')
12
+ const helper = require('node-red-node-test-helper')
13
+ const TeamBrokerApi = require('../../nodes/lib/TeamBrokerApi.js')
14
+ const mqttNodes = require('../../nodes/ff-mqtt.js')
15
+ const BROKER_HOST = process.env.MQTT_BROKER_SERVER || 'mqtt://localhost'
16
+ const BROKER_PORT = process.env.MQTT_BROKER_PORT || 1883
17
+ // By default, MQTT tests are disabled. Set ENV VAR NR_MQTT_TESTS to "1" or "true" to enable
18
+ const skipTests = process.env.NR_MQTT_TESTS !== 'true' && process.env.NR_MQTT_TESTS !== '1'
19
+ // const testUtils = require('nr-test-utils')
20
+ const RED = require('node-red/lib/red')
21
+
22
+ describe('FF MQTT Nodes', function () {
23
+ before(function (done) {
24
+ helper.startServer(done)
25
+ })
26
+
27
+ beforeEach(function () {
28
+ setupDefaultForgeSettings({ forgeUrl: 'http://localhost:3000' })
29
+ mockTeamBrokerApi(RED, null, { forgeURL: 'http://localhost:3000' })
30
+ })
31
+
32
+ after(function (done) {
33
+ helper.stopServer(done)
34
+ })
35
+
36
+ afterEach(function () {
37
+ sinon.restore()
38
+ TeamBrokerApi.TeamBrokerApi = TeamBrokerApi._teamBrokerApi
39
+ delete TeamBrokerApi._teamBrokerApi
40
+ const mqttIn = helper.getNode('mqtt.in')
41
+ const mqttOut = helper.getNode('mqtt.out')
42
+ const brokerConn = mqttIn?.brokerConn || mqttOut?.brokerConn
43
+ if (brokerConn) {
44
+ clearInterval(brokerConn.linkMonitorInterval)
45
+ brokerConn.disconnect()
46
+ }
47
+ try {
48
+ helper.unload()
49
+ } catch (error) { }
50
+ })
51
+
52
+ function mockTeamBrokerApi (RED, gotClient, { forgeURL, teamId, instanceType = 'application', instanceId = 'application-id', token, API_VERSION = 'v1' } = {}) {
53
+ TeamBrokerApi._teamBrokerApi = TeamBrokerApi.TeamBrokerApi
54
+ TeamBrokerApi.TeamBrokerApi = sinon.stub().callsFake(function (RED, gotClient, settings) {
55
+ const api = {
56
+ link: sinon.stub().callsFake(async function (password) {
57
+ return { }
58
+ })
59
+ }
60
+ return api
61
+ })
62
+ }
63
+
64
+ function setupDefaultForgeSettings ({ forgeURL, teamID, applicationID, projectID, deviceID, useSharedSubscriptions, broker } = {}) {
65
+ RED.settings.flowforge = {
66
+ forgeURL: forgeURL || 'http://localhost:3000',
67
+ teamID: teamID || 'team-id',
68
+ applicationID: applicationID || 'application-id',
69
+ projectID: projectID || 'project-id',
70
+ deviceID: deviceID || null
71
+ }
72
+ RED.settings.flowforge.projectLink = {
73
+ useSharedSubscriptions: useSharedSubscriptions || false,
74
+ broker: broker || {
75
+ url: BROKER_HOST,
76
+ username: null,
77
+ password: null
78
+ }
79
+ }
80
+ }
81
+
82
+ it('should not be loaded without FF settings', async function () {
83
+ this.timeout = 2000
84
+ const { flow } = buildBasicMQTTSendRecvFlow({ id: 'mqtt.in', topic: 'in_topic' }, { id: 'mqtt.out', topic: 'out_topic' })
85
+
86
+ RED.settings.flowforge = null // no settings
87
+ should(helper.load(mqttNodes, flow)).be.rejectedWith('FlowFuse MQTT nodes cannot be loaded outside of an FlowFuse EE environment')
88
+
89
+ setupDefaultForgeSettings()
90
+ RED.settings.flowforge.forgeURL = null // no url
91
+ should(helper.load(mqttNodes, flow)).be.rejectedWith('FlowFuse MQTT nodes cannot be loaded outside of an FlowFuse EE environment')
92
+
93
+ setupDefaultForgeSettings()
94
+ RED.settings.flowforge.teamID = null // no teamID
95
+ should(helper.load(mqttNodes, flow)).be.rejectedWith('FlowFuse MQTT nodes cannot be loaded outside of an FlowFuse EE environment')
96
+
97
+ setupDefaultForgeSettings()
98
+ RED.settings.flowforge.broker = null
99
+ should(helper.load(mqttNodes, flow)).be.rejectedWith('FlowFuse MQTT nodes cannot be loaded without a broker configured in FlowFuse EE settings')
100
+
101
+ setupDefaultForgeSettings()
102
+ RED.settings.flowforge.projectID = null // no projectID
103
+ should(helper.load(mqttNodes, flow)).be.rejectedWith('FlowFuse MQTT nodes cannot be loaded due to missing instance information')
104
+ })
105
+
106
+ it('should be loaded and have default values', async function () {
107
+ this.timeout = 2000
108
+ const { flow } = buildBasicMQTTSendRecvFlow({ id: 'mqtt.in', topic: 'in_topic' }, { id: 'mqtt.out', topic: 'out_topic' })
109
+ await helper.load(mqttNodes, flow)
110
+
111
+ const mqttIn = helper.getNode('mqtt.in')
112
+ const mqttOut = helper.getNode('mqtt.out')
113
+
114
+ should(mqttIn).be.type('object', 'mqtt in node should be an object')
115
+ mqttIn.should.have.property('datatype', 'utf8') // default: 'utf8'
116
+ mqttIn.should.have.property('isDynamic', false) // default: false
117
+ mqttIn.should.have.property('inputs', 0) // default: 0
118
+ mqttIn.should.have.property('qos', 2) // default: 2
119
+ mqttIn.should.have.property('topic', 'in_topic')
120
+ mqttIn.should.have.property('wires', [['helper.node']])
121
+ mqttIn.should.have.property('brokerConn').and.be.type('object')
122
+
123
+ should(mqttOut).be.type('object', 'mqtt out node should be an object')
124
+ mqttOut.should.have.property('topic', 'out_topic')
125
+ mqttOut.should.have.property('brokerConn').and.be.type('object')
126
+
127
+ mqttOut.brokerConn.should.equal(mqttIn.brokerConn) // should be the same broker connection
128
+ const mqttBroker = mqttOut.brokerConn
129
+ clearInterval(mqttBroker.linkMonitorInterval) // clear the link monitor interval so the test can exit
130
+ })
131
+
132
+ if (skipTests) {
133
+ it('skipping MQTT tests. Set env var "NR_MQTT_TESTS=true" to enable. Requires a v5 capable broker running on localhost:1883.', function (done) {
134
+ done()
135
+ })
136
+ }
137
+ // Conditional test runner (only run if skipTests=false)
138
+ function itConditional (title, test) {
139
+ return !skipTests ? it(title, test) : it.skip(title, test)
140
+ }
141
+ itConditional.skip = it.skip
142
+ // eslint-disable-next-line no-only-tests/no-only-tests
143
+ itConditional.only = it.only
144
+
145
+ // #region ################### BASIC TESTS ################### #//
146
+
147
+ itConditional('basic send and receive tests', function (done) {
148
+ if (skipTests) { return this.skip() }
149
+ this.timeout = 2000
150
+ const options = {}
151
+ options.sendMsg = {
152
+ topic: nextTopic(),
153
+ payload: 'hello',
154
+ qos: 0
155
+ }
156
+ options.expectMsg = Object.assign({}, options.sendMsg)
157
+ testSendRecv({}, { datatype: 'auto', topicType: 'static' }, {}, options, { done })
158
+ })
159
+ // Prior to V3, "auto" mode would only parse to string or buffer.
160
+ itConditional('should send JSON and receive string (auto mode)', function (done) {
161
+ if (skipTests) { return this.skip() }
162
+ this.timeout = 2000
163
+ const options = {}
164
+ options.sendMsg = {
165
+ topic: nextTopic(),
166
+ payload: '{"prop":"value1", "num":1}',
167
+ qos: 1
168
+ }
169
+ options.expectMsg = Object.assign({}, options.sendMsg)
170
+ testSendRecv({}, { datatype: 'auto', topicType: 'static' }, {}, options, { done })
171
+ })
172
+ // In V3, "auto" mode should try to parse JSON, then string and fall back to buffer
173
+ itConditional('should send JSON and receive object (auto-detect mode)', function (done) {
174
+ if (skipTests) { return this.skip() }
175
+ this.timeout = 2000
176
+ const options = {}
177
+ options.sendMsg = {
178
+ topic: nextTopic(),
179
+ payload: '{"prop":"value1", "num":1}',
180
+ qos: 1
181
+ }
182
+ options.expectMsg = Object.assign({}, options.sendMsg)
183
+ options.expectMsg.payload = JSON.parse(options.sendMsg.payload)
184
+ testSendRecv({}, { datatype: 'auto-detect', topicType: 'static' }, {}, options, { done })
185
+ })
186
+ itConditional('should send invalid JSON and receive string (auto mode)', function (done) {
187
+ if (skipTests) { return this.skip() }
188
+ this.timeout = 2000
189
+ const options = {}
190
+ options.sendMsg = {
191
+ topic: nextTopic(),
192
+ payload: '{prop:"value3", "num":3}'// send invalid JSON ...
193
+ }
194
+ options.expectMsg = Object.assign({}, options.sendMsg)// expect same payload
195
+ testSendRecv({}, { datatype: 'auto', topicType: 'static' }, {}, options, { done })
196
+ })
197
+ itConditional('should send invalid JSON and receive string (auto-detect mode)', function (done) {
198
+ if (skipTests) { return this.skip() }
199
+ this.timeout = 2000
200
+ const options = {}
201
+ options.sendMsg = {
202
+ topic: nextTopic(),
203
+ payload: '{prop:"value3", "num":3}'// send invalid JSON ...
204
+ }
205
+ options.expectMsg = Object.assign({}, options.sendMsg)// expect same payload
206
+ testSendRecv({}, { datatype: 'auto-detect', topicType: 'static' }, {}, options, { done })
207
+ })
208
+
209
+ itConditional('should send JSON and receive string (utf8 mode)', function (done) {
210
+ if (skipTests) { return this.skip() }
211
+ this.timeout = 2000
212
+ const options = {}
213
+ options.sendMsg = {
214
+ topic: nextTopic(),
215
+ payload: '{"prop":"value2", "num":2}',
216
+ qos: 2
217
+ }
218
+ options.expectMsg = Object.assign({}, options.sendMsg)
219
+ testSendRecv({}, { datatype: 'utf8', topicType: 'static' }, {}, options, { done })
220
+ })
221
+ itConditional('should send JSON and receive Object (json mode)', function (done) {
222
+ if (skipTests) { return this.skip() }
223
+ this.timeout = 2000
224
+ const options = {}
225
+ options.sendMsg = {
226
+ topic: nextTopic(),
227
+ payload: '{"prop":"value3", "num":3}'// send a string ...
228
+ }
229
+ options.expectMsg = Object.assign({}, options.sendMsg, { payload: { prop: 'value3', num: 3 } })// expect an object
230
+ testSendRecv({}, { datatype: 'json', topicType: 'static' }, {}, options, { done })
231
+ })
232
+ itConditional('should send invalid JSON and raise error (json mode)', function (done) {
233
+ if (skipTests) { return this.skip() }
234
+ this.timeout = 2000
235
+ const options = {}
236
+ options.sendMsg = {
237
+ topic: nextTopic(),
238
+ payload: '{prop:"value3", "num":3}' // send invalid JSON ...
239
+ }
240
+ const hooks = { done: null, beforeLoad: null, afterLoad: null, afterConnect: null }
241
+ hooks.afterLoad = (helperNode, mqttBroker, mqttIn, mqttOut) => {
242
+ helperNode.on('input', function (msg) {
243
+ try {
244
+ msg.should.have.a.property('error').type('object')
245
+ msg.error.should.have.a.property('source').type('object')
246
+ msg.error.source.should.have.a.property('id', mqttIn.id)
247
+ done()
248
+ } catch (err) {
249
+ done(err)
250
+ }
251
+ })
252
+ return true // handled
253
+ }
254
+ testSendRecv({}, { datatype: 'json', topicType: 'static' }, {}, options, hooks)
255
+ })
256
+ itConditional('should send String and receive Buffer (buffer mode)', function (done) {
257
+ if (skipTests) { return this.skip() }
258
+ this.timeout = 2000
259
+ const options = {}
260
+ options.sendMsg = {
261
+ topic: nextTopic(),
262
+ payload: 'a b c' // send string ...
263
+ }
264
+ options.expectMsg = Object.assign({}, options.sendMsg, { payload: Buffer.from(options.sendMsg.payload) })// expect Buffer.from(msg.payload)
265
+ testSendRecv({}, { datatype: 'buffer', topicType: 'static' }, {}, options, { done })
266
+ })
267
+ itConditional('should send utf8 Buffer and receive String (auto mode)', function (done) {
268
+ if (skipTests) { return this.skip() }
269
+ this.timeout = 2000
270
+ const options = {}
271
+ options.sendMsg = {
272
+ topic: nextTopic(),
273
+ payload: Buffer.from([0x78, 0x20, 0x79, 0x20, 0x7a]) // "x y z"
274
+ }
275
+ options.expectMsg = Object.assign({}, options.sendMsg, { payload: 'x y z' })// set expected payload to "x y z"
276
+ testSendRecv({}, { datatype: 'auto', topicType: 'static' }, {}, options, { done })
277
+ })
278
+ itConditional('should send non utf8 Buffer and receive Buffer (auto mode)', function (done) {
279
+ if (skipTests) { return this.skip() }
280
+ this.timeout = 2000
281
+ const options = {}
282
+ const hooks = { done, beforeLoad: null, afterLoad: null, afterConnect: null }
283
+ options.sendMsg = {
284
+ topic: nextTopic(),
285
+ payload: Buffer.from([0xC0, 0xC1, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF]) // non valid UTF8
286
+ }
287
+ options.expectMsg = Object.assign({}, options.sendMsg, { payload: Buffer.from([0xC0, 0xC1, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF]) })
288
+ testSendRecv({}, { datatype: 'auto', topicType: 'static' }, {}, options, hooks)
289
+ })
290
+ itConditional('should send/receive all v5 flags and settings', function (done) {
291
+ if (skipTests) { return this.skip() }
292
+ this.timeout = 2000
293
+ const t = nextTopic()
294
+ const options = {}
295
+ const hooks = { done, beforeLoad: null, afterLoad: null, afterConnect: null }
296
+ options.sendMsg = {
297
+ topic: t + '/command',
298
+ payload: Buffer.from('{"version":"v5"}'),
299
+ qos: 1,
300
+ retain: true,
301
+ responseTopic: t + '/response',
302
+ userProperties: { prop1: 'val1' },
303
+ contentType: 'text/plain',
304
+ correlationData: Buffer.from([1, 2, 3]),
305
+ payloadFormatIndicator: true,
306
+ messageExpiryInterval: 2000
307
+ }
308
+ options.expectMsg = Object.assign({}, options.sendMsg)
309
+ options.expectMsg.payload = options.expectMsg.payload.toString() // auto mode + payloadFormatIndicator + contentType: "text/plain" should make a string
310
+ delete options.expectMsg.payloadFormatIndicator // Seems mqtt.js only publishes payloadFormatIndicator the will msg
311
+ const inOptions = {
312
+ datatype: 'auto',
313
+ topicType: 'static',
314
+ qos: 1,
315
+ nl: false,
316
+ rap: true,
317
+ rh: 1
318
+ }
319
+ testSendRecv({ protocolVersion: 5 }, inOptions, {}, options, hooks)
320
+ })
321
+ itConditional('should send regular string with v5 media type "text/plain" and receive a string (auto mode)', function (done) {
322
+ if (skipTests) { return this.skip() }
323
+ this.timeout = 2000
324
+ const options = {}
325
+ const hooks = { done, beforeLoad: null, afterLoad: null, afterConnect: null }
326
+ options.sendMsg = {
327
+ topic: nextTopic(), payload: 'abc', contentType: 'text/plain'
328
+ }
329
+ options.expectMsg = Object.assign({}, options.sendMsg)
330
+ testSendRecv({ protocolVersion: 5 }, { datatype: 'auto', topicType: 'static' }, {}, options, hooks)
331
+ })
332
+ itConditional('should send JSON with v5 media type "text/plain" and receive a string (auto mode)', function (done) {
333
+ if (skipTests) { return this.skip() }
334
+ this.timeout = 2000
335
+ const options = {}
336
+ const hooks = { done, beforeLoad: null, afterLoad: null, afterConnect: null }
337
+ options.sendMsg = {
338
+ topic: nextTopic(), payload: '{"prop":"val"}', contentType: 'text/plain'
339
+ }
340
+ options.expectMsg = Object.assign({}, options.sendMsg)
341
+ testSendRecv({ protocolVersion: 5 }, { datatype: 'auto', topicType: 'static' }, {}, options, hooks)
342
+ })
343
+ itConditional('should send JSON with v5 media type "text/plain" and receive a string (auto-detect mode)', function (done) {
344
+ if (skipTests) { return this.skip() }
345
+ this.timeout = 2000
346
+ const options = {}
347
+ const hooks = { done, beforeLoad: null, afterLoad: null, afterConnect: null }
348
+ options.sendMsg = {
349
+ topic: nextTopic(), payload: '{"prop":"val"}', contentType: 'text/plain'
350
+ }
351
+ options.expectMsg = Object.assign({}, options.sendMsg)
352
+ testSendRecv({ protocolVersion: 5 }, { datatype: 'auto-detect', topicType: 'static' }, {}, options, hooks)
353
+ })
354
+ itConditional('should send JSON with v5 media type "application/json" and receive an object (auto-detect mode)', function (done) {
355
+ if (skipTests) { return this.skip() }
356
+ this.timeout = 2000
357
+ const options = {}
358
+ const hooks = { done, beforeLoad: null, afterLoad: null, afterConnect: null }
359
+ options.sendMsg = {
360
+ topic: nextTopic(), payload: '{"prop":"val"}', contentType: 'application/json'
361
+ }
362
+ options.expectMsg = Object.assign({}, options.sendMsg, { payload: JSON.parse(options.sendMsg.payload) })
363
+ testSendRecv({ protocolVersion: 5 }, { datatype: 'auto-detect', topicType: 'static' }, {}, options, hooks)
364
+ })
365
+ itConditional('should send invalid JSON with v5 media type "application/json" and raise an error (auto mode)', function (done) {
366
+ if (skipTests) { return this.skip() }
367
+ this.timeout = 2000
368
+ const options = {}
369
+ options.sendMsg = {
370
+ topic: nextTopic(),
371
+ payload: '{prop:"value3", "num":3}',
372
+ contentType: 'application/json' // send invalid JSON ...
373
+ }
374
+ const hooks = { done: null, beforeLoad: null, afterLoad: null, afterConnect: null }
375
+ hooks.afterLoad = (helperNode, mqttBroker, mqttIn, mqttOut) => {
376
+ helperNode.on('input', function (msg) {
377
+ try {
378
+ msg.should.have.a.property('error').type('object')
379
+ msg.error.should.have.a.property('source').type('object')
380
+ msg.error.source.should.have.a.property('id', mqttIn.id)
381
+ done()
382
+ } catch (err) {
383
+ done(err)
384
+ }
385
+ })
386
+ return true // handled
387
+ }
388
+ testSendRecv({ protocolVersion: 5 }, { datatype: 'auto', topicType: 'static' }, {}, options, hooks)
389
+ })
390
+
391
+ itConditional('should send buffer with v5 media type "application/json" and receive an object (auto-detect mode)', function (done) {
392
+ if (skipTests) { return this.skip() }
393
+ this.timeout = 2000
394
+ const options = {}
395
+ const hooks = { done, beforeLoad: null, afterLoad: null, afterConnect: null }
396
+ options.sendMsg = {
397
+ topic: nextTopic(), payload: Buffer.from([0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x22, 0x3a, 0x22, 0x76, 0x61, 0x6c, 0x22, 0x7d]), contentType: 'application/json'
398
+ }
399
+ options.expectMsg = Object.assign({}, options.sendMsg, { payload: { prop: 'val' } })
400
+ testSendRecv({ protocolVersion: 5 }, { datatype: 'auto-detect', topicType: 'static' }, {}, options, hooks)
401
+ })
402
+ itConditional('should send buffer with v5 media type "text/plain" and receive a string (auto mode)', function (done) {
403
+ if (skipTests) { return this.skip() }
404
+ this.timeout = 2000
405
+ const options = {}
406
+ const hooks = { done, beforeLoad: null, afterLoad: null, afterConnect: null }
407
+ options.sendMsg = {
408
+ topic: nextTopic(), payload: Buffer.from([0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x22, 0x3a, 0x22, 0x76, 0x61, 0x6c, 0x22, 0x7d]), contentType: 'text/plain'
409
+ }
410
+ options.expectMsg = Object.assign({}, options.sendMsg, { payload: '{"prop":"val"}' })
411
+ testSendRecv({ protocolVersion: 5 }, { datatype: 'auto', topicType: 'static' }, {}, options, hooks)
412
+ })
413
+ itConditional('should send buffer with v5 media type "application/zip" and receive a buffer (auto mode)', function (done) {
414
+ if (skipTests) { return this.skip() }
415
+ this.timeout = 2000
416
+ const options = {}
417
+ const hooks = { done, beforeLoad: null, afterLoad: null, afterConnect: null }
418
+ options.sendMsg = {
419
+ topic: nextTopic(), payload: Buffer.from([0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x22, 0x3a, 0x22, 0x76, 0x61, 0x6c, 0x22, 0x7d]), contentType: 'application/zip'
420
+ }
421
+ options.expectMsg = Object.assign({}, options.sendMsg, { payload: Buffer.from([0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x22, 0x3a, 0x22, 0x76, 0x61, 0x6c, 0x22, 0x7d]) })
422
+ testSendRecv({ protocolVersion: 5 }, { datatype: 'auto', topicType: 'static' }, {}, options, hooks)
423
+ })
424
+
425
+ itConditional('should subscribe dynamically via action', function (done) {
426
+ if (skipTests) { return this.skip() }
427
+ this.timeout = 2000
428
+ const options = {}
429
+ const hooks = { done, beforeLoad: null, afterLoad: null, afterConnect: null }
430
+ options.sendMsg = {
431
+ topic: nextTopic(), payload: 'abc'
432
+ }
433
+ options.expectMsg = Object.assign({}, options.sendMsg)
434
+ testSendRecv({ protocolVersion: 5 }, { datatype: 'utf8', topicType: 'dynamic' }, {}, options, hooks)
435
+ })
436
+ // #endregion BASIC TESTS
437
+
438
+ // #region ################### ADVANCED TESTS ################### #//
439
+ // next test is skipped because broker options are not settible for ff-mqtt nodes (always auto connects)
440
+ itConditional.skip('should connect via "connect" action', function (done) {
441
+ if (skipTests) { return this.skip() }
442
+ this.timeout = 2000
443
+ const options = {}
444
+ const hooks = { done: null, beforeLoad: null, afterLoad: null, afterConnect: null }
445
+ hooks.afterLoad = (helperNode, mqttBroker, mqttIn, mqttOut) => {
446
+ mqttBroker.should.have.property('autoConnect', false)
447
+ mqttBroker.should.have.property('connecting', false)// should not attempt to connect (autoConnect:false)
448
+ mqttIn.receive({ action: 'connect' }) // now request connect action
449
+ return true // handled
450
+ }
451
+ hooks.afterConnect = (helperNode, mqttBroker, mqttIn, mqttOut) => {
452
+ done()// if we got here, it connected :)
453
+ return true
454
+ }
455
+ testSendRecv({ protocolVersion: 5, autoConnect: false }, { datatype: 'utf8', topicType: 'dynamic' }, {}, options, hooks)
456
+ })
457
+ itConditional('should disconnect via "disconnect" action', function (done) {
458
+ if (skipTests) { return this.skip() }
459
+ this.timeout = 2000
460
+ const options = {}
461
+ const hooks = { beforeLoad: null, afterLoad: null, afterConnect: null }
462
+ hooks.beforeLoad = (flow) => { // add a status node pointed at MQTT Out node (to watch for connection status change)
463
+ flow.push({ id: 'status.node', type: 'status', name: 'status_node', scope: ['mqtt.out'], wires: [['helper.node']] })// add status node to watch mqtt_out
464
+ }
465
+ hooks.afterLoad = (helperNode, mqttBroker, mqttIn, mqttOut) => {
466
+ mqttBroker.should.have.property('autoConnect', true)
467
+ mqttBroker.should.have.property('connecting', true)// should be trying to connect (autoConnect:true)
468
+ return true // handled
469
+ }
470
+ hooks.afterConnect = (helperNode, mqttBroker, mqttIn, mqttOut) => {
471
+ // connected - now add the "on" handler then send "disconnect" action
472
+ helperNode.on('input', function (msg) {
473
+ try {
474
+ msg.should.have.property('status')
475
+ msg.status.should.have.property('text')
476
+ msg.status.text.should.containEql('disconnect')
477
+ done() // it disconnected - yey!
478
+ } catch (error) {
479
+ done(error)
480
+ }
481
+ })
482
+ mqttOut.receive({ action: 'disconnect' })
483
+ return true // handed
484
+ }
485
+ testSendRecv({ protocolVersion: 5 }, null, {}, options, hooks)
486
+ })
487
+ // next test is skipped because broker options are not settible for ff-mqtt nodes (always auto connects)
488
+ itConditional.skip('should publish birth message', function (done) {
489
+ if (skipTests) { return this.skip() }
490
+ this.timeout = 2000
491
+ const baseTopic = nextTopic()
492
+ const brokerOptions = {
493
+ autoConnect: false,
494
+ protocolVersion: 4,
495
+ birthTopic: baseTopic + '/birth',
496
+ birthPayload: 'broker birth',
497
+ birthQos: 2
498
+ }
499
+ const expectMsg = {
500
+ topic: brokerOptions.birthTopic,
501
+ payload: brokerOptions.birthPayload,
502
+ qos: brokerOptions.birthQos
503
+ }
504
+ const options = { }
505
+ const hooks = { }
506
+ hooks.afterLoad = (helperNode, mqttBroker, mqttIn, mqttOut) => {
507
+ helperNode.on('input', function (msg) {
508
+ try {
509
+ compareMsgToExpected(msg, expectMsg)
510
+ done()
511
+ } catch (error) {
512
+ done(error)
513
+ }
514
+ })
515
+ mqttIn.receive({ action: 'connect' }) // now request connect action
516
+ return true // handled
517
+ }
518
+ testSendRecv(brokerOptions, { topic: brokerOptions.birthTopic }, {}, options, hooks)
519
+ })
520
+ // next test is skipped because broker options are not settible for ff-mqtt nodes (always auto connects)
521
+ itConditional.skip('should safely discard bad birth topic', function (done) {
522
+ if (skipTests) { return this.skip() }
523
+ this.timeout = 2000
524
+ const baseTopic = nextTopic()
525
+ const brokerOptions = {
526
+ protocolVersion: 4,
527
+ birthTopic: baseTopic + '#', // a publish topic should never have a wildcard
528
+ birthPayload: 'broker connected',
529
+ birthQos: 2
530
+ }
531
+ const options = {}
532
+ const hooks = { done: null, beforeLoad: null, afterLoad: null, afterConnect: null }
533
+ hooks.afterLoad = (helperNode, mqttBroker, mqttIn, mqttOut) => {
534
+ helperNode.on('input', function (msg) {
535
+ try {
536
+ msg.should.have.a.property('error').type('object')
537
+ msg.error.should.have.a.property('source').type('object')
538
+ msg.error.source.should.have.a.property('id', mqttIn.id)
539
+ done()
540
+ } catch (err) {
541
+ done(err)
542
+ }
543
+ })
544
+ return true // handled
545
+ }
546
+ options.expectMsg = null
547
+ try {
548
+ testSendRecv(brokerOptions, { topic: brokerOptions.birthTopic }, {}, options, hooks)
549
+ done()
550
+ } catch (error) {
551
+ done(error)
552
+ }
553
+ })
554
+ // next test is skipped because broker options are not settible for ff-mqtt nodes (always auto connects)
555
+ itConditional.skip('should publish close message', function (done) {
556
+ if (skipTests) { return this.skip() }
557
+ this.timeout = 2000
558
+ const baseTopic = nextTopic()
559
+ const broker1Options = { id: 'mqtt.broker1' }// Broker 1 - stays connected to receive the close message
560
+ const broker2Options = { id: 'mqtt.broker2', closeTopic: baseTopic + '/close', closePayload: '{"msg":"close"}', closeQos: 1 }// Broker 2 - connects to same broker but has a LWT message.
561
+ const { flow } = buildBasicMQTTSendRecvFlow(broker1Options, { broker: broker1Options.id, topic: broker2Options.closeTopic, datatype: 'json' }, { broker: broker2Options.id })
562
+ flow.push(buildMQTTBrokerNode(broker2Options.id, broker2Options.name, BROKER_HOST, BROKER_PORT, broker2Options)) // add second broker
563
+ helper.load(mqttNodes, flow, function () {
564
+ const helperNode = helper.getNode('helper.node')
565
+ const mqttOut = helper.getNode('mqtt.out')
566
+ const mqttBroker1 = helper.getNode('mqtt.broker1')
567
+ const mqttBroker2 = helper.getNode('mqtt.broker2')
568
+ waitBrokerConnect([mqttBroker1, mqttBroker2])
569
+ .then(() => {
570
+ // connected - add the on handler and call to disconnect
571
+ helperNode.on('input', function (msg) {
572
+ try {
573
+ msg.should.have.property('topic', broker2Options.closeTopic)
574
+ msg.should.have.property('payload', JSON.parse(broker2Options.closePayload))
575
+ msg.should.have.property('qos', broker2Options.closeQos)
576
+ done()
577
+ } catch (error) {
578
+ done(error)
579
+ }
580
+ })
581
+ mqttOut.receive({ action: 'disconnect' })// close broker2
582
+ })
583
+ .catch(done)
584
+ })
585
+ })
586
+ // next test is skipped because broker options are not settible for ff-mqtt nodes (always auto connects)
587
+ itConditional.skip('should publish will message', function (done) {
588
+ if (skipTests) { return this.skip() }
589
+ this.timeout = 2000
590
+ const baseTopic = nextTopic()
591
+ const broker1Options = { id: 'mqtt.broker1' }// Broker 1 - stays connected to receive the will message
592
+ const broker2Options = { id: 'mqtt.broker2', willTopic: baseTopic + '/will', willPayload: '{"msg":"will"}', willQos: 2 }// Broker 2 - connects to same broker but has a LWT message.
593
+ const { flow } = buildBasicMQTTSendRecvFlow(broker1Options, { broker: broker1Options.id, topic: broker2Options.willTopic, datatype: 'utf8' }, { broker: broker2Options.id })
594
+ flow.push(buildMQTTBrokerNode(broker2Options.id, broker2Options.name, BROKER_HOST, BROKER_PORT, broker2Options)) // add second broker
595
+
596
+ helper.load(mqttNodes, flow, function () {
597
+ const helperNode = helper.getNode('helper.node')
598
+ const mqttBroker1 = helper.getNode('mqtt.broker1')
599
+ const mqttBroker2 = helper.getNode('mqtt.broker2')
600
+ waitBrokerConnect([mqttBroker1, mqttBroker2])
601
+ .then(() => {
602
+ // connected - add the on handler and call to disconnect
603
+ helperNode.on('input', function (msg) {
604
+ try {
605
+ msg.should.have.property('topic', broker2Options.willTopic)
606
+ msg.should.have.property('payload', broker2Options.willPayload)
607
+ msg.should.have.property('qos', broker2Options.willQos)
608
+ done()
609
+ } catch (error) {
610
+ done(error)
611
+ }
612
+ })
613
+ mqttBroker2.client.end(true) // force closure
614
+ })
615
+ .catch(done)
616
+ })
617
+ })
618
+ // next test is skipped because broker options are not settible for ff-mqtt nodes (always auto connects)
619
+ itConditional.skip('should publish will message with V5 properties', function (done) {
620
+ if (skipTests) { return this.skip() }
621
+ // return this.skip(); //Issue receiving v5 props on will msg. Issue raised here: https://github.com/mqttjs/MQTT.js/issues/1455
622
+ this.timeout = 2000
623
+ const baseTopic = nextTopic()
624
+ // Broker 1 - stays connected to receive the will message when broker 2 is killed
625
+ const broker1Options = { id: 'mqtt.broker1', name: 'mqtt_broker1', protocolVersion: 5, datatype: 'utf8' }
626
+ // Broker 2 - connects to same broker but has a LWT message. Broker 2 gets killed shortly after connection so that the will message is sent from broker
627
+ const broker2Options = {
628
+ id: 'mqtt.broker2',
629
+ name: 'mqtt_broker2',
630
+ protocolVersion: 5,
631
+ willTopic: baseTopic + '/will',
632
+ willPayload: '{"msg":"will"}',
633
+ willQos: 2,
634
+ willMsg: {
635
+ contentType: 'application/json',
636
+ userProps: { will: 'value' },
637
+ respTopic: baseTopic + '/resp',
638
+ correl: Buffer.from('abc'),
639
+ expiry: 2000,
640
+ payloadFormatIndicator: true
641
+ }
642
+ }
643
+ const expectMsg = {
644
+ topic: broker2Options.willTopic,
645
+ payload: broker2Options.willPayload,
646
+ qos: broker2Options.willQos,
647
+ contentType: broker2Options.willMsg.contentType,
648
+ userProperties: broker2Options.willMsg.userProps,
649
+ responseTopic: broker2Options.willMsg.respTopic,
650
+ correlationData: broker2Options.willMsg.correl,
651
+ messageExpiryInterval: broker2Options.willMsg.expiry
652
+ // payloadFormatIndicator: broker2Options.willMsg.payloadFormatIndicator,
653
+ }
654
+ const { flow, nodes } = buildBasicMQTTSendRecvFlow(broker1Options, { broker: broker1Options.id, topic: broker2Options.willTopic, datatype: 'utf8' }, { broker: broker2Options.id })
655
+ flow.push(buildMQTTBrokerNode(broker2Options.id, broker2Options.name, nodes.mqtt_broker1.broker, nodes.mqtt_broker1.port, broker2Options)) // add second broker with will msg set
656
+ helper.load(mqttNodes, flow, function () {
657
+ const helperNode = helper.getNode('helper.node')
658
+ const mqttBroker1 = helper.getNode('mqtt.broker1')
659
+ const mqttBroker2 = helper.getNode('mqtt.broker2')
660
+ waitBrokerConnect([mqttBroker1, mqttBroker2])
661
+ .then(() => {
662
+ // connected - add the on handler and call to disconnect
663
+ helperNode.on('input', function (msg) {
664
+ try {
665
+ compareMsgToExpected(msg, expectMsg)
666
+ done()
667
+ } catch (error) {
668
+ done(error)
669
+ }
670
+ })
671
+ mqttBroker2.client.end(true) // force closure
672
+ })
673
+ .catch(done)
674
+ })
675
+ })
676
+ // #endregion ADVANCED TESTS
677
+ })
678
+
679
+ // #region ################### HELPERS ################### #//
680
+
681
+ /**
682
+ * A basic unit test that builds a flow containing 1 broker, 1 mqtt-in, one mqtt-out and a helper.
683
+ * It performs the following steps: builds flow, loads flow, waits for connection, sends `sendMsg`,
684
+ * waits for msg then compares `sendMsg` to `expectMsg`, and finally calls `done`
685
+ * @param {object} brokerOptions anything that can be set in an MQTTBrokerNode (e.g. id, name, url, broker, server, port, protocolVersion, ...)
686
+ * @param {object} inNodeOptions anything that can be set in an MQTTInNode (e.g. id, name, broker, topic, rh, nl, rap, ... )
687
+ * @param {object} outNodeOptions anything that can be set in an MQTTOutNode (e.g. id, name, broker, ...)
688
+ * @param {object} options an object for passing in test properties like `sendMsg` and `expectMsg`
689
+ * @param {object} hooks an object containing hook functions...
690
+ * * [fn] `done()` - the tests done function. If excluded, an error will be thrown upon test error
691
+ * * [fn] `beforeLoad(flow)` - provides opportunity to adjust the flow JSON before loading into runtime
692
+ * * [fn] `afterLoad(helperNode, mqttBroker, mqttIn, mqttOut)` - called before connection attempt
693
+ * * [fn] `afterConnect(helperNode, mqttBroker, mqttIn, mqttOut)` - called before connection attempt
694
+ */
695
+ function testSendRecv (brokerOptions, inNodeOptions, outNodeOptions, options, hooks) {
696
+ options = options || {}
697
+ brokerOptions = brokerOptions || {}
698
+ inNodeOptions = inNodeOptions || {}
699
+ outNodeOptions = outNodeOptions || {}
700
+ const sendMsg = options.sendMsg || {}
701
+ sendMsg.topic = sendMsg.topic || nextTopic()
702
+ const expectMsg = options.expectMsg || Object.assign({}, sendMsg)
703
+ expectMsg.payload = inNodeOptions.payload === undefined ? expectMsg.payload : inNodeOptions.payload
704
+ if (inNodeOptions.topicType !== 'dynamic') {
705
+ inNodeOptions.topic = inNodeOptions.topic || sendMsg.topic
706
+ }
707
+
708
+ const { flow, nodes } = buildBasicMQTTSendRecvFlow(inNodeOptions, outNodeOptions)
709
+ if (hooks.beforeLoad) { hooks.beforeLoad(flow) }
710
+ helper.load(mqttNodes, flow, function () {
711
+ let finished = false
712
+ try {
713
+ const helperNode = helper.getNode('helper.node')
714
+ const mqttIn = helper.getNode(nodes.mqtt_in.id)
715
+ const mqttOut = helper.getNode(nodes.mqtt_out.id)
716
+ const mqttBroker = mqttIn.brokerConn || mqttOut.brokerConn
717
+ let afterLoadHandled = false
718
+ if (hooks.afterLoad) {
719
+ afterLoadHandled = hooks.afterLoad(helperNode, mqttBroker, mqttIn, mqttOut)
720
+ }
721
+ if (!afterLoadHandled) {
722
+ helperNode.on('input', function (msg) {
723
+ finished = true
724
+ try {
725
+ compareMsgToExpected(msg, expectMsg)
726
+ if (hooks.done) { hooks.done() }
727
+ } catch (err) {
728
+ if (hooks.done) { hooks.done(err) } else { throw err }
729
+ }
730
+ })
731
+ }
732
+ waitBrokerConnect(mqttBroker)
733
+ .then(() => {
734
+ // finally, connected!
735
+ if (hooks.afterConnect) {
736
+ const handled = hooks.afterConnect(helperNode, mqttBroker, mqttIn, mqttOut)
737
+ if (handled) { return }
738
+ }
739
+ if (sendMsg.topic) {
740
+ if (mqttIn.isDynamic) {
741
+ mqttIn.receive({ action: 'subscribe', topic: sendMsg.topic })
742
+ }
743
+ mqttOut.receive(sendMsg)
744
+ }
745
+ })
746
+ .catch((e) => {
747
+ if (finished) { return }
748
+ if (hooks.done) { hooks.done(e) } else { throw e }
749
+ })
750
+ } catch (err) {
751
+ if (finished) { return }
752
+ if (hooks.done) { hooks.done(err) } else { throw err }
753
+ }
754
+ })
755
+ }
756
+
757
+ /**
758
+ * Builds a flow containing 2 parts.
759
+ * * 1: MQTT Out node (with broker configured).
760
+ * * 2: MQTT In node (with broker configured) --> helper node `id:helper.node`
761
+ */
762
+ function buildBasicMQTTSendRecvFlow (inOptions, outOptions) {
763
+ const inNode = buildMQTTInNode(inOptions.id, inOptions.name, inOptions.topic, inOptions, ['helper.node'])
764
+ const outNode = buildMQTTOutNode(outOptions.id, outOptions.name, outOptions.topic, outOptions)
765
+ const helper = buildNode('helper', 'helper.node', 'helper_node', {})
766
+ const catchNode = buildNode('catch', 'catch.node', 'catch_node', { scope: ['mqtt.in'] }, ['helper.node'])
767
+ return {
768
+ nodes: {
769
+ [inNode.name]: inNode,
770
+ [outNode.name]: outNode,
771
+ [helper.name]: helper,
772
+ [catchNode.name]: catchNode
773
+ },
774
+ flow: [inNode, outNode, helper, catchNode]
775
+ }
776
+ }
777
+
778
+ function buildMQTTBrokerNode (id, name, brokerHost, brokerPort, options) {
779
+ // url,broker,port,clientid,autoConnect,usetls,usews,verifyservercert,compatmode,protocolVersion,keepalive,
780
+ // cleansession,sessionExpiry,topicAliasMaximum,maximumPacketSize,receiveMaximum,userProperties,userPropertiesType,autoUnsubscribe
781
+ options = options || {}
782
+ const node = buildNode('mqtt-broker', id || 'mqtt.broker', name || 'mqtt_broker', options)
783
+ node.url = options.url
784
+ node.broker = brokerHost || options.broker || BROKER_HOST
785
+ node.port = brokerPort || options.port || BROKER_PORT
786
+ node.clientid = options.clientid || ''
787
+ node.cleansession = String(options.cleansession) !== 'false'
788
+ node.autoUnsubscribe = String(options.autoUnsubscribe) !== 'false'
789
+ node.autoConnect = String(options.autoConnect) !== 'false'
790
+ node.sessionExpiry = options.sessionExpiry ? options.sessionExpiry : undefined
791
+
792
+ if (options.birthTopic) {
793
+ node.birthTopic = options.birthTopic
794
+ node.birthQos = options.birthQos || '0'
795
+ node.birthPayload = options.birthPayload || ''
796
+ }
797
+ if (options.closeTopic) {
798
+ node.closeTopic = options.closeTopic
799
+ node.closeQos = options.closeQos || '0'
800
+ node.closePayload = options.closePayload || ''
801
+ }
802
+ if (options.willTopic) {
803
+ node.willTopic = options.willTopic
804
+ node.willQos = options.willQos || '0'
805
+ node.willPayload = options.willPayload || ''
806
+ }
807
+ updateNodeOptions(options, node)
808
+ return node
809
+ }
810
+
811
+ function buildMQTTInNode (id, name, topic, options, wires) {
812
+ // { "id": "mqtt.in", "type": "mqtt in", "name": "mqtt_in", "topic": "test/in", "qos": "2", "datatype": "auto", "broker": "mqtt.broker", "nl": false, "rap": true, "rh": 0, "inputs": 0, "wires": [["mqtt.out"]] }
813
+ options = options || {}
814
+ const node = buildNode('ff-mqtt-in', id || 'mqtt.in', name || 'mqtt_in', options)
815
+ node.topic = topic || ''
816
+ node.topicType = options.topicType === 'dynamic' ? 'dynamic' : 'static'
817
+ node.inputs = options.topicType === 'dynamic' ? 1 : 0
818
+ updateNodeOptions(node, options, wires)
819
+ return node
820
+ }
821
+
822
+ function buildMQTTOutNode (id, name, topic, options) {
823
+ // { "id": "mqtt.out", "type": "mqtt out", "name": "mqtt_out", "topic": "test/out", "qos": "", "retain": "", "respTopic": "", "contentType": "", "userProps": "", "correl": "", "expiry": "", "broker": brokerId, "wires": [] },
824
+ options = options || {}
825
+ options.broker = options.broker || 'mqtt.broker'
826
+ const node = buildNode('ff-mqtt-out', id || 'mqtt.out', name || 'mqtt_out', options)
827
+ node.topic = topic || ''
828
+ updateNodeOptions(node, options, null)
829
+ return node
830
+ }
831
+
832
+ function buildNode (type, id, name, options, wires) {
833
+ // { "id": "mqtt.in", "type": "mqtt in", "name": "mqtt_in", "topic": "test/in", "qos": "2", "datatype": "auto", "broker": "mqtt.broker", "nl": false, "rap": true, "rh": 0, "inputs": 0, "wires": [["mqtt.out"]] }
834
+ options = options || {}
835
+ const node = {
836
+ id: id || (type.replace(/[\W]/g, '.')),
837
+ type,
838
+ name: name || (type.replace(/[\W]/g, '_')),
839
+ wires: []
840
+ }
841
+ if (node.id.indexOf('.') === -1) { node.id += '.node' }
842
+ updateNodeOptions(node, options, wires)
843
+ return node
844
+ }
845
+
846
+ function updateNodeOptions (node, options, wires) {
847
+ const keys = Object.keys(options)
848
+ for (let index = 0; index < keys.length; index++) {
849
+ const key = keys[index]
850
+ const val = options[key]
851
+ if (node[key] === undefined) {
852
+ node[key] = val
853
+ }
854
+ }
855
+ if (wires && Array.isArray(wires)) {
856
+ node.wires[0] = [...wires]
857
+ }
858
+ }
859
+
860
+ function compareMsgToExpected (msg, expectMsg) {
861
+ msg.should.have.property('topic', expectMsg.topic)
862
+ msg.should.have.property('payload', expectMsg.payload)
863
+ if (hasProperty(expectMsg, 'retain')) { msg.retain.should.eql(expectMsg.retain) }
864
+ if (hasProperty(expectMsg, 'qos')) {
865
+ msg.qos.should.eql(expectMsg.qos)
866
+ } else {
867
+ msg.qos.should.eql(0)
868
+ }
869
+ if (hasProperty(expectMsg, 'userProperties')) { msg.should.have.property('userProperties', expectMsg.userProperties) }
870
+ if (hasProperty(expectMsg, 'contentType')) { msg.should.have.property('contentType', expectMsg.contentType) }
871
+ if (hasProperty(expectMsg, 'correlationData')) { msg.should.have.property('correlationData', expectMsg.correlationData) }
872
+ if (hasProperty(expectMsg, 'responseTopic')) { msg.should.have.property('responseTopic', expectMsg.responseTopic) }
873
+ if (hasProperty(expectMsg, 'payloadFormatIndicator')) { msg.should.have.property('payloadFormatIndicator', expectMsg.payloadFormatIndicator) }
874
+ if (hasProperty(expectMsg, 'messageExpiryInterval')) { msg.should.have.property('messageExpiryInterval', expectMsg.messageExpiryInterval) }
875
+ }
876
+
877
+ function waitBrokerConnect (broker, timeLimit) {
878
+ const waitConnected = (broker, timeLimit) => {
879
+ const brokers = Array.isArray(broker) ? broker : [broker]
880
+ timeLimit = timeLimit || 1000
881
+ return new Promise((resolve, reject) => {
882
+ let timer; let resolved = false
883
+ timer = wait()
884
+ function wait () {
885
+ if (brokers.every(e => e.connected === true)) {
886
+ resolved = true
887
+ clearTimeout(timer)
888
+ resolve()
889
+ } else {
890
+ timeLimit = timeLimit - 15
891
+ if (timeLimit <= 0) {
892
+ if (!resolved) {
893
+ // eslint-disable-next-line prefer-promise-reject-errors
894
+ reject('Timeout waiting broker connect')
895
+ }
896
+ }
897
+ timer = setTimeout(wait, 15)
898
+ return timer
899
+ }
900
+ }
901
+ })
902
+ }
903
+ return waitConnected(broker, timeLimit)
904
+ }
905
+
906
+ function hasProperty (obj, propName) {
907
+ return Object.prototype.hasOwnProperty.call(obj, propName)
908
+ }
909
+
910
+ const baseTopic = 'nr' + Date.now().toString() + '/'
911
+ let topicNo = 0
912
+ function nextTopic (topic) {
913
+ topicNo++
914
+ if (!topic) { topic = 'unittest' }
915
+ if (topic.startsWith('/')) { topic = topic.substring(1) }
916
+ if (topic.startsWith(baseTopic)) { return topic + String(topicNo) }
917
+ return (baseTopic + topic + String(topicNo))
918
+ }
919
+
920
+ // #endregion HELPERS