node-red-contrib-influxdb3 1.0.6 → 1.0.7

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/README.md CHANGED
@@ -52,8 +52,8 @@ A configuration node that stores connection details for your InfluxDB v3 instanc
52
52
  - **Host**: Your InfluxDB v3 host URL (e.g., `https://us-east-1-1.aws.cloud2.influxdata.com`)
53
53
  - **Token**: Your InfluxDB v3 authentication token
54
54
  - **Database**: The default database (bucket) name
55
- - **Verify TLS**: Toggle TLS certificate verification (unchecked sets `NODE_TLS_REJECT_UNAUTHORIZED=0`)
56
- - **CA Cert Path**: Optional filesystem path for a custom root CA (`NODE_EXTRA_CA_CERTS`)
55
+ - **Verify TLS**: Toggle TLS certificate verification for this connection (unchecked disables verification for this connection only)
56
+ - **CA Cert Path**: Optional filesystem path to a PEM CA certificate used to verify this connection's TLS certificate
57
57
 
58
58
  ### InfluxDB v3 Write Node
59
59
 
@@ -399,8 +399,8 @@ Simply reference them in the Node-RED UI using `${INFLUX_HOST}` syntax (if using
399
399
 
400
400
  If you are connecting to a local InfluxDB v3 instance with a custom certificate:
401
401
 
402
- - Set **CA Cert Path** in the config node to the PEM file containing your root CA. This sets `NODE_EXTRA_CA_CERTS` for the Node-RED process.
403
- - As a last resort, disable **Verify TLS** to set `NODE_TLS_REJECT_UNAUTHORIZED=0` (this disables TLS verification globally for the process).
402
+ - Set **CA Cert Path** in the config node to the PEM file containing your root CA. The certificate is read when the connection is first used and applied to this connection only.
403
+ - As a last resort, disable **Verify TLS**. This skips certificate verification for this connection only (it does not affect other connections or the rest of the Node-RED process).
404
404
 
405
405
  ### Data Not Appearing
406
406
 
@@ -416,7 +416,8 @@ The node will display error status and log details to the Node-RED debug panel:
416
416
 
417
417
  ## Requirements
418
418
 
419
- - Node-RED v2.0.0 or higher
419
+ - Node.js v18.0.0 or higher
420
+ - Node-RED v3.0.0 or higher
420
421
  - InfluxDB v3 instance (Cloud or Edge)
421
422
 
422
423
  ## License
@@ -1,4 +1,6 @@
1
1
  const path = require('path');
2
+ const fs = require('fs');
3
+ const os = require('os');
2
4
 
3
5
  let mockLastClientOptions;
4
6
  let mockLastClientInstance;
@@ -135,8 +137,37 @@ describe('InfluxDB v3 config node', () => {
135
137
  expect(options.token).toBe('token');
136
138
  });
137
139
 
138
- test('sets extra CA certificate path when configured', () => {
139
- const original = process.env.NODE_EXTRA_CA_CERTS;
140
+ test('passes CA certificate via transportOptions and does not touch global env', () => {
141
+ const originalEnv = process.env.NODE_EXTRA_CA_CERTS;
142
+ const caPath = path.join(os.tmpdir(), `influx-ca-${Date.now()}.pem`);
143
+ const caContents = '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n';
144
+ fs.writeFileSync(caPath, caContents);
145
+
146
+ try {
147
+ const { RED, influxModule } = setup();
148
+ const ConfigCtor = RED._types['influxdb3-config'];
149
+
150
+ const configNode = new ConfigCtor({
151
+ host: 'https://example.com',
152
+ database: 'metrics',
153
+ name: 'Test',
154
+ caCertPath: caPath,
155
+ credentials: { token: 'token' }
156
+ });
157
+
158
+ configNode.getClient();
159
+
160
+ const options = influxModule.__getLastClientOptions();
161
+ expect(options.transportOptions).toBeDefined();
162
+ expect(options.transportOptions.ca.toString()).toBe(caContents);
163
+ // Global env must be left untouched
164
+ expect(process.env.NODE_EXTRA_CA_CERTS).toBe(originalEnv);
165
+ } finally {
166
+ fs.unlinkSync(caPath);
167
+ }
168
+ });
169
+
170
+ test('throws a clear error when the CA certificate cannot be read', () => {
140
171
  const { RED } = setup();
141
172
  const ConfigCtor = RED._types['influxdb3-config'];
142
173
 
@@ -144,14 +175,50 @@ describe('InfluxDB v3 config node', () => {
144
175
  host: 'https://example.com',
145
176
  database: 'metrics',
146
177
  name: 'Test',
147
- caCertPath: path.join('C:', 'certs', 'root.pem'),
178
+ caCertPath: path.join(os.tmpdir(), 'does-not-exist-influx-ca.pem'),
179
+ credentials: { token: 'token' }
180
+ });
181
+
182
+ expect(() => configNode.getClient()).toThrow(/Failed to read CA certificate/);
183
+ });
184
+
185
+ test('disables TLS verification via transportOptions without setting global env', () => {
186
+ const originalEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
187
+ const { RED, influxModule } = setup();
188
+ const ConfigCtor = RED._types['influxdb3-config'];
189
+
190
+ const configNode = new ConfigCtor({
191
+ host: 'https://example.com',
192
+ database: 'metrics',
193
+ name: 'Test',
194
+ tlsRejectUnauthorized: false,
195
+ credentials: { token: 'token' }
196
+ });
197
+
198
+ configNode.getClient();
199
+
200
+ const options = influxModule.__getLastClientOptions();
201
+ expect(options.transportOptions).toBeDefined();
202
+ expect(options.transportOptions.rejectUnauthorized).toBe(false);
203
+ // Global env must be left untouched
204
+ expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBe(originalEnv);
205
+ });
206
+
207
+ test('omits transportOptions entirely when TLS defaults are used', () => {
208
+ const { RED, influxModule } = setup();
209
+ const ConfigCtor = RED._types['influxdb3-config'];
210
+
211
+ const configNode = new ConfigCtor({
212
+ host: 'https://example.com',
213
+ database: 'metrics',
214
+ name: 'Test',
148
215
  credentials: { token: 'token' }
149
216
  });
150
217
 
151
218
  configNode.getClient();
152
219
 
153
- expect(process.env.NODE_EXTRA_CA_CERTS).toBe(path.join('C:', 'certs', 'root.pem'));
154
- process.env.NODE_EXTRA_CA_CERTS = original;
220
+ const options = influxModule.__getLastClientOptions();
221
+ expect(options.transportOptions).toBeUndefined();
155
222
  });
156
223
  });
157
224
 
@@ -379,6 +446,27 @@ describe('addFieldToPoint – field type handling', () => {
379
446
  expect.stringContaining("marked as integer but value is 3.7")
380
447
  );
381
448
  });
449
+
450
+ test('negative non-integer float truncates toward zero (not floor)', async () => {
451
+ const { influxModule, writeNode } = createWriteNode();
452
+ const msg = {
453
+ measurement: 'sensor',
454
+ payload: {
455
+ fields: { value: -3.7 },
456
+ integers: ['value']
457
+ }
458
+ };
459
+ const send = jest.fn();
460
+ const done = jest.fn();
461
+ await writeNode._handlers.input(msg, send, done);
462
+
463
+ const point = influxModule.__getLastPoint();
464
+ // Math.trunc(-3.7) === -3 (toward zero), not Math.floor's -4
465
+ expect(point.integerFields.value).toBe(-3);
466
+ expect(writeNode.warn).toHaveBeenCalledWith(
467
+ expect.stringContaining("truncated to -3")
468
+ );
469
+ });
382
470
  });
383
471
 
384
472
  describe('addFieldToPoint – enhanced error messages (issue #16)', () => {
@@ -604,6 +692,91 @@ describe('buildLineProtocol – simplified payload format', () => {
604
692
  });
605
693
  });
606
694
 
695
+ describe('buildLineProtocol – tag value handling', () => {
696
+ test('string, number and boolean tag values are coerced to strings', async () => {
697
+ const { influxModule, writeNode } = createWriteNode();
698
+ const msg = {
699
+ measurement: 'sensor',
700
+ payload: {
701
+ fields: { value: 1 },
702
+ tags: { location: 'lab', floor: 2, active: true }
703
+ }
704
+ };
705
+ const send = jest.fn();
706
+ const done = jest.fn();
707
+ await writeNode._handlers.input(msg, send, done);
708
+
709
+ const point = influxModule.__getLastPoint();
710
+ expect(point.tags.location).toBe('lab');
711
+ expect(point.tags.floor).toBe('2');
712
+ expect(point.tags.active).toBe('true');
713
+ });
714
+
715
+ test('object tag value is skipped with a warning instead of "[object Object]"', async () => {
716
+ const { influxModule, writeNode } = createWriteNode();
717
+ const msg = {
718
+ measurement: 'sensor',
719
+ payload: {
720
+ fields: { value: 1 },
721
+ tags: { good: 'ok', broken: { nested: true } }
722
+ }
723
+ };
724
+ const send = jest.fn();
725
+ const done = jest.fn();
726
+ await writeNode._handlers.input(msg, send, done);
727
+
728
+ const point = influxModule.__getLastPoint();
729
+ expect(point.tags.good).toBe('ok');
730
+ expect(point.tags.broken).toBeUndefined();
731
+ expect(writeNode.warn).toHaveBeenCalledWith(
732
+ expect.stringContaining("Skipping tag 'broken': unsupported type 'object' (Object)")
733
+ );
734
+ // The valid field still writes, so the message succeeds
735
+ expect(send).toHaveBeenCalled();
736
+ expect(done).toHaveBeenCalled();
737
+ });
738
+
739
+ test('array tag value shows Array type name in warning and is skipped', async () => {
740
+ const { influxModule, writeNode } = createWriteNode();
741
+ const msg = {
742
+ measurement: 'sensor',
743
+ payload: {
744
+ fields: { value: 1 },
745
+ tags: { list: [1, 2, 3] }
746
+ }
747
+ };
748
+ const send = jest.fn();
749
+ const done = jest.fn();
750
+ await writeNode._handlers.input(msg, send, done);
751
+
752
+ const point = influxModule.__getLastPoint();
753
+ expect(point.tags.list).toBeUndefined();
754
+ expect(writeNode.warn).toHaveBeenCalledWith(
755
+ expect.stringContaining("Skipping tag 'list': unsupported type 'object' (Array)")
756
+ );
757
+ });
758
+
759
+ test('null and undefined tag values are skipped silently', async () => {
760
+ const { influxModule, writeNode } = createWriteNode();
761
+ const msg = {
762
+ measurement: 'sensor',
763
+ payload: {
764
+ fields: { value: 1 },
765
+ tags: { keep: 'yes', gone: null, missing: undefined }
766
+ }
767
+ };
768
+ const send = jest.fn();
769
+ const done = jest.fn();
770
+ await writeNode._handlers.input(msg, send, done);
771
+
772
+ const point = influxModule.__getLastPoint();
773
+ expect(point.tags.keep).toBe('yes');
774
+ expect(point.tags.gone).toBeUndefined();
775
+ expect(point.tags.missing).toBeUndefined();
776
+ expect(writeNode.warn).not.toHaveBeenCalled();
777
+ });
778
+ });
779
+
607
780
  describe('buildLineProtocol – timestamp handling', () => {
608
781
  test('numeric timestamp from msg.payload.timestamp', async () => {
609
782
  const { influxModule, writeNode } = createWriteNode();
@@ -867,7 +1040,7 @@ describe('Array payload support', () => {
867
1040
  });
868
1041
 
869
1042
  test('array item error includes item index', async () => {
870
- const { RED, influxModule } = setup();
1043
+ const { RED } = setup();
871
1044
  const ConfigCtor = RED._types['influxdb3-config'];
872
1045
  const WriteCtor = RED._types['influxdb3-write'];
873
1046
 
@@ -937,6 +1110,155 @@ describe('Array payload support', () => {
937
1110
  });
938
1111
  });
939
1112
 
1113
+ describe('write node – client.write failure', () => {
1114
+ test('rejected write sets red status and calls done with the error', async () => {
1115
+ const { configNode, writeNode } = createWriteNode();
1116
+
1117
+ // Pre-create the cached client and make its write reject
1118
+ const client = configNode.getClient();
1119
+ client.write = jest.fn().mockRejectedValue(new Error('connection refused'));
1120
+
1121
+ const msg = {
1122
+ measurement: 'sensor',
1123
+ payload: { fields: { value: 1 } }
1124
+ };
1125
+ const send = jest.fn();
1126
+ const done = jest.fn();
1127
+ await writeNode._handlers.input(msg, send, done);
1128
+
1129
+ expect(client.write).toHaveBeenCalled();
1130
+ expect(done).toHaveBeenCalledWith(expect.any(Error));
1131
+ expect(done.mock.calls[0][0].message).toBe('connection refused');
1132
+ // Error status is red and the message is NOT forwarded
1133
+ expect(writeNode.status).toHaveBeenCalledWith(
1134
+ expect.objectContaining({ fill: 'red', text: 'connection refused' })
1135
+ );
1136
+ expect(send).not.toHaveBeenCalled();
1137
+ });
1138
+
1139
+ test('long error messages are truncated in the node status', async () => {
1140
+ const { configNode, writeNode } = createWriteNode();
1141
+ const longMessage = 'x'.repeat(200);
1142
+
1143
+ const client = configNode.getClient();
1144
+ client.write = jest.fn().mockRejectedValue(new Error(longMessage));
1145
+
1146
+ const msg = { measurement: 'sensor', payload: { fields: { value: 1 } } };
1147
+ const send = jest.fn();
1148
+ const done = jest.fn();
1149
+ await writeNode._handlers.input(msg, send, done);
1150
+
1151
+ const statusArg = writeNode.status.mock.calls.find(c => c[0] && c[0].fill === 'red')[0];
1152
+ expect(statusArg.text.length).toBeLessThanOrEqual(83); // 80 chars + '...'
1153
+ expect(statusArg.text.endsWith('...')).toBe(true);
1154
+ // done still receives the full, untruncated error
1155
+ expect(done.mock.calls[0][0].message).toBe(longMessage);
1156
+ });
1157
+ });
1158
+
1159
+ describe('config node – close()', () => {
1160
+ test('close() closes the cached client and clears the reference', () => {
1161
+ const { configNode } = createWriteNode();
1162
+
1163
+ const client = configNode.getClient();
1164
+ expect(configNode.client).toBe(client);
1165
+
1166
+ configNode._handlers.close();
1167
+
1168
+ expect(client.close).toHaveBeenCalled();
1169
+ expect(configNode.client).toBeNull();
1170
+ });
1171
+
1172
+ test('close() is a no-op when no client was ever created', () => {
1173
+ const { configNode } = createWriteNode();
1174
+ expect(configNode.client).toBeNull();
1175
+ expect(() => configNode._handlers.close()).not.toThrow();
1176
+ });
1177
+ });
1178
+
1179
+ describe('write node – measurement resolution', () => {
1180
+ test('empty msg.measurement falls back to the node default', async () => {
1181
+ const { influxModule, writeNode } = createWriteNode(); // node default: 'test_measurement'
1182
+ const msg = {
1183
+ measurement: '',
1184
+ payload: { fields: { value: 1 } }
1185
+ };
1186
+ const send = jest.fn();
1187
+ const done = jest.fn();
1188
+ await writeNode._handlers.input(msg, send, done);
1189
+
1190
+ const point = influxModule.__getLastPoint();
1191
+ expect(point.measurement).toBe('test_measurement');
1192
+ expect(done).toHaveBeenCalled();
1193
+ expect(done.mock.calls[0][0]).toBeUndefined();
1194
+ });
1195
+
1196
+ test('msg.measurement overrides the node default', async () => {
1197
+ const { influxModule, writeNode } = createWriteNode();
1198
+ const msg = {
1199
+ measurement: 'override',
1200
+ payload: { fields: { value: 1 } }
1201
+ };
1202
+ const send = jest.fn();
1203
+ const done = jest.fn();
1204
+ await writeNode._handlers.input(msg, send, done);
1205
+
1206
+ const point = influxModule.__getLastPoint();
1207
+ expect(point.measurement).toBe('override');
1208
+ });
1209
+
1210
+ test('whitespace-only msg.measurement falls back to the node default', async () => {
1211
+ const { influxModule, writeNode } = createWriteNode(); // node default: 'test_measurement'
1212
+ const msg = {
1213
+ measurement: ' ',
1214
+ payload: { fields: { value: 1 } }
1215
+ };
1216
+ const send = jest.fn();
1217
+ const done = jest.fn();
1218
+ await writeNode._handlers.input(msg, send, done);
1219
+
1220
+ const point = influxModule.__getLastPoint();
1221
+ expect(point.measurement).toBe('test_measurement');
1222
+ expect(done.mock.calls[0][0]).toBeUndefined();
1223
+ });
1224
+
1225
+ test('a provided measurement is trimmed before use', async () => {
1226
+ const { influxModule, writeNode } = createWriteNode();
1227
+ const msg = {
1228
+ measurement: ' spaced ',
1229
+ payload: { fields: { value: 1 } }
1230
+ };
1231
+ const send = jest.fn();
1232
+ const done = jest.fn();
1233
+ await writeNode._handlers.input(msg, send, done);
1234
+
1235
+ const point = influxModule.__getLastPoint();
1236
+ expect(point.measurement).toBe('spaced');
1237
+ });
1238
+
1239
+ test('whitespace-only measurement with no node default errors', async () => {
1240
+ const { RED } = setup();
1241
+ const ConfigCtor = RED._types['influxdb3-config'];
1242
+ const WriteCtor = RED._types['influxdb3-write'];
1243
+
1244
+ const configNode = new ConfigCtor({
1245
+ host: 'https://example.com',
1246
+ database: 'metrics',
1247
+ name: 'Test',
1248
+ credentials: { token: 'token' }
1249
+ });
1250
+ const writeNode = new WriteCtor({ influxdb: configNode, measurement: '', database: '' });
1251
+
1252
+ const msg = { measurement: ' ', payload: { fields: { value: 1 } } };
1253
+ const send = jest.fn();
1254
+ const done = jest.fn();
1255
+ await writeNode._handlers.input(msg, send, done);
1256
+
1257
+ expect(done).toHaveBeenCalledWith(expect.any(Error));
1258
+ expect(done.mock.calls[0][0].message).toContain('Measurement not specified');
1259
+ });
1260
+ });
1261
+
940
1262
  describe('InfluxDB v3 config node – credentials warning', () => {
941
1263
  test('logs warning when credentials object is undefined', () => {
942
1264
  const RED = buildRED();
package/influxdb3.html CHANGED
@@ -62,9 +62,9 @@
62
62
  <dt>Database <span class="property-type">string</span></dt>
63
63
  <dd>The default database (bucket) name to write to</dd>
64
64
  <dt>Verify TLS <span class="property-type">boolean</span></dt>
65
- <dd>When unchecked, sets <code>NODE_TLS_REJECT_UNAUTHORIZED=0</code> for the Node-RED process</dd>
65
+ <dd>When unchecked, disables TLS certificate verification for this connection only (insecure; use only for trusted local instances)</dd>
66
66
  <dt>CA Cert Path <span class="property-type">string</span></dt>
67
- <dd>Optional filesystem path used to set <code>NODE_EXTRA_CA_CERTS</code> for custom root CAs</dd>
67
+ <dd>Optional filesystem path to a PEM CA certificate, used to verify this connection's TLS certificate</dd>
68
68
  </dl>
69
69
  </script>
70
70
 
package/influxdb3.js CHANGED
@@ -13,6 +13,8 @@ module.exports = function(RED) {
13
13
  // Point.setTimestamp(date)
14
14
  // Point.toLineProtocol()
15
15
  const { InfluxDBClient, Point } = require('@influxdata/influxdb3-client');
16
+ const fs = require('fs');
17
+ const { validateLineProtocol } = require('./lib/line-protocol');
16
18
 
17
19
  /**
18
20
  * Normalize host URL to ensure it has trailing slash
@@ -75,23 +77,44 @@ module.exports = function(RED) {
75
77
 
76
78
  const normalizedHost = normalizeHost(configNode.host);
77
79
 
80
+ // Build per-client transport (TLS) options. These are passed only to this
81
+ // client's HTTPS requests, so they do NOT affect other connections or the
82
+ // rest of the Node-RED process (unlike NODE_TLS_REJECT_UNAUTHORIZED /
83
+ // NODE_EXTRA_CA_CERTS, which are global and read only at process startup).
84
+ const transportOptions = {};
85
+
78
86
  if (!configNode.tlsRejectUnauthorized) {
79
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
80
- RED.log.warn('InfluxDB v3: TLS certificate verification is disabled for this process.');
87
+ transportOptions.rejectUnauthorized = false;
88
+ RED.log.warn(
89
+ 'InfluxDB v3: TLS certificate verification is disabled for this connection. ' +
90
+ 'This is insecure and should only be used for trusted local instances.'
91
+ );
81
92
  }
82
93
 
83
94
  if (configNode.caCertPath) {
84
- process.env.NODE_EXTRA_CA_CERTS = configNode.caCertPath;
85
- RED.log.info(`InfluxDB v3: Using extra CA certificates from ${configNode.caCertPath}`);
95
+ try {
96
+ transportOptions.ca = fs.readFileSync(configNode.caCertPath);
97
+ RED.log.info(`InfluxDB v3: Using custom CA certificate from ${configNode.caCertPath}`);
98
+ } catch (error) {
99
+ throw new Error(
100
+ `Failed to read CA certificate from '${configNode.caCertPath}': ${error.message}`,
101
+ { cause: error }
102
+ );
103
+ }
86
104
  }
87
105
 
88
106
  RED.log.info(`InfluxDB v3: Connecting to ${normalizedHost} with database ${configNode.database}`);
89
107
 
90
- configNode.client = new InfluxDBClient({
108
+ const clientOptions = {
91
109
  host: normalizedHost,
92
110
  token: configNode.token,
93
111
  database: configNode.database
94
- });
112
+ };
113
+ if (Object.keys(transportOptions).length > 0) {
114
+ clientOptions.transportOptions = transportOptions;
115
+ }
116
+
117
+ configNode.client = new InfluxDBClient(clientOptions);
95
118
 
96
119
  RED.log.info('InfluxDB v3: Client created successfully');
97
120
  }
@@ -157,38 +180,6 @@ module.exports = function(RED) {
157
180
  }
158
181
  }
159
182
 
160
- /**
161
- * Validate that a string looks like InfluxDB line protocol.
162
- * Returns null if valid, or an error message string if invalid.
163
- * @param {string} lp - Trimmed line protocol string
164
- * @returns {string|null}
165
- */
166
- function validateLineProtocol(lp) {
167
- // Detect JSON-like strings (both valid JSON and JS object notation)
168
- if (/^\{[\s\S]*}$/.test(lp) || /^\[[\s\S]*]$/.test(lp)) {
169
- const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
170
- return (
171
- 'The payload appears to be a JSON/object string, not line protocol. ' +
172
- 'If you are sending JSON, ensure msg.payload is a parsed object (not a string). ' +
173
- 'Use a JSON parse node before this node to convert the string to an object. ' +
174
- `Received string: ${preview}`
175
- );
176
- }
177
-
178
- // Line protocol must have at least: measurement field=value
179
- // i.e. at least one space and one '=' in the field set
180
- if (!lp.includes(' ') || !lp.includes('=')) {
181
- const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
182
- return (
183
- 'The payload string does not appear to be valid line protocol. ' +
184
- 'Expected format: measurement[,tag=val] field=val[,field=val] [timestamp]. ' +
185
- `Received: ${preview}`
186
- );
187
- }
188
-
189
- return null;
190
- }
191
-
192
183
  /**
193
184
  * Process a field value and add it to a Point.
194
185
  * Returns true if the field was added, false if it was skipped.
@@ -253,10 +244,10 @@ module.exports = function(RED) {
253
244
  if (!Number.isInteger(value)) {
254
245
  node.warn(
255
246
  `Field '${key}' is marked as integer but value is ${value}${context}. ` +
256
- `Value will be truncated to ${Math.floor(value)} using Math.floor.`
247
+ `Value will be truncated to ${Math.trunc(value)}.`
257
248
  );
258
249
  }
259
- point.setIntegerField(key, Math.floor(value));
250
+ point.setIntegerField(key, Math.trunc(value));
260
251
  } else {
261
252
  point.setFloatField(key, value);
262
253
  }
@@ -297,7 +288,11 @@ module.exports = function(RED) {
297
288
  * @returns {{lineProtocol: string}|{error: string}} result or error
298
289
  */
299
290
  function buildLineProtocol(msg) {
300
- const measurement = msg.measurement || node.measurement;
291
+ // Trim each source before the fallback so a blank/whitespace-only
292
+ // msg.measurement falls back to the node default (instead of being used
293
+ // verbatim) and never produces a measurement made of spaces.
294
+ const trim = (m) => (typeof m === 'string' ? m.trim() : m);
295
+ const measurement = trim(msg.measurement) || trim(node.measurement);
301
296
 
302
297
  if (!measurement) {
303
298
  return { error: 'Measurement not specified' };
@@ -307,10 +302,25 @@ module.exports = function(RED) {
307
302
 
308
303
  // Add tags
309
304
  if (msg.payload.tags && typeof msg.payload.tags === 'object' && !Array.isArray(msg.payload.tags)) {
305
+ const tagContext = measurement ? ` (measurement: '${measurement}')` : '';
310
306
  for (const [key, value] of Object.entries(msg.payload.tags)) {
311
- if (value !== null && value !== undefined) {
312
- point.setTag(key, String(value));
307
+ if (value === null || value === undefined) {
308
+ continue;
309
+ }
310
+ // Guard against objects/arrays, which would otherwise be coerced to
311
+ // useless strings like "[object Object]". Mirrors addFieldToPoint.
312
+ if (typeof value === 'object') {
313
+ const typeName = Array.isArray(value)
314
+ ? 'Array'
315
+ : (value.constructor ? value.constructor.name : 'object');
316
+ node.warn(
317
+ `Skipping tag '${key}': unsupported type 'object' (${typeName})${tagContext}. ` +
318
+ `Actual value: ${safeStringify(value)}. ` +
319
+ `Tag values must be a string, number, or boolean.`
320
+ );
321
+ continue;
313
322
  }
323
+ point.setTag(key, String(value));
314
324
  }
315
325
  }
316
326
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pure line-protocol helpers, extracted so they can be unit-tested directly
3
+ * (and shared) rather than re-implemented in test files.
4
+ * @module lib/line-protocol
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ /**
10
+ * Validate that a string looks like InfluxDB line protocol.
11
+ * Returns null if valid, or an error message string if invalid.
12
+ * @param {string} lp - Trimmed line protocol string
13
+ * @returns {string|null}
14
+ */
15
+ function validateLineProtocol(lp) {
16
+ // Detect JSON-like strings (both valid JSON and JS object notation)
17
+ if (/^\{[\s\S]*}$/.test(lp) || /^\[[\s\S]*]$/.test(lp)) {
18
+ const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
19
+ return (
20
+ 'The payload appears to be a JSON/object string, not line protocol. ' +
21
+ 'If you are sending JSON, ensure msg.payload is a parsed object (not a string). ' +
22
+ 'Use a JSON parse node before this node to convert the string to an object. ' +
23
+ `Received string: ${preview}`
24
+ );
25
+ }
26
+
27
+ // Line protocol must have at least: measurement field=value
28
+ // i.e. at least one space and one '=' in the field set
29
+ if (!lp.includes(' ') || !lp.includes('=')) {
30
+ const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
31
+ return (
32
+ 'The payload string does not appear to be valid line protocol. ' +
33
+ 'Expected format: measurement[,tag=val] field=val[,field=val] [timestamp]. ' +
34
+ `Received: ${preview}`
35
+ );
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ module.exports = { validateLineProtocol };
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "node-red-contrib-influxdb3",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Node-RED nodes for InfluxDB v3 integration",
5
5
  "main": "influxdb3.js",
6
6
  "scripts": {
7
+ "lint": "eslint .",
7
8
  "test": "jest"
8
9
  },
9
10
  "keywords": [
@@ -33,9 +34,12 @@
33
34
  }
34
35
  },
35
36
  "dependencies": {
36
- "@influxdata/influxdb3-client": "^2.1.0"
37
+ "@influxdata/influxdb3-client": "^2.2.0"
37
38
  },
38
39
  "devDependencies": {
40
+ "@eslint/js": "^10.0.1",
41
+ "eslint": "^10.4.1",
42
+ "globals": "^17.6.0",
39
43
  "jest": "^30.3.0"
40
44
  }
41
45
  }
package/renovate.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
3
  "extends": [
4
- "config:recommended"
4
+ "config:base"
5
+ ],
6
+ "platformAutomerge": true,
7
+ "packageRules": [
8
+ {
9
+ "description": "Automerge non-major updates",
10
+ "matchUpdateTypes": ["minor", "patch"],
11
+ "automerge": true
12
+ }
5
13
  ]
6
14
  }
@@ -1,32 +1,10 @@
1
1
  /**
2
2
  * Tests for line protocol string validation.
3
- * Mirrors the validateLineProtocol logic in influxdb3.js.
3
+ * Imports the real validateLineProtocol from the shipping code so the test
4
+ * cannot drift from the implementation.
4
5
  */
5
6
 
6
- // Re-implement the validation logic here to test it in isolation
7
- // (the function is not exported from influxdb3.js)
8
- function validateLineProtocol(lp) {
9
- if (/^\{[\s\S]*}$/.test(lp) || /^\[[\s\S]*]$/.test(lp)) {
10
- const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
11
- return (
12
- 'The payload appears to be a JSON/object string, not line protocol. ' +
13
- 'If you are sending JSON, ensure msg.payload is a parsed object (not a string). ' +
14
- 'Use a JSON parse node before this node to convert the string to an object. ' +
15
- `Received string: ${preview}`
16
- );
17
- }
18
-
19
- if (!lp.includes(' ') || !lp.includes('=')) {
20
- const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
21
- return (
22
- 'The payload string does not appear to be valid line protocol. ' +
23
- 'Expected format: measurement[,tag=val] field=val[,field=val] [timestamp]. ' +
24
- `Received: ${preview}`
25
- );
26
- }
27
-
28
- return null;
29
- }
7
+ const { validateLineProtocol } = require('../lib/line-protocol');
30
8
 
31
9
  describe('Line protocol string validation', () => {
32
10
  test('valid line protocol returns null', () => {
@@ -1,56 +1,69 @@
1
1
  const { Point } = require('@influxdata/influxdb3-client');
2
2
 
3
- describe('@influxdata/influxdb3-client v2.x Point API', () => {
4
- test('Point.setField exists and is a function', () => {
5
- const point = new Point('test');
6
- expect(typeof point.setField).toBe('function');
3
+ /**
4
+ * Pins the real @influxdata/influxdb3-client Point API that influxdb3.js depends on.
5
+ * The node uses the type-specific setters (setFloatField / setIntegerField /
6
+ * setStringField / setBooleanField / setTag / setTimestamp) and toLineProtocol(),
7
+ * so those are what we verify here — if the library changes them, this suite fails.
8
+ */
9
+ describe('@influxdata/influxdb3-client v2.x Point API used by influxdb3.js', () => {
10
+ test('setFloatField writes a float field', () => {
11
+ const lp = new Point('test').setFloatField('temp', 23.5).toLineProtocol();
12
+ expect(lp).toContain('temp=23.5');
7
13
  });
8
14
 
9
- test('Point.setField accepts (name, value) for float', () => {
10
- const point = new Point('test');
11
- point.setField('temp', 23.5);
12
- const lp = point.toLineProtocol();
13
- expect(lp).toContain('temp=23.5');
15
+ test('setFloatField keeps whole numbers as floats (no i suffix)', () => {
16
+ const lp = new Point('test').setFloatField('count', 60).toLineProtocol();
17
+ expect(lp).toContain('count=60');
18
+ expect(lp).not.toContain('count=60i');
14
19
  });
15
20
 
16
- test('Point.setField accepts (name, value, "integer") for integer', () => {
17
- const point = new Point('test');
18
- point.setField('count', 42, 'integer');
19
- const lp = point.toLineProtocol();
21
+ test('setIntegerField writes an integer field with the i suffix', () => {
22
+ const lp = new Point('test').setIntegerField('count', 42).toLineProtocol();
20
23
  expect(lp).toContain('count=42i');
21
24
  });
22
25
 
23
- test('Point.setField accepts (name, value) for string', () => {
24
- const point = new Point('test');
25
- point.setField('status', 'ok');
26
- const lp = point.toLineProtocol();
26
+ test('setIntegerField supports negative integers', () => {
27
+ const lp = new Point('test').setIntegerField('offset', -7).toLineProtocol();
28
+ expect(lp).toContain('offset=-7i');
29
+ });
30
+
31
+ test('setStringField writes a quoted string field', () => {
32
+ const lp = new Point('test').setStringField('status', 'ok').toLineProtocol();
27
33
  expect(lp).toContain('status="ok"');
28
34
  });
29
35
 
30
- test('Point.setField accepts (name, value) for boolean', () => {
31
- const point = new Point('test');
32
- point.setField('active', true);
33
- const lp = point.toLineProtocol();
36
+ test('setBooleanField writes a boolean field', () => {
37
+ const lp = new Point('test').setBooleanField('active', true).toLineProtocol();
34
38
  // Library serializes booleans as T/F in line protocol
35
39
  expect(lp).toContain('active=T');
36
40
  });
37
41
 
38
- test('Point.setTag exists and is a function', () => {
39
- const point = new Point('test');
40
- expect(typeof point.setTag).toBe('function');
42
+ test('setTag writes a tag in the measurement,tag=value section', () => {
43
+ const lp = new Point('test')
44
+ .setTag('location', 'room1')
45
+ .setFloatField('value', 1)
46
+ .toLineProtocol();
47
+ expect(lp).toContain('test,location=room1 ');
41
48
  });
42
49
 
43
- test('Point.setTimestamp exists and is a function', () => {
44
- const point = new Point('test');
45
- expect(typeof point.setTimestamp).toBe('function');
50
+ test('setTimestamp accepts a Date and appends a nanosecond timestamp', () => {
51
+ const lp = new Point('test')
52
+ .setFloatField('value', 1)
53
+ .setTimestamp(new Date(1700000000000))
54
+ .toLineProtocol();
55
+ // 1700000000000 ms -> 1700000000000000000 ns
56
+ expect(lp).toContain('1700000000000000000');
46
57
  });
47
58
 
48
- test('type-specific methods exist alongside generic setField', () => {
59
+ test('the setters used by the node are all functions', () => {
49
60
  const point = new Point('test');
50
- expect(typeof point.setIntegerField).toBe('function');
51
61
  expect(typeof point.setFloatField).toBe('function');
62
+ expect(typeof point.setIntegerField).toBe('function');
52
63
  expect(typeof point.setStringField).toBe('function');
53
64
  expect(typeof point.setBooleanField).toBe('function');
65
+ expect(typeof point.setTag).toBe('function');
66
+ expect(typeof point.setTimestamp).toBe('function');
67
+ expect(typeof point.toLineProtocol).toBe('function');
54
68
  });
55
69
  });
56
-