UNPKG

node-red-contrib-chronos

Version:

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

1,107 lines (961 loc) 104 kB
/* * Copyright (c) 2020 - 2025 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 repeatNode = require("../nodes/repeat.js"); const chronos = require("../nodes/common/chronos.js"); const moment = require("moment"); const cronosjs = require("cronosjs"); 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("repeat until 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 correctly load", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "*/5 * * * * *", customRepetitionType: "flow", customRepetitionValue: "myVar", untilType: "time", untilValue: "12:00", untilOffset: 0, untilRandom: false, preserveCtrlProps: true, ignoreCtrlProps: true, msgIngress: "forward"}, cfgNode]; await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.status.should.be.calledOnce(); rn1.should.have.property("name", "repeat"); rn1.should.have.property("mode", "simple"); rn1.should.have.property("interval", 1); rn1.should.have.property("intervalUnit", "seconds"); rn1.should.have.property("crontab", "*/5 * * * * *"); rn1.should.have.property("customRepetitionType", "flow"); rn1.should.have.property("customRepetitionValue", "myVar"); rn1.should.have.property("untilType", "time"); rn1.should.have.property("untilValue", "12:00"); rn1.should.have.property("untilOffset", 0); rn1.should.have.property("untilRandom", false); rn1.should.have.property("preserveCtrlProps", true); rn1.should.have.property("ignoreCtrlProps", true); rn1.should.have.property("msgIngress", "forward"); }); it("should preserve backward compatibility", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", interval: 1, intervalUnit: "seconds", expression: "myExpression", untilType: "time", untilValue: "12:00", untilOffset: 0, untilRandom: false, preserveCtrlProps: true}, cfgNode]; await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.status.should.be.calledOnce(); rn1.should.have.property("name", "repeat"); rn1.should.have.property("mode", "simple"); rn1.should.have.property("interval", 1); rn1.should.have.property("intervalUnit", "seconds"); rn1.should.have.property("crontab", ""); rn1.should.have.property("customRepetitionType", "jsonata"); rn1.should.have.property("customRepetitionValue", "myExpression"); rn1.should.have.property("untilValue", "12:00"); rn1.should.have.property("untilType", "time"); rn1.should.have.property("untilValue", "12:00"); rn1.should.have.property("untilOffset", 0); rn1.should.have.property("untilRandom", false); rn1.should.have.property("preserveCtrlProps", true); rn1.should.have.property("msgIngress", "forward:forced"); }); it("should fail due to missing configuration", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat"}]; await helper.load(repeatNode, flow, {}); const rn1 = helper.getNode("rn1"); rn1.status.should.be.calledOnce(); rn1.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: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1"}, cfgNode]; const invalidCredentials = {"cn1": {latitude: "", longitude: "10"}}; await helper.load([configNode, repeatNode], flow, invalidCredentials); const rn1 = helper.getNode("rn1"); rn1.status.should.be.calledOnce(); rn1.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: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1"}, cfgNode]; const invalidCredentials = {"cn1": {latitude: "50", longitude: ""}}; await helper.load([configNode, repeatNode], flow, invalidCredentials); const rn1 = helper.getNode("rn1"); rn1.status.should.be.calledOnce(); rn1.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: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1"}, cfgNodeInvalidTZ]; await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.status.should.be.calledOnce(); rn1.error.should.be.calledOnce().and.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"); }); it("should fail due to invalid cron table", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "advanced", interval: 1, intervalUnit: "seconds", crontab: "invalid", untilType: "time", untilValue: "invalid", untilOffset: 0, untilRandom: false, preserveCtrlProps: true}, cfgNode]; await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.status.should.be.calledOnce(); rn1.error.should.be.calledOnce().and.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"); }); it("should fail due to invalid expression", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "custom", interval: 1, intervalUnit: "seconds", crontab: "", customRepetitionType: "jsonata", customRepetitionValue: "invalid[", untilType: "nextMsg", untilValue: "", untilDate: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: false}, cfgNode]; await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.receive({payload: "test"}); rn1.error.getCall(0).should.be.calledWith("Expected \"]\" before end of expression"); rn1.error.getCall(1).should.be.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"); }); it("should fail due to invalid until time", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "time", untilValue: "invalid", untilOffset: 0, untilRandom: false, preserveCtrlProps: true}, cfgNode]; await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.status.should.be.calledTwice(); rn1.error.should.be.calledOnce().and.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"); }); it("should fail due to invalid until expression", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "jsonata", untilValue: "invalid[", untilDate: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: false}, cfgNode]; await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.receive({payload: "test"}); rn1.error.getCall(0).should.be.calledWith("Expected \"]\" before end of expression"); rn1.error.getCall(1).should.be.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"); }); }); context("message ingress behavior", function() { let clock = null; let curTime = 0; beforeEach(function() { curTime = 0; clock = sinon.useFakeTimers({toFake: ["setTimeout", "clearTimeout"]}); sinon.stub(chronos, "getCurrentTime").callsFake(function() { return moment.utc(curTime); }); }); afterEach(function() { helper.unload(); sinon.restore(); }); it("should never forward message on ingress", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "noop", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { done("unexpected message received"); }); rn1.receive({payload: "test"}); done(); } catch (e) { done(e); } }); }); it("should not forward message on ingress due to exceeded until time", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "time", untilValue: "00:00", untilOffset: 0, untilRandom: false, msgIngress: "forward", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { done("unexpected message received"); }); curTime += 60000; rn1.receive({payload: "test"}); done(); } catch (e) { done(e); } }); }); it("should forward message on ingress (until time not reached)", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "time", untilValue: "00:00", untilOffset: 0, untilRandom: false, msgIngress: "forward", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); done(); } catch (e) { done(e); } }); rn1.receive({payload: "test"}); } catch (e) { done(e); } }); }); it("should forward message on ingress (no until time)", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "forward", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); done(); } catch (e) { done(e); } }); rn1.receive({payload: "test"}); } catch (e) { done(e); } }); }); it("should forward message on ingress (forced)", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "time", untilValue: "00:00", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); done(); } catch (e) { done(e); } }); curTime += 60000; rn1.receive({payload: "test"}); } catch (e) { done(e); } }); }); it("should override ingress behavior", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "noop", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); msg.should.have.property("ingress", "forward"); done(); } catch (e) { done(e); } }); rn1.receive({payload: "test", ingress: "forward"}); } catch (e) { done(e); } }); }); it("should override ingress behavior and not preserve", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "noop", preserveCtrlProps: false}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); msg.should.not.have.property("ingress"); done(); } catch (e) { done(e); } }); rn1.receive({payload: "test", ingress: "forward"}); } catch (e) { done(e); } }); }); function testInvalidIngressOverride(title, override) { it("should fall back to configured ingress behavior: " + title, function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "forward", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); msg.should.have.property("ingress", override); done(); } catch (e) { done(e); } }); rn1.receive({payload: "test", ingress: override}); } catch (e) { done(e); } }); }); } testInvalidIngressOverride("null override", null); testInvalidIngressOverride("empty override", ""); testInvalidIngressOverride("invalid override", 42); testInvalidIngressOverride("invalid ingress", "invalid"); }); context("message repeating in simple mode (until next message)", function() { let clock = null; beforeEach(function() { clock = sinon.useFakeTimers({toFake: ["Date", "setTimeout", "clearTimeout"]}); sinon.stub(chronos, "getCurrentTime").returns(moment().utc()); }); afterEach(function() { helper.unload(); sinon.restore(); }); function testRepeatInterval(interval, unit) { it("should repeat message every " + interval + " " + unit, function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], interval: interval, intervalUnit: unit, untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); const intvMs = (unit == "seconds") ? interval * 1000 : (unit == "minutes") ? interval * 60000 : interval * 3600000; helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); } catch (e) { done(e); } }); rn1.receive({payload: "test"}); clock.tick(intvMs); clock.tick(intvMs); clock.tick(intvMs); clock.setTimeout.should.be.calledWith(sinon.match.any, intvMs).and.have.callCount(4); done(); } catch (e) { done(e); } }); }); } testRepeatInterval(4, "seconds"); testRepeatInterval(3, "minutes"); testRepeatInterval(2, "hours"); it("should repeat message until next message is received", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); let count = 0; hn1.on("input", function(msg) { try { count++; msg.should.have.property("payload", "test" + ((count <= 3) ? 1 : 2)); } catch (e) { done(e); } }); rn1.receive({payload: "test1"}); clock.tick(1000); clock.tick(1000); rn1.receive({payload: "test2"}); clock.tick(1000); clock.tick(1000); clock.setTimeout.should.be.calledWith(sinon.match.any, 1000).and.have.callCount(6); done(); } catch (e) { done(e); } }); }); it("should repeat message until stopped", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); let count = 0; hn1.on("input", function(msg) { try { count++; if (count <= 3) { msg.should.have.property("payload", "test"); } else { done("unexpected message received"); } } catch (e) { done(e); } }); rn1.receive({payload: "test"}); clock.tick(1000); clock.tick(1000); clock.setTimeout.should.be.calledWith(sinon.match.any, 1000).and.have.callCount(3); clock.setTimeout.resetHistory() rn1.receive({stop: true}); clock.tick(1000); clock.tick(1000); clock.setTimeout.should.not.be.called(); done(); } catch (e) { done(e); } }); }); it("should override interval", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); let count = 0; hn1.on("input", function(msg) { try { count++; msg.should.have.property("payload", "test" + ((count <= 3) ? 1 : 2)); msg.should.have.property("interval", {value: ((count <= 3) ? 2 : 3), unit: "seconds"}); } catch (e) { done(e); } }); rn1.receive({payload: "test1", interval: {value: 2, unit: "seconds"}}); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.setTimeout.should.be.calledWith(sinon.match.any, 2000).and.have.callCount(3); clock.setTimeout.resetHistory(); rn1.receive({payload: "test2", interval: {value: 3, unit: "seconds"}}); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.setTimeout.should.be.calledWith(sinon.match.any, 3000).and.have.callCount(3); done(); } catch (e) { done(e); } }); }); it("should override interval and not preserve", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: false}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); let count = 0; hn1.on("input", function(msg) { try { count++; msg.should.have.property("payload", "test" + ((count <= 3) ? 1 : 2)); msg.should.not.have.property("interval"); } catch (e) { done(e); } }); rn1.receive({payload: "test1", interval: {value: 2, unit: "seconds"}}); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.setTimeout.should.be.calledWith(sinon.match.any, 2000).and.have.callCount(3); clock.setTimeout.resetHistory(); rn1.receive({payload: "test2", interval: {value: 3, unit: "seconds"}}); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.setTimeout.should.be.calledWith(sinon.match.any, 3000).and.have.callCount(3); done(); } catch (e) { done(e); } }); }); function testInvalidIntervalOverride(title, override) { it("should fall back to configured interval: " + title, function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); msg.should.have.property("interval", override); } catch (e) { done(e); } }); rn1.receive({payload: "test", interval: override}); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.setTimeout.should.be.calledWith(sinon.match.any, 1000).and.have.callCount(5); done(); } catch (e) { done(e); } }); }); } testInvalidIntervalOverride("invalid override", "invalid"); testInvalidIntervalOverride("null override", null); testInvalidIntervalOverride("empty override", {}); testInvalidIntervalOverride("no value", {unit: "hours"}); testInvalidIntervalOverride("no uniot", {value: 4}); testInvalidIntervalOverride("invalid unit", {value: 4, unit: "invalid"}); testInvalidIntervalOverride("invalid value", {value: "invalid", unit: "seconds"}); testInvalidIntervalOverride("value too small for seconds", {value: 0, unit: "seconds"}); testInvalidIntervalOverride("value too large for seconds", {value: 60, unit: "seconds"}); testInvalidIntervalOverride("value too small for minutes", {value: 0, unit: "minutes"}); testInvalidIntervalOverride("value too large for minutes", {value: 60, unit: "minutes"}); testInvalidIntervalOverride("value too small for hours", {value: 0, unit: "hours"}); testInvalidIntervalOverride("value too large for hours", {value: 25, unit: "hours"}); it("should ignore overridden interval", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "nextMsg", untilValue: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: true, ignoreCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); msg.should.have.property("interval", 4000); } catch (e) { done(e); } }); rn1.receive({payload: "test", interval: 4000}); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.tick(1000); clock.setTimeout.should.be.calledWith(sinon.match.any, 1000).and.have.callCount(5); done(); } catch (e) { done(e); } }); }); it("should handle time error", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "time", untilValue: "00:00", untilDate: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: false}, cfgNode]; sinon.stub(chronos, "getTime").throws(function() { return new chronos.TimeError("time error", {type: "sun", value: "sunset"}); }); await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.receive({payload: "test"}); rn1.error.should.be.calledWith("time error", {_msgid: sinon.match.any, payload: "test", errorDetails: {type: "sun", value: "sunset"}}); }); it("should handle time error (rename errorDetails)", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "time", untilValue: "00:00", untilDate: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: false}, cfgNode]; sinon.stub(chronos, "getTime").throws(function() { return new chronos.TimeError("time error", {type: "sun", value: "sunset"}); }); await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.receive({payload: "test", errorDetails: "details"}); rn1.error.should.be.calledWith("time error", {_msgid: sinon.match.any, payload: "test", _errorDetails: "details", errorDetails: {type: "sun", value: "sunset"}}); }); it("should handle time error (no details)", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "time", untilValue: "00:00", untilDate: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: false}, cfgNode]; sinon.stub(chronos, "getTime").throws(function() { return new chronos.TimeError("time error", null); }); await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.receive({payload: "test"}); rn1.error.should.be.calledWith("time error", {_msgid: sinon.match.any, payload: "test"}); }); it("should handle other error", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "time", untilValue: "00:00", untilDate: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: false}, cfgNode]; sinon.stub(chronos, "getTime").throws("error", "error message"); await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.receive({payload: "test"}); rn1.error.should.be.calledWith("error message"); }); it("should handle time error caused by ending time (invalid JSONata expression)", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "jsonata", untilValue: "$invalFunc()", untilDate: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: false}, cfgNode]; await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.receive({payload: "test"}); rn1.error.should.be.calledWith("node-red-contrib-chronos/chronos-config:common.error.evaluationFailed", {payload: "test", _msgid: sinon.match.any, errorDetails: {expression: "$invalFunc()", code: sinon.match.any, description: sinon.match.any, position: sinon.match.any, token: sinon.match.any}}); }); it("should handle time error caused by ending time (not boolean)", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "jsonata", untilValue: "42", untilDate: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: false}, cfgNode]; await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); rn1.receive({payload: "test"}); rn1.error.should.be.calledWith("node-red-contrib-chronos/chronos-config:common.error.notBoolean", {payload: "test", _msgid: sinon.match.any, errorDetails: {expression: "42", result: 42}}); }); it("should handle time error caused by invalid ending time from context variable", async function() { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "flow", untilValue: "invalidVariable", untilDate: "", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: false}, cfgNode]; const ctx = {flow: {}, global: {}}; sinon.stub(helper._RED.util, "parseContextStore").returns({key: "testKey", store: "testStore"}); await helper.load([configNode, repeatNode], flow, credentials); const rn1 = helper.getNode("rn1"); sinon.stub(rn1, "context").returns(ctx); ctx.flow.get = sinon.fake.returns("42"); ctx.global.get = sinon.fake.returns(undefined); rn1.receive({payload: "test"}); rn1.error.should.be.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidContext", {payload: "test", _msgid: sinon.match.any, errorDetails: {store: "testStore", key: "testKey", value: "42"}}); }); }); context("message repeating in simple mode (until specifc time)", function() { let clock = null; let curTime = 0; beforeEach(function() { curTime = 0; clock = sinon.useFakeTimers({toFake: ["setTimeout", "clearTimeout"]}); sinon.stub(chronos, "getCurrentTime").callsFake(function() { return moment.utc(curTime); }); sinon.stub(chronos, "getUserDate").callsFake(function(RED, node, value) { return moment.utc(value, "YYYY-MM-DD"); }); }); afterEach(function() { helper.unload(); sinon.restore(); }); it("should repeat message until ending time is reached", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "seconds", crontab: "", untilType: "time", untilValue: "00:00:03", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); } catch (e) { done(e); } }); rn1.receive({payload: "test"}); curTime += 1000; clock.tick(1000); curTime += 1000; clock.tick(1000); curTime += 1000; clock.tick(1000); curTime += 1000; clock.tick(1000); curTime += 1000; clock.tick(1000); curTime += 1000; clock.tick(1000); clock.setTimeout.should.be.calledWith(sinon.match.any, 1000).and.have.callCount(3); done(); } catch (e) { done(e); } }); }); it("should repeat message until ending time at specific date is reached", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 24, intervalUnit: "hours", crontab: "", untilType: "time", untilValue: "00:05:00", untilDate: "2000-01-03", untilOffset: 0, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); curTime = 946684800000; // 2000-01-01 00:00:00 helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); } catch (e) { done(e); } }); rn1.receive({payload: "test"}); curTime += 86400000; clock.tick(86400000); curTime += 86400000; clock.tick(86400000); curTime += 86400000; clock.tick(86400000); curTime += 86400000; clock.tick(86400000); curTime += 86400000; clock.tick(86400000); curTime += 86400000; clock.tick(86400000); clock.setTimeout.should.be.calledWith(sinon.match.any, 86400000).and.have.callCount(2); done(); } catch (e) { done(e); } }); }); it("should repeat message until ending time with offset is reached", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "minutes", crontab: "", untilType: "time", untilValue: "00:02:00", untilOffset: 1, untilRandom: false, msgIngress: "forward:forced", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.getNode("hn1"); hn1.on("input", function(msg) { try { msg.should.have.property("payload", "test"); } catch (e) { done(e); } }); rn1.receive({payload: "test"}); curTime += 60000; clock.tick(60000); curTime += 60000; clock.tick(60000); curTime += 60000; clock.tick(60000); curTime += 60000; clock.tick(60000); curTime += 60000; clock.tick(60000); curTime += 60000; clock.tick(60000); clock.setTimeout.should.be.calledWith(sinon.match.any, 60000).and.have.callCount(3); done(); } catch (e) { done(e); } }); }); it("should repeat message until ending time with random offset is reached", function(done) { const flow = [{id: "rn1", type: "chronos-repeat", name: "repeat", config: "cn1", wires: [["hn1"]], mode: "simple", interval: 1, intervalUnit: "minutes", crontab: "", untilType: "time", untilValue: "00:02:00", untilOffset: 2, untilRandom: true, msgIngress: "forward:forced", preserveCtrlProps: true}, hlpNode, cfgNode]; sinon.spy(clock, "setTimeout"); sinon.stub(Math, "random").returns(0.5); helper.load([configNode, repeatNode], flow, credentials, function() { try { const rn1 = helper.getNode("rn1"); const hn1 = helper.