mqtt-devices-parser 1.0.27 → 1.0.29

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,34 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Node.js
17
+ uses: actions/setup-node@v4
18
+ with:
19
+ node-version: '20'
20
+ cache: 'npm'
21
+
22
+ - name: Install dependencies
23
+ run: npm ci
24
+
25
+ - name: Run tests with coverage
26
+ run: npm test
27
+
28
+ - name: Upload coverage report
29
+ uses: actions/upload-artifact@v4
30
+ if: always()
31
+ with:
32
+ name: coverage-report
33
+ path: coverage/
34
+ retention-days: 7
package/Changelog.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.29
4
+ fix(models/sensors): allow null model_id
5
+ fix: synching mqtt topics
6
+ feat: stores mqtt messages
7
+
8
+ ## 1.0.28
9
+ test: Add comprehensive Jest test suite for device.js module (#2)
10
+ feat: scope FOTA checks to device variant_id (#11)
11
+ doc(device): recover readme add test.md file
12
+ fix(models/variants): remove unique true from column name
13
+ ci: fix vulnerabilities
14
+
3
15
  ## 1.0.27
4
16
  feat(db): adds variants table
5
17
 
package/jest.config.js ADDED
@@ -0,0 +1,22 @@
1
+ module.exports = {
2
+ testEnvironment: 'node',
3
+ collectCoverage: true,
4
+ collectCoverageFrom: [
5
+ 'src/**/*.js',
6
+ '!src/**/*.test.js',
7
+ '!src/unitTest/**'
8
+ ],
9
+ coverageDirectory: 'coverage',
10
+ coverageReporters: ['text', 'lcov', 'html'],
11
+ testMatch: [
12
+ '**/__tests__/**/*.js',
13
+ '**/?(*.)+(spec|test).js'
14
+ ],
15
+ testPathIgnorePatterns: [
16
+ '/node_modules/',
17
+ '/src/unitTest/'
18
+ ],
19
+ clearMocks: true,
20
+ resetMocks: true,
21
+ restoreMocks: true
22
+ };
@@ -0,0 +1,29 @@
1
+
2
+ module.exports = (sequelize,DataTypes)=>{
3
+ return sequelize.define("logs_mqtt", {
4
+ mqtt_id: {
5
+ type: DataTypes.INTEGER,
6
+ allowNull: false,
7
+ },
8
+ device_id: {
9
+ type: DataTypes.INTEGER,
10
+ allowNull: false,
11
+ },
12
+ source: {
13
+ type: DataTypes.STRING,
14
+ allowNull: false
15
+ },
16
+ action: {
17
+ type: DataTypes.STRING,
18
+ allowNull: false
19
+ },
20
+ payload: {
21
+ type: DataTypes.STRING,
22
+ allowNull: true
23
+ },
24
+ },
25
+ {
26
+ tableName: 'logs_mqtt',
27
+ freezeTableName: true
28
+ })
29
+ }
@@ -0,0 +1,29 @@
1
+
2
+ module.exports = (sequelize,DataTypes)=>{
3
+ return sequelize.define("logs_mqtt_msgs", {
4
+ device_id: {
5
+ type: DataTypes.INTEGER,
6
+ allowNull: false,
7
+ },
8
+ topic: {
9
+ type: DataTypes.STRING,
10
+ allowNull: false
11
+ },
12
+ payload: {
13
+ type: DataTypes.STRING,
14
+ allowNull: true
15
+ },
16
+ qos: {
17
+ type: DataTypes.INTEGER,
18
+ allowNull: false
19
+ },
20
+ retain: {
21
+ type: DataTypes.INTEGER,
22
+ allowNull: false
23
+ },
24
+ },
25
+ {
26
+ tableName: 'logs_mqtt_msgs',
27
+ freezeTableName: true
28
+ })
29
+ }
@@ -3,7 +3,7 @@ module.exports = (sequelize,DataTypes)=>{
3
3
  return sequelize.define("sensors", {
4
4
  model_id: { // reference a model
5
5
  type: DataTypes.INTEGER,
6
- allowNull: false,
6
+ allowNull: true,
7
7
  references: {
8
8
  model: 'models',
9
9
  key: 'id'
@@ -3,7 +3,6 @@ module.exports = (sequelize,DataTypes)=>{
3
3
  return sequelize.define("variants", {
4
4
  name: {
5
5
  type: DataTypes.STRING,
6
- unique: true
7
6
  },
8
7
  model_id: {
9
8
  type: DataTypes.INTEGER,
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "mqtt-devices-parser",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
7
+ "test": "jest",
8
+ "test:watch": "jest --watch"
8
9
  },
9
10
  "keywords": [],
10
11
  "author": "",
@@ -30,5 +31,8 @@
30
31
  "sequelize": {
31
32
  "uuid": "^11.1.1"
32
33
  }
34
+ },
35
+ "devDependencies": {
36
+ "jest": "^30.0.5"
33
37
  }
34
38
  }
package/src/db/device.js CHANGED
@@ -216,6 +216,56 @@ var self = module.exports = {
216
216
  });
217
217
  },
218
218
 
219
+ addMqttLog : async(deviceId,dbTopicId,from,action,payload)=>{
220
+ return new Promise((resolve,reject) => {
221
+
222
+ const timestamp = moment().utc().format('YYYY-MM-DD HH:mm:ss')
223
+ let obj = {
224
+ createdAt : timestamp,
225
+ updatedAt : timestamp
226
+ };
227
+
228
+ obj['device_id'] = deviceId;
229
+ obj['mqtt_id'] = dbTopicId;
230
+ obj['source'] = from;
231
+ obj['action'] = action;
232
+ obj['payload'] = payload;
233
+
234
+ $.db.insert("logs_mqtt",obj)
235
+ .then (rows => {
236
+ return resolve(rows);
237
+ })
238
+ .catch(error => {
239
+ return reject(error);
240
+ });
241
+ });
242
+ },
243
+
244
+ addMqttMsgLog : async(deviceId,topic,payload,retain,qos)=>{
245
+ return new Promise((resolve,reject) => {
246
+
247
+ const timestamp = moment().utc().format('YYYY-MM-DD HH:mm:ss')
248
+ let obj = {
249
+ createdAt : timestamp,
250
+ updatedAt : timestamp
251
+ };
252
+
253
+ obj['device_id'] = deviceId;
254
+ obj['topic'] = topic;
255
+ obj['payload'] = payload;
256
+ obj['retain'] = retain;
257
+ obj['qos'] = qos;
258
+
259
+ $.db.insert("logs_mqtt_msgs",obj)
260
+ .then (rows => {
261
+ return resolve(rows);
262
+ })
263
+ .catch(error => {
264
+ return reject(error);
265
+ });
266
+ });
267
+ },
268
+
219
269
  addLogIfChanged : async(id,column,value)=>{
220
270
  return new Promise(async (resolve,reject) => {
221
271
 
@@ -28,18 +28,18 @@ var self = module.exports = {
28
28
  });
29
29
  },
30
30
 
31
- getLatestVersion : async (modelId,release)=>{
31
+ getLatestVersion : async (modelId,release,variantId)=>{
32
32
 
33
33
  return new Promise((resolve,reject) => {
34
34
 
35
35
  let query = "";
36
36
  let table = [];
37
37
 
38
- query = `SELECT version,filename,token,id FROM firmwares where model_id = ? and build_release = ? ORDER BY CAST(SUBSTRING_INDEX(version, '.', 1) AS UNSIGNED) DESC,
38
+ query = `SELECT version,filename,token,id FROM firmwares where model_id = ? and build_release = ? and variant_id = ? ORDER BY CAST(SUBSTRING_INDEX(version, '.', 1) AS UNSIGNED) DESC,
39
39
  CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(version, '.', 2), '.', -1) AS UNSIGNED) DESC,
40
40
  CAST(SUBSTRING_INDEX(version, '.', -1) AS UNSIGNED) DESC
41
41
  LIMIT 1`;
42
- table = [modelId,release];
42
+ table = [modelId,release,variantId];
43
43
 
44
44
  query = mysql.format(query,table);
45
45
 
@@ -57,18 +57,18 @@ var self = module.exports = {
57
57
  });
58
58
  },
59
59
 
60
- getLatestAppVersion : async (modelId,release)=>{
60
+ getLatestAppVersion : async (modelId,release,variantId)=>{
61
61
 
62
62
  return new Promise((resolve,reject) => {
63
63
 
64
64
  let query = "";
65
65
  let table = [];
66
66
 
67
- query = `SELECT app_version,filename,token,id FROM firmwares where model_id = ? and build_release = ? ORDER BY CAST(SUBSTRING_INDEX(app_version, '.', 1) AS UNSIGNED) DESC,
67
+ query = `SELECT app_version,filename,token,id FROM firmwares where model_id = ? and build_release = ? and variant_id = ? ORDER BY CAST(SUBSTRING_INDEX(app_version, '.', 1) AS UNSIGNED) DESC,
68
68
  CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(app_version, '.', 2), '.', -1) AS UNSIGNED) DESC,
69
69
  CAST(SUBSTRING_INDEX(app_version, '.', -1) AS UNSIGNED) DESC
70
70
  LIMIT 1`;
71
- table = [modelId,release];
71
+ table = [modelId,release,variantId];
72
72
 
73
73
  query = mysql.format(query,table);
74
74
 
@@ -88,7 +88,3 @@ To activate the project you just need to add the project in config/index.js file
88
88
  projects: {
89
89
  demo : process.env.demo || true
90
90
  }
91
-
92
-
93
-
94
-
@@ -0,0 +1,95 @@
1
+ # Device Module Jest Test Plan
2
+
3
+ This test suite provides comprehensive coverage for the `src/device/device.js` module using Jest.
4
+
5
+ ## Test Coverage
6
+
7
+ The test suite covers all major functions in the device module:
8
+
9
+ ### 1. `init()` Function
10
+ - ✅ Database connection and project initialization
11
+ - ✅ Project module loading and initialization
12
+ - ✅ Database project insertion for new projects
13
+
14
+ ### 2. `parseMessage()` Function
15
+ - ✅ Status message parsing (online/offline)
16
+ - ✅ Model message parsing with validation
17
+ - ✅ Version message parsing (when device has existing version)
18
+ - ✅ App version message parsing (when device has existing app version)
19
+ - ✅ Invalid topic format handling
20
+ - ✅ Unknown project handling
21
+ - ✅ Settings/set message handling (local settings updates)
22
+ - ✅ Firmware (fw) message handling with JSON payload
23
+
24
+ ### 3. Settings Management Functions
25
+ - ✅ `updateLocalSettings()` - nested settings updates
26
+ - ✅ `updateLocalSettings()` - invalid JSON payload handling
27
+ - ✅ `updateLocalSettings()` - empty topic handling
28
+ - ✅ `updateRemoteSettings()` - remote settings updates
29
+ - ✅ `updateRemoteSettings()` - non-JSON payload handling
30
+
31
+ ### 4. Log Management Functions
32
+ - ✅ `deleteLogs()` - old log entries cleanup from log tables
33
+
34
+ ### 5. FOTA (Firmware Over The Air) Functions
35
+ - ✅ `checkFota()` - firmware update checking
36
+ - ✅ `checkFota()` - device release acceptance filtering
37
+ - ✅ `triggerFota()` - firmware update triggering for eligible devices
38
+ - ✅ `handleFotaSuccess()` - successful FOTA completion handling
39
+ - ✅ `handleFotaError()` - FOTA error handling
40
+
41
+ ## Test Features
42
+
43
+ ### Mocking Strategy
44
+ The test suite extensively mocks all database dependencies:
45
+ - `$.db` - Database connection and table operations
46
+ - `$.db_device` - Device database operations
47
+ - `$.db_project` - Project database operations
48
+ - `$.db_model` - Model database operations
49
+ - `$.db_sensor` - Sensor database operations
50
+ - `$.db_data` - Data storage operations
51
+ - `$.db_firmware` - Firmware database operations
52
+ - `$.db_fota` - FOTA database operations
53
+
54
+ ### Coverage Statistics
55
+ - **Statements**: 73.3%
56
+ - **Branches**: 55.24%
57
+ - **Functions**: 78.94%
58
+ - **Lines**: 74.13%
59
+
60
+ ### Test Configuration
61
+ - Uses Jest test framework
62
+ - Node.js test environment
63
+ - Coverage reports in text, lcov, and HTML formats
64
+ - Excludes existing integration tests in `src/unitTest/`
65
+
66
+ ## Running Tests
67
+
68
+ ```bash
69
+ # Run all tests
70
+ npm test
71
+
72
+ # Run tests with watch mode
73
+ npm run test:watch
74
+
75
+ # Run tests with coverage
76
+ npm test -- --coverage
77
+ ```
78
+
79
+ ## Architecture Notes
80
+
81
+ The tests follow Jest best practices:
82
+ - Comprehensive mocking of external dependencies
83
+ - Clear test descriptions and organization
84
+ - Edge case coverage
85
+ - Error handling validation
86
+ - Async/await pattern usage
87
+
88
+ ## Known Limitations
89
+
90
+ Some complex scenarios are not tested due to the existing code architecture:
91
+ - Database connection failure handling (complex Promise wrapping)
92
+ - Device creation race conditions (async .then() chains)
93
+ - Project module require() failures (virtual module mocking limitations)
94
+
95
+ These limitations represent opportunities for code improvement in the actual device.js module.
@@ -33,7 +33,7 @@ var self = module.exports = {
33
33
  });
34
34
  },
35
35
 
36
- parseMessage : async (client,topic,payload,retain)=>{
36
+ parseMessage : async (client,topic,payload,retain,qos=0)=>{
37
37
 
38
38
  let topic_bck = topic;
39
39
  let project_name = null;
@@ -77,6 +77,7 @@ var self = module.exports = {
77
77
  if(device.protocol.toLowerCase() === "lwm2m"){
78
78
  parseLwm2mMessage(client, project_name, device, topic, payload, action);
79
79
  }else if(device.protocol.toLowerCase() === "mqtt"){
80
+ $.db_device.addMqttMsgLog(device.id,topic,payload,retain,qos);
80
81
  parseMqttMessage(client, project_name, device, topic, payload, retain);
81
82
  }
82
83
  },
@@ -113,25 +114,40 @@ var self = module.exports = {
113
114
  continue;
114
115
 
115
116
  try{
116
- const latestVersion = await $.db_firmware.getLatestVersion(model.id,release);
117
- const latestAppVersion = await $.db_firmware.getLatestAppVersion(model.id,release);
118
- //console.log("latestVersion:",latestVersion?.version);
119
- //console.log("latestAppVersion:",latestAppVersion?.app_version);
120
-
121
117
  const devices = await $.db_device.listByModel(model.id);
122
118
 
123
119
  if(devices == null)
124
120
  continue;
125
121
 
122
+ const firmwareCache = new Map();
123
+
126
124
  for (const device of devices) {
127
125
 
128
126
  if(device?.accept_release != release){
129
127
  continue;
130
128
  }
131
129
 
130
+ if(!device?.variant_id){
131
+ continue;
132
+ }
133
+
134
+ const cacheKey = `${model.id}_${release}_${device.variant_id}`;
135
+ if(!firmwareCache.has(cacheKey)){
136
+ const latestVersion = await $.db_firmware.getLatestVersion(model.id,release,device.variant_id);
137
+ const latestAppVersion = await $.db_firmware.getLatestAppVersion(model.id,release,device.variant_id);
138
+ firmwareCache.set(cacheKey, { latestVersion, latestAppVersion });
139
+ }
140
+
141
+ const { latestVersion, latestAppVersion } = firmwareCache.get(cacheKey);
142
+ //console.log("latestVersion:",latestVersion?.version);
143
+ //console.log("latestAppVersion:",latestAppVersion?.app_version);
144
+
145
+ if(!latestVersion && !latestAppVersion)
146
+ continue;
147
+
132
148
  let obj = null;
133
149
  // insert filename on fota table for this device or update.
134
- if(device?.app_version != latestAppVersion.app_version){
150
+ if(latestAppVersion && device?.app_version != latestAppVersion.app_version){
135
151
  obj = {
136
152
  model_id : device.model_id,
137
153
  target_version : latestVersion.version,
@@ -251,7 +267,6 @@ var self = module.exports = {
251
267
  }
252
268
  },
253
269
 
254
-
255
270
  updateSensor : async (device,ref,payload)=>{
256
271
 
257
272
  let sensors = await $.db_device.getSensorsByRef(device.id,ref)
@@ -304,17 +319,37 @@ var self = module.exports = {
304
319
 
305
320
  handleMqttTopic : async(device, dbTopic, payload, set)=>{
306
321
  if(set){
322
+ if(payload === "" || payload == null){
323
+ // acknowledgment
324
+ $.db_device.setSynchedTopic(dbTopic.id,true);
325
+ $.db_device.updateRemoteTopic(dbTopic.id,dbTopic?.localData?.value ? dbTopic.localData.value : dbTopic?.localData);
326
+ let from = "device";
327
+ let action = "ack"
328
+ $.db_device.addMqttLog(device.id,dbTopic.id,from,action,null);
329
+ return;
330
+ }
307
331
  // update local topic
332
+ let from = "server";
333
+ let action = "write";
334
+ $.db_device.addMqttLog(device.id,dbTopic.id,from,action,JSON.stringify(payload));
335
+
308
336
  $.db_device.updateLocalTopic(dbTopic.id,payload)
309
337
  .then(()=>{
310
338
  // set as not synched
311
- $.db_device.setSynchedTopic(dbTopic.id,false);
339
+ if(isDeepStrictEqual(payload, dbTopic?.remoteData))
340
+ $.db_device.setSynchedTopic(dbTopic.id,true);
341
+ else
342
+ $.db_device.setSynchedTopic(dbTopic.id,false);
312
343
  })
313
344
  .catch((error)=>{
314
345
  console.error(error);
315
346
  })
316
347
  }else{
317
348
  // update remote topic
349
+ let from = "device";
350
+ let action = "update";
351
+ $.db_device.addMqttLog(device.id,dbTopic.id,from,action,JSON.stringify(payload));
352
+
318
353
  $.db_device.updateRemoteTopic(dbTopic.id,payload)
319
354
  .then(async (res)=>{
320
355
  // check if topics mismatch
@@ -368,8 +403,17 @@ async function parseLwm2mMessage(client, project_name, device, topic, payload, a
368
403
 
369
404
  async function parseMqttMessage(client, project_name, device, topic, payload, retain){
370
405
 
371
- if(topic.endsWith("/get"))
406
+ if(topic.endsWith("/get")){
407
+ let findTopic = $.parser.getWordBeforeLastSlash(topic);
408
+ const dbTopic = await $.db_device.getMqttTopic(device.id,findTopic)
409
+ if(dbTopic != null){
410
+ let from = "server";
411
+ let action = "request";
412
+ let data = null;
413
+ $.db_device.addMqttLog(device.id,dbTopic.id,from,action,data);
414
+ }
372
415
  return;
416
+ }
373
417
 
374
418
  const topicBck = topic;
375
419
  //console.log("[MQTT] parse topic: ",topic);
@@ -470,7 +514,8 @@ async function parseMqttMessage(client, project_name, device, topic, payload, re
470
514
  }
471
515
  }
472
516
  break;
473
- case "settings":
517
+ case "settings": // deprecated
518
+ break;
474
519
  if(topic.endsWith("/set")){
475
520
  updateLocalSettings(device,topic,payload);
476
521
  }else{
@@ -510,19 +555,25 @@ async function synchMqttTopic(device,dbTopic){
510
555
  let mqtt_prefix = `${project.name}/${device.uid}`;
511
556
  let topic = `${mqtt_prefix}/${dbTopic.topic}/set`
512
557
  let payload = "";
513
- if(dbTopic?.remoteData && typeof dbTopic?.remoteData === 'object'){
558
+ if(dbTopic?.localData && typeof dbTopic?.localData === 'object'){
514
559
  try{
515
- payload = JSON.stringify(dbTopic?.remoteData);
560
+ if(dbTopic?.localData?.value)
561
+ payload = JSON.stringify(dbTopic?.localData?.value);
562
+ else
563
+ payload = JSON.stringify(dbTopic?.localData);
516
564
  }catch(err){
517
565
  console.error(err);
518
566
  return;
519
567
  }
520
568
  }else{
521
- payload = dbTopic?.remoteData;
569
+ payload = dbTopic?.localData;
522
570
  }
523
571
  $.mqtt_client.publish(topic,payload,{qos:1,retain:false});
524
572
  }
525
573
 
574
+ // keep a struct with all settings configured on the device
575
+ // only topics <project>/<uid>/settings are stored on this struct
576
+ // easier way to return all settings at once
526
577
  async function updateLocalSettings(device,topic,payload){
527
578
 
528
579
  $.db_device.addLog(device.id,"local_settings",JSON.stringify(payload));
@@ -571,6 +622,9 @@ async function updateLocalSettings(device,topic,payload){
571
622
  }
572
623
  }
573
624
 
625
+ // keep a struct with all settings configured on the device
626
+ // only topics <project>/<uid>/settings are stored on this struct
627
+ // easier way to return all settings at once
574
628
  async function updateRemoteSettings(device,topic,payload){
575
629
 
576
630
  $.db_device.addLog(device.id,"remote_settings",JSON.stringify(payload));
@@ -614,13 +668,15 @@ async function updateRemoteSettings(device,topic,payload){
614
668
 
615
669
  try {
616
670
  await $.db_device.updateRemoteSettings(JSON.stringify(settings), device.id);
617
- if(device?.synch)
671
+ // deprecated, only defined topics with synch enabled can be synched
672
+ if(device?.synch && false)
618
673
  synchSettings(device,route[0]);
619
674
  } catch (err) {
620
675
  console.error("Failed to update local settings:", err);
621
676
  }
622
677
  }
623
678
 
679
+ // deprecated - only defined mqtt topics can be synched
624
680
  async function synchSettings(device,key){
625
681
 
626
682
  console.log(`check topic ${key} from ${device.uid}`);
@@ -631,6 +687,7 @@ async function synchSettings(device,key){
631
687
  console.log(remoteSettings)
632
688
 
633
689
  //let keys = await checkSettings(localSettings,remoteSettings);
690
+ /*
634
691
  if(localSettings?.hasOwnProperty(key) && remoteSettings?.hasOwnProperty(key)){
635
692
  if(!isDeepStrictEqual(localSettings?.[key], remoteSettings?.[key])){
636
693
  const project = await $.db_project.getById(device.project_id);
@@ -647,9 +704,9 @@ async function synchSettings(device,key){
647
704
  }
648
705
  }
649
706
  }
707
+ */
650
708
  }
651
709
 
652
-
653
710
  function handleFotaSuccess (deviceId){
654
711
  let object = {
655
712
  "nAttempts" : 0,
@@ -0,0 +1,456 @@
1
+ const parser = require('../aux/parser');
2
+
3
+ // Mock global dependencies
4
+ global.$ = {
5
+ db: {
6
+ connect: jest.fn(),
7
+ getTables: jest.fn(),
8
+ deleteOldEntries: jest.fn()
9
+ },
10
+ parser: parser,
11
+ db_project: {
12
+ getByName: jest.fn(),
13
+ insert: jest.fn(),
14
+ getById: jest.fn()
15
+ },
16
+ db_device: {
17
+ get: jest.fn(),
18
+ insert: jest.fn(),
19
+ update: jest.fn(),
20
+ addLog: jest.fn(),
21
+ getLocalSettings: jest.fn(),
22
+ updateLocalSettings: jest.fn(),
23
+ getRemoteSettings: jest.fn(),
24
+ updateRemoteSettings: jest.fn(),
25
+ listByModel: jest.fn(),
26
+ getById: jest.fn(),
27
+ getMqttTopic: jest.fn(),
28
+ getSensorsByRef: jest.fn(),
29
+ updateLocalTopic: jest.fn(),
30
+ setSynchedTopic: jest.fn(),
31
+ updateRemoteTopic: jest.fn(),
32
+ getAssociatedDevice: jest.fn()
33
+ },
34
+ db_model: {
35
+ getByName: jest.fn(),
36
+ getAll: jest.fn(),
37
+ getById: jest.fn()
38
+ },
39
+ db_sensor: {
40
+ getByRef: jest.fn(),
41
+ insert: jest.fn()
42
+ },
43
+ db_data: {
44
+ updateJson: jest.fn(),
45
+ addJsonLog: jest.fn(),
46
+ update: jest.fn(),
47
+ addLog: jest.fn(),
48
+ getAssociatedToDevice: jest.fn()
49
+ },
50
+ db_firmware: {
51
+ getLatestVersion: jest.fn(),
52
+ getLatestAppVersion: jest.fn(),
53
+ getById: jest.fn()
54
+ },
55
+ db_fota: {
56
+ getEntry: jest.fn(),
57
+ update: jest.fn(),
58
+ getUpdatable: jest.fn(),
59
+ newLog: jest.fn(),
60
+ updateLog: jest.fn()
61
+ },
62
+ config: {
63
+ web: {
64
+ protocol: 'https://',
65
+ domain: 'example.com',
66
+ fw_path: '/firmware/'
67
+ }
68
+ },
69
+ mqtt_client: {
70
+ publish: jest.fn()
71
+ }
72
+ };
73
+
74
+ global.BASE_DIR = '/mock/base/dir';
75
+
76
+ // Mock require for project modules
77
+ jest.mock('fs');
78
+
79
+ const device = require('./device');
80
+
81
+ describe('Device Module', () => {
82
+ beforeEach(() => {
83
+ // Clear all mocks before each test
84
+ jest.clearAllMocks();
85
+
86
+ // Setup default mock implementations
87
+ $.db.connect.mockImplementation((config, callback) => callback());
88
+ $.db_project.getByName.mockResolvedValue(null);
89
+ $.db_device.getMqttTopic.mockResolvedValue(null);
90
+ $.db_device.getSensorsByRef.mockResolvedValue([]);
91
+ });
92
+
93
+ describe('init', () => {
94
+ it('should initialize database connection and projects successfully', async () => {
95
+ const mockConfig = { database: 'test' };
96
+ const mockProjects = ['project1', 'project2'];
97
+
98
+ // Mock project modules
99
+ const mockProject1 = { init: jest.fn() };
100
+ const mockProject2 = { init: jest.fn() };
101
+
102
+ jest.doMock(`${BASE_DIR}/projects/project1/project1.js`, () => mockProject1, { virtual: true });
103
+ jest.doMock(`${BASE_DIR}/projects/project2/project2.js`, () => mockProject2, { virtual: true });
104
+
105
+ $.db_project.getByName.mockResolvedValue(null);
106
+ $.db_project.insert.mockResolvedValue();
107
+
108
+ await device.init(mockConfig, mockProjects);
109
+
110
+ expect($.db.connect).toHaveBeenCalledWith(mockConfig, expect.any(Function));
111
+ expect($.db_project.getByName).toHaveBeenCalledTimes(2);
112
+ expect($.db_project.insert).toHaveBeenCalledTimes(2);
113
+ });
114
+ });
115
+
116
+ describe('parseMessage', () => {
117
+ let mockClient, mockDevice;
118
+
119
+ beforeEach(() => {
120
+ mockClient = { id: 'test-client' };
121
+ mockDevice = {
122
+ id: 1,
123
+ uid: 'test-device-123',
124
+ project_id: 1,
125
+ status: 'offline',
126
+ tech: 'wifi',
127
+ version: '1.0.0',
128
+ app_version: '1.0.0',
129
+ protocol: 'mqtt'
130
+ };
131
+
132
+ $.db_project.getByName.mockResolvedValue({
133
+ id: 1,
134
+ name: 'testproject',
135
+ uidPrefix: 'test-device-',
136
+ uidLength: 15
137
+ });
138
+
139
+ $.db_device.get.mockResolvedValue(mockDevice);
140
+ $.db_fota.update.mockResolvedValue();
141
+ $.db_fota.updateLog.mockResolvedValue();
142
+ });
143
+
144
+ it('should parse status message correctly', async () => {
145
+ const topic = 'testproject/test-device-123/status';
146
+ const payload = 'online';
147
+
148
+ await device.parseMessage(mockClient, topic, payload, false);
149
+
150
+ expect($.db_device.update).toHaveBeenCalledWith(1, 'status', 'online');
151
+ expect($.db_device.addLog).toHaveBeenCalledWith(1, 'status', 'online');
152
+ });
153
+
154
+ it('should publish get topics when device comes online', async () => {
155
+ const topic = 'testproject/test-device-123/status';
156
+ const payload = 'online';
157
+ mockDevice.remote_settings = {};
158
+
159
+ await device.parseMessage(mockClient, topic, payload, false);
160
+
161
+ expect($.mqtt_client.publish).toHaveBeenCalled();
162
+ });
163
+
164
+ it('should parse model message correctly when device tech differs from payload', async () => {
165
+ const topic = 'testproject/test-device-123/model';
166
+ const payload = 'TEST_MODEL';
167
+
168
+ // Make sure device.tech is different from payload so the condition passes
169
+ mockDevice.tech = 'different_tech';
170
+ $.db_device.get.mockResolvedValue(mockDevice);
171
+ $.db_model.getByName.mockResolvedValue({ id: 5 });
172
+
173
+ await device.parseMessage(mockClient, topic, payload, false);
174
+
175
+ expect($.db_model.getByName).toHaveBeenCalledWith('TEST_MODEL');
176
+ expect($.db_device.update).toHaveBeenCalledWith(1, 'model_id', 5);
177
+ expect($.db_device.addLog).toHaveBeenCalledWith(1, 'model_id', 5);
178
+ });
179
+
180
+ it('should parse version message correctly when device has existing version', async () => {
181
+ const topic = 'testproject/test-device-123/version';
182
+ const payload = '2.0.0';
183
+
184
+ // Device already has a version that's different from payload
185
+ mockDevice.version = '1.0.0';
186
+ $.db_device.get.mockResolvedValue(mockDevice);
187
+
188
+ await device.parseMessage(mockClient, topic, payload, false);
189
+
190
+ expect($.db_device.addLog).toHaveBeenCalledWith(1, 'version', '2.0.0');
191
+ expect($.db_device.update).toHaveBeenCalledWith(1, 'version', '2.0.0');
192
+ });
193
+
194
+ it('should parse app_version message correctly when device has existing app_version', async () => {
195
+ const topic = 'testproject/test-device-123/app_version';
196
+ const payload = '2.0.0';
197
+
198
+ // Device already has an app_version that's different from payload
199
+ mockDevice.app_version = '1.0.0';
200
+ $.db_device.get.mockResolvedValue(mockDevice);
201
+
202
+ await device.parseMessage(mockClient, topic, payload, false);
203
+
204
+ expect($.db_device.addLog).toHaveBeenCalledWith(1, 'app_version', '2.0.0');
205
+ expect($.db_device.update).toHaveBeenCalledWith(1, 'app_version', '2.0.0');
206
+ });
207
+
208
+ it('should return early for unknown project', async () => {
209
+ const topic = 'unknownproject/test-device-123/status';
210
+ const payload = 'online';
211
+
212
+ $.db_project.getByName.mockResolvedValue(null);
213
+
214
+ await device.parseMessage(mockClient, topic, payload, false);
215
+
216
+ expect($.db_device.get).not.toHaveBeenCalled();
217
+ });
218
+
219
+ it('should return early when uid does not match project prefix', async () => {
220
+ const topic = 'testproject/other-device-999/status';
221
+ const payload = 'online';
222
+
223
+ await device.parseMessage(mockClient, topic, payload, false);
224
+
225
+ expect($.db_device.get).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it('should return early when device is not found', async () => {
229
+ const topic = 'testproject/test-device-123/status';
230
+ const payload = 'online';
231
+
232
+ $.db_device.get.mockResolvedValue(null);
233
+
234
+ await device.parseMessage(mockClient, topic, payload, false);
235
+
236
+ expect($.db_device.update).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it('should handle settings/set messages (local settings)', async () => {
240
+ const settingsPayload = { ssid: 'test-network' };
241
+ const topic = 'testproject/test-device-123/settings/wifi/ssid/set';
242
+ const payload = JSON.stringify(settingsPayload);
243
+
244
+ $.db_device.getLocalSettings.mockResolvedValue({});
245
+ $.db_device.updateLocalSettings.mockResolvedValue();
246
+
247
+ await device.parseMessage(mockClient, topic, payload, false);
248
+
249
+ // updateLocalSettings calls addLog with the JSON-parsed (object) payload
250
+ expect($.db_device.addLog).toHaveBeenCalledWith(
251
+ 1,
252
+ 'local_settings',
253
+ JSON.stringify(settingsPayload)
254
+ );
255
+ expect($.db_device.updateLocalSettings).toHaveBeenCalled();
256
+ });
257
+
258
+ it('should handle settings messages without /set (remote settings)', async () => {
259
+ const settingsPayload = { threshold: 25 };
260
+ const topic = 'testproject/test-device-123/settings/sensor/temperature';
261
+ const payload = JSON.stringify(settingsPayload);
262
+
263
+ $.db_device.getRemoteSettings.mockResolvedValue({});
264
+ $.db_device.updateRemoteSettings.mockResolvedValue();
265
+
266
+ await device.parseMessage(mockClient, topic, payload, false);
267
+
268
+ expect($.db_device.addLog).toHaveBeenCalledWith(
269
+ 1,
270
+ 'remote_settings',
271
+ JSON.stringify(settingsPayload)
272
+ );
273
+ expect($.db_device.updateRemoteSettings).toHaveBeenCalled();
274
+ });
275
+
276
+ it('should handle fw messages with JSON payload', async () => {
277
+ const fwPayload = { version: '1.0.0', build: '123' };
278
+ const topic = 'testproject/test-device-123/fw';
279
+ const payload = JSON.stringify(fwPayload);
280
+
281
+ $.db_data.updateJson.mockResolvedValue();
282
+ $.db_data.addJsonLog.mockResolvedValue();
283
+
284
+ await device.parseMessage(mockClient, topic, payload, false);
285
+
286
+ expect($.db_data.updateJson).toHaveBeenCalledWith('fw', 1, fwPayload);
287
+ expect($.db_data.addJsonLog).toHaveBeenCalledWith('logs_fw', 1, fwPayload, '');
288
+ });
289
+
290
+ it('should handle fw/fota/update/status (FOTA error) message', async () => {
291
+ const topic = 'testproject/test-device-123/fw/fota/update/status';
292
+ const payload = 'Download failed';
293
+
294
+ await device.parseMessage(mockClient, topic, payload, false);
295
+
296
+ expect($.db_fota.updateLog).toHaveBeenCalledWith(1, { error: 'Download failed' });
297
+ });
298
+
299
+ it('should skip topics ending with /get', async () => {
300
+ const topic = 'testproject/test-device-123/settings/wifi/get';
301
+ const payload = '';
302
+
303
+ await device.parseMessage(mockClient, topic, payload, false);
304
+
305
+ expect($.db_device.update).not.toHaveBeenCalled();
306
+ expect($.db_device.addLog).not.toHaveBeenCalled();
307
+ });
308
+ });
309
+
310
+ describe('deleteLogs', () => {
311
+ it('should delete old logs from all log tables', async () => {
312
+ const mockTables = [
313
+ { 'Tables_in_mqtt-aedes': 'logs_device' },
314
+ { 'Tables_in_mqtt-aedes': 'logs_sensor' },
315
+ { 'Tables_in_mqtt-aedes': 'regular_table' }
316
+ ];
317
+
318
+ $.db.getTables.mockResolvedValue(mockTables);
319
+ $.db.deleteOldEntries.mockResolvedValue();
320
+
321
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
322
+
323
+ await device.deleteLogs();
324
+
325
+ expect($.db.getTables).toHaveBeenCalled();
326
+ expect($.db.deleteOldEntries).toHaveBeenCalledTimes(2); // Only log tables
327
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('deleting logs of table'));
328
+
329
+ consoleSpy.mockRestore();
330
+ });
331
+ });
332
+
333
+ describe('checkFota', () => {
334
+ const mockModels = [
335
+ { id: 1, name: 'test-model' }
336
+ ];
337
+
338
+ const mockDevices = [
339
+ {
340
+ id: 1,
341
+ uid: 'device-123',
342
+ model_id: 1,
343
+ variant_id: 2,
344
+ version: '1.0.0',
345
+ app_version: '1.0.0',
346
+ accept_release: 'prod'
347
+ }
348
+ ];
349
+
350
+ beforeEach(() => {
351
+ $.db_model.getAll.mockResolvedValue(mockModels);
352
+ $.db_device.listByModel.mockResolvedValue(mockDevices);
353
+ $.db_fota.getEntry.mockResolvedValue(null);
354
+ $.db_fota.update.mockResolvedValue();
355
+ });
356
+
357
+ it('should check for firmware updates', async () => {
358
+ const latestVersion = { id: 1, version: '2.0.0' };
359
+ const latestAppVersion = { id: 2, app_version: '2.0.0' };
360
+
361
+ $.db_firmware.getLatestVersion.mockResolvedValue(latestVersion);
362
+ $.db_firmware.getLatestAppVersion.mockResolvedValue(latestAppVersion);
363
+
364
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
365
+
366
+ await device.checkFota('prod');
367
+
368
+ expect($.db_model.getAll).toHaveBeenCalled();
369
+ expect($.db_firmware.getLatestVersion).toHaveBeenCalledWith(1, 'prod', 2);
370
+ expect($.db_firmware.getLatestAppVersion).toHaveBeenCalledWith(1, 'prod', 2);
371
+ expect($.db_fota.update).toHaveBeenCalled();
372
+
373
+ consoleSpy.mockRestore();
374
+ });
375
+
376
+ it('should skip devices with different release acceptance', async () => {
377
+ const devDevices = [{ ...mockDevices[0], accept_release: 'dev' }];
378
+ $.db_device.listByModel.mockResolvedValue(devDevices);
379
+
380
+ $.db_firmware.getLatestVersion.mockResolvedValue({ id: 1, version: '2.0.0' });
381
+ $.db_firmware.getLatestAppVersion.mockResolvedValue({ id: 2, app_version: '2.0.0' });
382
+
383
+ await device.checkFota('prod');
384
+
385
+ expect($.db_fota.update).not.toHaveBeenCalled();
386
+ });
387
+
388
+ it('should skip devices without variant_id', async () => {
389
+ const devicesWithoutVariant = [{ ...mockDevices[0], variant_id: null }];
390
+ $.db_device.listByModel.mockResolvedValue(devicesWithoutVariant);
391
+
392
+ $.db_firmware.getLatestVersion.mockResolvedValue({ id: 1, version: '2.0.0' });
393
+ $.db_firmware.getLatestAppVersion.mockResolvedValue({ id: 2, app_version: '2.0.0' });
394
+
395
+ await device.checkFota('prod');
396
+
397
+ expect($.db_firmware.getLatestVersion).not.toHaveBeenCalled();
398
+ expect($.db_fota.update).not.toHaveBeenCalled();
399
+ });
400
+
401
+ it('should return early when no models are found', async () => {
402
+ $.db_model.getAll.mockResolvedValue([]);
403
+
404
+ await device.checkFota('prod');
405
+
406
+ expect($.db_firmware.getLatestVersion).not.toHaveBeenCalled();
407
+ });
408
+
409
+ it('should not create FOTA entry when versions already match', async () => {
410
+ $.db_firmware.getLatestVersion.mockResolvedValue({ id: 1, version: '1.0.0' });
411
+ $.db_firmware.getLatestAppVersion.mockResolvedValue({ id: 2, app_version: '1.0.0' });
412
+
413
+ await device.checkFota('prod');
414
+
415
+ expect($.db_fota.update).not.toHaveBeenCalled();
416
+ });
417
+
418
+ it('should only compare firmware with the same variant_id as the device', async () => {
419
+ const latestVersion = { id: 1, version: '2.0.0' };
420
+ const latestAppVersion = { id: 2, app_version: '2.0.0' };
421
+
422
+ $.db_firmware.getLatestVersion.mockResolvedValue(latestVersion);
423
+ $.db_firmware.getLatestAppVersion.mockResolvedValue(latestAppVersion);
424
+
425
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
426
+
427
+ await device.checkFota('prod');
428
+
429
+ expect($.db_firmware.getLatestVersion).toHaveBeenCalledWith(1, 'prod', mockDevices[0].variant_id);
430
+ expect($.db_firmware.getLatestAppVersion).toHaveBeenCalledWith(1, 'prod', mockDevices[0].variant_id);
431
+
432
+ consoleSpy.mockRestore();
433
+ });
434
+ });
435
+
436
+ describe('triggerFota', () => {
437
+ beforeEach(() => {
438
+ $.db_fota.getUpdatable.mockResolvedValue([]);
439
+ $.db_fota.update.mockResolvedValue();
440
+ $.db_fota.newLog.mockResolvedValue();
441
+ $.mqtt_client.publish.mockImplementation(() => {});
442
+ });
443
+
444
+ it('should call getUpdatable with provided release', async () => {
445
+ await device.triggerFota('prod');
446
+
447
+ expect($.db_fota.getUpdatable).toHaveBeenCalledWith('prod');
448
+ });
449
+
450
+ it('should use dev release by default', async () => {
451
+ await device.triggerFota();
452
+
453
+ expect($.db_fota.getUpdatable).toHaveBeenCalledWith('dev');
454
+ });
455
+ });
456
+ });
@@ -140,6 +140,7 @@ var self = module.exports = {
140
140
 
141
141
  try{
142
142
  payload = JSON.parse(payload);
143
+ //console.log(`[KAFKA] payload: ${payload.mqtt.payload}`);
143
144
  }catch(error){
144
145
  console.log(`[KAFKA] error: ${payload}`);
145
146
  console.log(error)