UNPKG

node-red-contrib-chronos

Version:

Time-based Node-RED scheduling, repeating, queueing, routing, filtering and manipulating nodes

594 lines (501 loc) 30.4 kB
/* * Copyright (c) 2020 - 2026 Jens-Uwe Rossbach * * This code is licensed under the MIT License. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ const sinon = require("sinon"); const helper = require("node-red-node-test-helper"); const configNode = require("../nodes/config.js"); const cfgNodeInvalidTZ = {id: "cn1", type: "chronos-config", name: "config", timezone: "invalid"}; const filterNode = require("../nodes/filter.js"); const chronos = require("../nodes/common/chronos.js"); const sfUtils = require("../nodes/common/sfutils.js"); require("should-sinon"); const cfgNode = {id: "cn1", type: "chronos-config", name: "config"}; const hlpNode = {id: "hn1", type: "helper"}; const credentials = {"cn1": {latitude: "50", longitude: "10"}}; describe("time filter node", function() { before(function(done) { helper.startServer(done); }); after(function(done) { helper.stopServer(done); }); context("node initialization", function() { afterEach(function() { helper.unload(); sinon.restore(); }); it("should load correctly", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, cfgNode]; await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.should.have.property("name", "filter"); fn1.should.have.property("baseTimeType", "msgIngress"); fn1.should.have.property("baseTime", ""); fn1.should.have.property("conditions").which.is.an.Array(); fn1.should.have.property("evaluation", ""); fn1.should.have.property("evaluationType", "or"); }); it("should be backward compatible: base time", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}], allMustMatch: false, annotateOnly: false}, cfgNode]; await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.should.have.property("name", "filter"); fn1.should.have.property("baseTimeType", "msgIngress"); fn1.should.have.property("conditions").which.is.an.Array(); fn1.should.have.property("evaluation", ""); fn1.should.have.property("evaluationType", "or"); }); it("should be backward compatible: evaluation (annotation)", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}], allMustMatch: false, annotateOnly: true}, cfgNode]; await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.should.have.property("name", "filter"); fn1.should.have.property("baseTimeType", "msgIngress"); fn1.should.have.property("conditions").which.is.an.Array(); fn1.should.have.property("evaluation", ""); fn1.should.have.property("evaluationType", "annotation"); }); it("should be backward compatible: evaluation (logical AMD)", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}], allMustMatch: true, annotateOnly: false}, cfgNode]; await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.should.have.property("name", "filter"); fn1.should.have.property("baseTimeType", "msgIngress"); fn1.should.have.property("conditions").which.is.an.Array(); fn1.should.have.property("evaluation", ""); fn1.should.have.property("evaluationType", "and"); }); it("should fail due to missing configuration", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter"}]; await helper.load(filterNode, flow, {}); const fn1 = helper.getNode("fn1"); fn1.status.should.be.calledOnce(); fn1.error.should.be.calledOnce().and.calledWith("node-red-contrib-chronos/chronos-config:common.error.noConfig"); }); it("should fail due to invalid latitude", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1"}, cfgNode]; const invalidCredentials = {"cn1": {latitude: "", longitude: "10"}}; await helper.load([configNode, filterNode], flow, invalidCredentials); const fn1 = helper.getNode("fn1"); fn1.status.should.be.calledOnce(); fn1.error.should.be.calledOnce().and.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"); }); it("should fail due to invalid longitude", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1"}, cfgNode]; const invalidCredentials = {"cn1": {latitude: "50", longitude: ""}}; await helper.load([configNode, filterNode], flow, invalidCredentials); const fn1 = helper.getNode("fn1"); fn1.status.should.be.calledOnce(); fn1.error.should.be.calledOnce().and.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"); }); it("should fail due to invalid time zone", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1"}, cfgNodeInvalidTZ]; await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.status.should.be.calledOnce(); fn1.error.should.be.calledOnce().and.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"); }); it("should fail due to invalid condition", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, cfgNode]; sinon.stub(sfUtils, "validateCondition").returns(false); await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.status.should.be.called(); fn1.error.should.be.calledOnce(); }); function testInvalidConfig(title, flow, numCalls, exp) { it("should fail due to " + title, async function() { await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.status.should.be.called(); fn1.error.should.have.callCount(numCalls); fn1.error.getCall(numCalls-1).should.be.calledWith(exp); }); } const invalidConfig = "node-red-contrib-chronos/chronos-config:common.error.invalidConfig"; testInvalidConfig("invalid basetime", [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msg", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{}]}, cfgNode], 1, invalidConfig); testInvalidConfig("missing conditions", [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: []}, cfgNode], 1, "node-red-contrib-chronos/chronos-config:common.error.noConditions"); testInvalidConfig("invalid JSONata expression", [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "jsonata", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, cfgNode], 2, invalidConfig); }); context("message filtering", function() { let clock = null; beforeEach(function() { clock = sinon.useFakeTimers({toFake: ["Date"]}); }); afterEach(function() { helper.unload(); sinon.restore(); }); function testInvalidBasetime(title, flow) { it("should handle invalid basetime: " + title, async function() { sinon.stub(sfUtils, "getBaseTime").returns(null); await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); sinon.stub(helper._RED.util, "parseContextStore").returns({key: "testKey", store: "testStore"}); fn1.receive({}); fn1.error.should.be.calledOnce().and.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidBaseTime"); }); } testInvalidBasetime("message ingress", [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, cfgNode]); testInvalidBasetime("flow", [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "flow", baseTime: "testVariable", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, cfgNode]); it("should handle time error during condition evaluation", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").throws(function() { return new chronos.TimeError("time error", {type: "sun", value: "sunset"}); }); await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.receive({payload: "test"}); fn1.error.should.be.calledWith("time error", {_msgid: sinon.match.any, payload: "test", errorDetails: {type: "sun", value: "sunset"}}); }); it("should handle time error during condition evaluation (rename errorDetails)", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").throws(function() { return new chronos.TimeError("time error", {type: "sun", value: "sunset"}); }); await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.receive({payload: "test", errorDetails: "details"}); fn1.error.should.be.calledWith("time error", {_msgid: sinon.match.any, payload: "test", _errorDetails: "details", errorDetails: {type: "sun", value: "sunset"}}); }); it("should handle time error during condition evaluation (no details)", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").throws(function() { return new chronos.TimeError("time error", null); }); await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.receive({payload: "test"}); fn1.error.should.be.calledWith("time error", {_msgid: sinon.match.any, payload: "test"}); }); it("should handle other error during condition evaluation", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").throws("error", "error message"); await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.receive({payload: "test"}); fn1.error.should.be.calledWith("error message"); }); it("should add conditions for JSONata expression", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "$condition[0]", evaluationType: "jsonata", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").returns(true); sinon.stub(helper._RED.util, "prepareJSONataExpression").returns({assign: sinon.spy()}); sinon.stub(helper._RED.util, "evaluateJSONataExpression").callsFake(function(expr, msg, cb) { cb(true); }); await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); fn1.receive({payload: "test"}); fn1.expression.assign.should.be.calledWith("condition", [true]); helper._RED.util.evaluateJSONataExpression.should.be.calledWith(sinon.match.any, {_msgid: sinon.match.any, payload: "test"}, sinon.match.any); }); it("should handle expression error during JSONata evaluation", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "my JSONata expression", evaluationType: "jsonata", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}, {operator: "after", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, {id: "chn1", type: "catch", name: "", scope: null, uncaught: false, wires: [["hn1"]]}, hlpNode, cfgNode]; sinon.stub(helper._RED.util, "prepareJSONataExpression").returns({assign: sinon.spy()}); sinon.stub(helper._RED.util, "evaluateJSONataExpression").callsFake(function(expr, msg, cb) { cb("some error occurred"); }); await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); helper._RED.util.prepareJSONataExpression.should.be.calledWith("my JSONata expression", sinon.match.any); fn1.receive({payload: "test"}); fn1.expression.assign.should.be.calledWith("condition", sinon.match.any); fn1.error.should.be.calledTwice(); fn1.error.getCall(1).should.be.calledWith("node-red-contrib-chronos/chronos-config:common.error.evaluationFailed"); }); it("should handle no boolean error during JSONata evaluation", async function() { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", baseTimeType: "msgIngress", baseTime: "", evaluation: "my JSONata expression", evaluationType: "jsonata", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}, {operator: "after", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, {id: "chn1", type: "catch", name: "", scope: null, uncaught: false, wires: [["hn1"]]}, hlpNode, cfgNode]; sinon.stub(helper._RED.util, "prepareJSONataExpression").returns({assign: sinon.spy()}); sinon.stub(helper._RED.util, "evaluateJSONataExpression").callsFake(function(expr, msg, cb) { cb(null, 42); }); await helper.load([configNode, filterNode], flow, credentials); const fn1 = helper.getNode("fn1"); helper._RED.util.prepareJSONataExpression.should.be.calledWith("my JSONata expression", sinon.match.any); fn1.receive({payload: "test"}); fn1.expression.assign.should.be.calledWith("condition", sinon.match.any); fn1.error.should.be.calledOnce().and.calledWith("node-red-contrib-chronos/chronos-config:common.error.notBoolean"); }); it("should pass through (one match)", function(done) { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", wires: [["hn1"]], baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}, {operator: "after", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, hlpNode, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").onFirstCall().returns(false).onSecondCall().returns(true); helper.load([configNode, filterNode], flow, credentials, function() { try { const fn1 = helper.getNode("fn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); done(); } catch (e) { done(e); } }); fn1.receive({payload: "test"}); } catch (e) { done(e); } }); }); it("should pass through (all match)", function(done) { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", wires: [["hn1"]], baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}, {operator: "after", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, hlpNode, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").returns(true); helper.load([configNode, filterNode], flow, credentials, function() { try { const fn1 = helper.getNode("fn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); done(); } catch (e) { done(e); } }); fn1.receive({payload: "test"}); } catch (e) { done(e); } }); }); it("should pass through (annotation mode)", function(done) { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", wires: [["hn1"]], baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "annotation", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}, {operator: "after", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, hlpNode, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").onFirstCall().returns(false).onSecondCall().returns(true); helper.load([configNode, filterNode], flow, credentials, function() { try { const fn1 = helper.getNode("fn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); msg.should.have.property("evaluation", [false, true]); done(); } catch (e) { done(e); } }); fn1.receive({payload: "test"}); } catch (e) { done(e); } }); }); it("should pass through (annotation mode with existing evaluation property)", function(done) { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", wires: [["hn1"]], baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "annotation", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}, {operator: "after", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, hlpNode, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").onFirstCall().returns(false).onSecondCall().returns(true); helper.load([configNode, filterNode], flow, credentials, function() { try { const fn1 = helper.getNode("fn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); msg.should.have.property("evaluation", [false, true]); msg.should.have.property("_evaluation", "result"); done(); } catch (e) { done(e); } }); fn1.receive({payload: "test", evaluation: "result"}); } catch (e) { done(e); } }); }); it("should filter out (no match)", function(done) { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", wires: [["hn1"]], baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "or", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}, {operator: "after", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, hlpNode, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").returns(false); helper.load([configNode, filterNode], flow, credentials, function() { try { const fn1 = helper.getNode("fn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { done("unexpected message received"); }); fn1.receive({payload: "test"}); done(); } catch (e) { done(e); } }); }); it("should filter out (one match, but all must match)", function(done) { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", wires: [["hn1"]], baseTimeType: "msgIngress", baseTime: "", evaluation: "", evaluationType: "and", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}, {operator: "after", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, hlpNode, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").onFirstCall().returns(false).onSecondCall().returns(true); helper.load([configNode, filterNode], flow, credentials, function() { try { const fn1 = helper.getNode("fn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { done("unexpected message received"); }); fn1.should.have.property("evaluationType", "and"); fn1.receive({payload: "test"}); done(); } catch (e) { done(e); } }); }); it("should pass through (JSONata match)", function(done) { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", wires: [["hn1"]], baseTimeType: "msgIngress", baseTime: "", evaluation: "$condition[0] or $condition[1]", evaluationType: "jsonata", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}, {operator: "after", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, hlpNode, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").onFirstCall().returns(false).onSecondCall().returns(true); helper.load([configNode, filterNode], flow, credentials, function() { try { const fn1 = helper.getNode("fn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); done(); } catch (e) { done(e); } }); fn1.should.have.property("evaluationType", "jsonata"); fn1.should.have.property("evaluation", "$condition[0] or $condition[1]"); fn1.receive({payload: "test"}); } catch (e) { done(e); } }); }); it("should filter out (no JSONata match)", function(done) { const flow = [{id: "fn1", type: "chronos-filter", name: "filter", config: "cn1", wires: [["hn1"]], baseTimeType: "msgIngress", baseTime: "", evaluation: "$condition[0] and $condition[1]", evaluationType: "jsonata", conditions: [{operator: "before", operands: {type: "time", value: "10:00", offset: 0, random: false}}, {operator: "after", operands: {type: "time", value: "10:00", offset: 0, random: false}}]}, hlpNode, cfgNode]; sinon.stub(sfUtils, "evaluateCondition").onFirstCall().returns(false).onSecondCall().returns(true); helper.load([configNode, filterNode], flow, credentials, function() { try { const fn1 = helper.getNode("fn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { done("unexpected message received"); } catch (e) { done(e); } }); fn1.should.have.property("evaluationType", "jsonata"); fn1.should.have.property("evaluation", "$condition[0] and $condition[1]"); fn1.receive({payload: "test"}); done(); } catch (e) { done(e); } }); }); }); });