node-red-contrib-chronos
Version:
Time-based Node-RED scheduling, repeating, queueing, routing, filtering and manipulating nodes
950 lines (808 loc) • 83.8 kB
JavaScript
/*
* 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 schedulerNode = require("../nodes/scheduler.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 cfgNodeInvalidTZ = {id: "cn1", type: "chronos-config", name: "config", timezone: "invalid"};
const hlpNode = {id: "hn1", type: "helper"};
const credentials = {"cn1": {latitude: "50", longitude: "10"}};
describe("scheduler node", function()
{
before(function(done)
{
helper.startServer(done);
});
after(function(done)
{
helper.stopServer(done);
});
context("node initialization", function()
{
beforeEach(function()
{
sinon.useFakeTimers({toFake: ["Date", "setTimeout", "clearTimeout"]});
});
afterEach(function()
{
helper.unload();
sinon.restore();
});
it("should correctly convert schedule number property", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: "my_property", type: "num", value: "5.6"}}}]}, cfgNode];
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
should.strictEqual(sn1.schedule[0].config.output.property.value, 5.6);
});
it("should correctly convert schedule boolean property", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: "my_property", type: "bool", value: "true"}}}]}, cfgNode];
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
should.strictEqual(sn1.schedule[0].config.output.property.value, true);
});
it("should load ports", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: "my_property", type: "string", value: "test"}}, port: 0}]}, cfgNode];
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
should.strictEqual(sn1.schedule[0].port, 0);
});
it("should load ports (backward compatibility mode)", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: "my_property", type: "string", value: "test"}, port: 0}}]}, cfgNode];
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
should.strictEqual(sn1.schedule[0].port, 0);
});
it("should setup multiple ports", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: "my_property", type: "string", value: "test"}}, port: 0}], multiPort: true, outputs: 1}, cfgNode];
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
sn1.should.have.ownProperty("ports").which.is.an.Array().and.has.length(1);
});
it("should fail due to missing configuration", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler"}];
await helper.load(schedulerNode, flow, {});
const sn1 = helper.getNode("sn1");
sn1.status.should.be.calledOnce();
sn1.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: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1"}, cfgNode];
const invalidCredentials = {"cn1": {latitude: "", longitude: "10"}};
await helper.load([configNode, schedulerNode], flow, invalidCredentials);
const sn1 = helper.getNode("sn1");
sn1.status.should.be.calledOnce();
sn1.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: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1"}, cfgNode];
const invalidCredentials = {"cn1": {latitude: "50", longitude: ""}};
await helper.load([configNode, schedulerNode], flow, invalidCredentials);
const sn1 = helper.getNode("sn1");
sn1.status.should.be.calledOnce();
sn1.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: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1"}, cfgNodeInvalidTZ];
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
sn1.status.should.be.calledOnce();
sn1.error.should.be.calledOnce().and.calledWith("node-red-contrib-chronos/chronos-config:common.error.invalidConfig");
});
function testInvalidSchedule(title, flow, numCalls, exp)
{
it("should fail due to " + title, async function()
{
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
sn1.status.should.be.called();
sn1.error.should.have.callCount(numCalls)
sn1.error.getCall(numCalls-1).calledWith(exp);
});
}
const invalidConfig = "node-red-contrib-chronos/chronos-config:common.error.invalidConfig";
testInvalidSchedule("missing schedule", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: []}, cfgNode], 1, "scheduler.error.noSchedule");
testInvalidSchedule("invalid schedule user time", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "", offset: 0, random: false}, output: {}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule cron table", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "crontab", value: "invalid", offset: 0, random: false}, output: {}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule context variable", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "flow", value: ""}, output: {}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule old config full message", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "fullMsg", value: "["}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule old config full message (no object)", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "fullMsg", value: "true"}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule JSON full message", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "fullMsg", contentType: "json", value: "["}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule JSON full message (no object)", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "fullMsg", contentType: "json", value: "true"}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule JSONata full message", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "fullMsg", contentType: "jsonata", value: "["}}]}, cfgNode], 2, invalidConfig);
testInvalidSchedule("empty schedule property name", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: ""}}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule number property", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: "my_property", type: "num", value: "invalid"}}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule boolean property", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: "my_property", type: "bool", value: "invalid"}}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule JSON property", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: "my_property", type: "json", value: "invalid"}}}]}, cfgNode], 1, invalidConfig);
testInvalidSchedule("invalid schedule JSONata property", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: "my_property", type: "jsonata", value: "["}}}]}, cfgNode], 2, invalidConfig);
testInvalidSchedule("invalid schedule binary buffer property", [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "12:00", offset: 0, random: false}, output: {type: "msg", property: {name: "my_property", type: "bin", value: "invalid"}}}]}, cfgNode], 1, invalidConfig);
});
context("node timers (part 1)", function()
{
let clock = null;
beforeEach(function()
{
clock = sinon.useFakeTimers({toFake: ["Date", "setTimeout", "clearTimeout"]});
sinon.stub(chronos, "getCurrentTime").returns(moment().utc());
sinon.stub(cronosjs.CronosExpression, "parse").callsFake(function(crontab)
{
return cronosjs.CronosExpression.parse.wrappedMethod.apply(this, [crontab, {timezone: "UTC"}]);
});
});
afterEach(function()
{
helper.unload();
sinon.restore();
});
function testTriggerAtTime(title, output, propName, propVal)
{
it("should trigger at specified time: " + title, function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: output}], outputs: 1}, hlpNode, cfgNode];
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property(propName, propVal);
done();
}
catch (e)
{
done(e);
}
});
clock.tick(60000); // advance clock by 1 min
}
catch (e)
{
done(e);
}
});
});
}
testTriggerAtTime("message property", {type: "msg", property: {name: "payload", type: "string", value: "test"}}, "payload", "test");
testTriggerAtTime("message JSONata property", {type: "msg", property: {name: "payload", type: "jsonata", value: "name & \" fired with config \" & config.name & \" (@\" & config.latitude & \"|\" & config.longitude & \")\""}}, "payload", "scheduler fired with config config (@50|10)");
testTriggerAtTime("message timestamp property", {type: "msg", property: {name: "payload", type: "date"}}, "payload", 60000);
testTriggerAtTime("full message (JSON)", {type: "fullMsg", contentType: "json", value: "{\"payload\": \"test\"}"}, "payload", "test");
testTriggerAtTime("full message (JSONata)", {type: "fullMsg", contentType: "jsonata", value: "{\"payload\": name & \" fired with config \" & config.name & \" (@\" & config.latitude & \"|\" & config.longitude & \")\"}"}, "payload", "scheduler fired with config config (@50|10)");
it("should trigger at specified time: to context variable", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "flow", property: {name: "testVariable", type: "string", value: "test"}}}], disabled: true, outputs: 1}, hlpNode, cfgNode];
const ctx = {flow: {}, global: {}};
sinon.spy(clock, "setTimeout");
sinon.stub(helper._RED.util, "parseContextStore").returns({key: "testKey", store: "testStore"});
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
sinon.stub(sn1, "context").returns(ctx);
ctx.flow.set = sinon.spy();
ctx.global.set = sinon.spy();
sn1.receive({payload: true});
clock.tick(60000); // advance clock by 1 min
helper._RED.util.parseContextStore.should.be.calledWith("testVariable");
sn1.context.should.be.calledOnce();
ctx.flow.set.should.be.calledWith("testKey", "test", "testStore");
});
it("should trigger at specified time: to context variable (JSONata expression)", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "global", property: {name: "testVariable", type: "jsonata", value: "name & \" fired with config \" & config.name & \" (@\" & config.latitude & \"|\" & config.longitude & \")\""}}}], outputs: 1}, hlpNode, cfgNode];
const ctx = {flow: {}, global: {}};
sinon.spy(clock, "setTimeout");
sinon.stub(helper._RED.util, "parseContextStore").returns({key: "testKey", store: "testStore"});
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
sinon.stub(sn1, "context").returns(ctx);
ctx.flow.set = sinon.spy();
ctx.global.set = sinon.spy();
sn1.receive({payload: true});
clock.tick(60000); // advance clock by 1 min
helper._RED.util.parseContextStore.should.be.calledWith("testVariable");
sn1.context.should.be.calledOnce();
ctx.global.set.should.be.calledWith("testKey", "scheduler fired with config config (@50|10)", "testStore");
});
it("should trigger at specified time with offset", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "time", value: "00:01", offset: 1, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], outputs: 1}, hlpNode, cfgNode];
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", "test");
done();
}
catch (e)
{
done(e);
}
});
clock.tick(120000); // advance clock by 2 mins
}
catch (e)
{
done(e);
}
});
});
it("should trigger at specified time with random offset", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "time", value: "00:01", offset: 2, random: true}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], outputs: 1}, hlpNode, cfgNode];
sinon.stub(Math, "random").returns(0.5);
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", "test");
done();
}
catch (e)
{
done(e);
}
});
clock.tick(120000); // advance clock by 2 mins
}
catch (e)
{
done(e);
}
});
});
it("should trigger multiple ports", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"], ["hn2"]], schedule: [{trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "port1"}}, port: 0}, {trigger: {type: "time", value: "00:02", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "port2"}}, port: 1}], multiPort: true, outputs: 2}, hlpNode, {id: "hn2", type: "helper"}, cfgNode];
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
const hn2 = helper.getNode("hn2");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", "port1");
}
catch (e)
{
done(e);
}
});
hn2.on("input", function(msg)
{
try
{
msg.should.have.property("payload", "port2");
done();
}
catch (e)
{
done(e);
}
});
clock.tick(60000); // advance clock by 1 min
clock.tick(60000); // advance clock by 1 min
}
catch (e)
{
done(e);
}
});
});
it("should schedule a cron table", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "crontab", value: "0 * * * * *", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], disabled: false, outputs: 1}, hlpNode, cfgNode];
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", "test");
done();
}
catch (e)
{
done(e);
}
});
cronosjs.CronosExpression.parse.should.be.calledWith("0 * * * * *");
clock.tick(60000); // advance clock by 1 min
}
catch (e)
{
done(e);
}
});
});
it("should not schedule a cron job due to no first trigger", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "crontab", value: "*/2 * * * * *", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], disabled: false, outputs: 1}, hlpNode, cfgNode];
cronosjs.CronosExpression.parse.restore();
sinon.stub(cronosjs.CronosExpression, "parse").returns({nextDate: () => null});
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const sn1 = helper.getNode("sn1");
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
done("unexpected message received");
});
clock.tick(2000);
should(sn1.schedule[0].triggerTime).be.undefined();
should(sn1.schedule[0].timer).be.undefined();
done();
}
catch (e)
{
done(e);
}
});
});
it("should not reschedule a cron job due to no second trigger", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "crontab", value: "0 0 0 1 12 * 1970", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], disabled: false, outputs: 1}, hlpNode, cfgNode];
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const sn1 = helper.getNode("sn1");
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", "test");
done();
}
catch (e)
{
done(e);
}
});
clock.tick(28857600000);
should(sn1.schedule[0].triggerTime).be.undefined();
}
catch (e)
{
done(e);
}
});
});
it("should handle time error during setup of timer", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "sun", value: "sunset", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], outputs: 1}, cfgNode];
sinon.stub(chronos, "getTime").throws(function() { return new chronos.TimeError("time error", {type: "sun", value: "sunset"}); });
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
sn1.error.should.be.calledWith("time error", {errorDetails: {type: "sun", value: "sunset"}});
});
it("should handle other error during setup of timer", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "sun", value: "sunset", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], outputs: 1}, cfgNode];
sinon.stub(chronos, "getTime").throws("error", "error message");
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
sn1.error.should.be.calledWith("error message");
});
it("should handle time error during timeout handling (invalid JSONata expression)", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "jsonata", value: "$invalFunc()"}}}], outputs: 1}, cfgNode];
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
clock.tick(60000); // advance clock by 1 min
sn1.error.should.be.calledWith("node-red-contrib-chronos/chronos-config:common.error.evaluationFailed", {errorDetails: {event: 1, expression: "$invalFunc()", code: sinon.match.any, description: sinon.match.any, position: sinon.match.any, token: sinon.match.any}});
});
it("should handle time error during timeout handling (no object for full message)", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "fullMsg", contentType: "jsonata", value: "42"}}], outputs: 1}, cfgNode];
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
clock.tick(60000); // advance clock by 1 min
sn1.error.should.be.calledWith("scheduler.error.notObject", {errorDetails: {event: 1, expression: "42", result: 42}});
});
it("should handle other error during timeout handling", async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", schedule: [{trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], outputs: 1}, cfgNode];
sinon.stub(helper._RED.util, "setMessageProperty").throws("error", "error message");
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
clock.tick(60000); // advance clock by 1 min
sn1.error.should.be.calledWith("error message");
});
function testValidContextVariable(title, ctxVar)
{
it("should trigger from context variable: " + title, async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "flow", value: "testVariable"}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], disabled: true, outputs: 1}, hlpNode, cfgNode];
const ctx = {flow: {}, global: {}};
sinon.spy(clock, "setTimeout");
sinon.stub(helper._RED.util, "parseContextStore").returns({key: "testKey", store: "testStore"});
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
sinon.stub(sn1, "context").returns(ctx);
ctx.flow.get = sinon.fake.returns(ctxVar);
ctx.global.get = sinon.fake.returns(ctxVar);
sn1.receive({payload: true});
helper._RED.util.parseContextStore.should.be.calledWith("testVariable");
sn1.context.should.be.calledOnce();
ctx.flow.get.should.be.calledWith("testKey", "testStore");
clock.setTimeout.should.be.calledWith(sinon.match.any, 60000);
});
}
testValidContextVariable("normal", {type: "time", value: "00:01", offset: 0, random: false});
testValidContextVariable("extended", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", value: "test"}}});
function testInvalidContextVariable(title, ctxVar)
{
it("should fail due to invalid context variable: " + title, async function()
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "flow", value: "testVariable"}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], disabled: true, outputs: 1}, hlpNode, cfgNode];
const ctx = {flow: {}, global: {}};
sinon.spy(clock, "setTimeout");
sinon.stub(helper._RED.util, "parseContextStore").returns({key: "testKey", store: "testStore"});
await helper.load([configNode, schedulerNode], flow, credentials);
const sn1 = helper.getNode("sn1");
sinon.stub(sn1, "context").returns(ctx);
ctx.flow.get = sinon.fake.returns(ctxVar);
ctx.global.get = sinon.fake.returns(ctxVar);
sn1.receive({payload: true});
helper._RED.util.parseContextStore.should.be.calledWith("testVariable");
sn1.context.should.be.calledOnce();
ctx.flow.get.should.be.calledWith("testKey", "testStore");
sn1.error.should.be.calledOnce().and.calledWith("scheduler.error.invalidCtxEvent");
clock.setTimeout.should.not.be.called();
});
}
testInvalidContextVariable("invalid extended variable", "invalid");
testInvalidContextVariable("null extended variable", null);
testInvalidContextVariable("invalid trigger part", {trigger: "invalid"});
testInvalidContextVariable("null trigger part", {trigger: null});
testInvalidContextVariable("invalid output part", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: "invalid"});
testInvalidContextVariable("null output part", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: null});
testInvalidContextVariable("trigger type no string", {type: 5, value: "00:01", offset: 0, random: false});
testInvalidContextVariable("trigger type wrong string", {type: "invalid", value: "00:01", offset: 0, random: false});
testInvalidContextVariable("trigger value wrong type", {type: "time", value: null, offset: 0, random: false});
testInvalidContextVariable("trigger value invalid time", {type: "time", value: "12_14", offset: 0, random: false});
testInvalidContextVariable("trigger value invalid sun position", {type: "sun", value: "invalid", offset: 0, random: false});
testInvalidContextVariable("trigger value invalid moon position", {type: "moon", value: "invalid", offset: 0, random: false});
testInvalidContextVariable("trigger value invalid crontab", {type: "crontab", value: "invalid", offset: 0, random: false});
testInvalidContextVariable("trigger offset wrong type", {type: "time", value: "00:01", offset: "invalid", random: false});
testInvalidContextVariable("trigger offset too small", {type: "time", value: "00:01", offset: -301, random: false});
testInvalidContextVariable("trigger offset too large", {type: "time", value: "00:01", offset: 301, random: false});
testInvalidContextVariable("trigger random wrong type", {type: "time", value: "00:01", offset: 0, random: "invalid"});
testInvalidContextVariable("output type no string", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: 5, property: {name: "payload", value: "test"}}});
testInvalidContextVariable("output type wrong string", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "invalid", property: {name: "payload", value: "test"}}});
testInvalidContextVariable("output property invalid type", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "msg", property: "invalid"}});
testInvalidContextVariable("output property null", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "msg", property: null}});
testInvalidContextVariable("output property name invalid type", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "msg", property: {name: 5, value: "test"}}});
testInvalidContextVariable("output property value missing", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "msg", property: {name: "payload"}}});
testInvalidContextVariable("output property type no date", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}});
testInvalidContextVariable("output full message invalid type", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "fullMsg", value: "invalid"}});
testInvalidContextVariable("output full message with content type", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "fullMsg", contentType: "jsonata", value: {payload: "test"}}});
testInvalidContextVariable("output full message null", {trigger: {type: "time", value: "00:01", offset: 0, random: false}, output: {type: "fullMsg", value: null}});
});
context("node timers (part 2)", function()
{
let clock = null;
beforeEach(function()
{
clock = sinon.useFakeTimers({now: 3600000, toFake: ["Date", "setTimeout", "clearTimeout"]});
sinon.stub(chronos, "getCurrentTime").returns(moment().utc());
});
afterEach(function()
{
helper.unload();
sinon.restore();
});
it("should trigger at specified time on next day", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "time", value: "00:30", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], outputs: 1}, hlpNode, cfgNode];
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", "test");
done();
}
catch (e)
{
done(e);
}
});
clock.tick(84600000); // advance clock by 23h and 30 mins
}
catch (e)
{
done(e);
}
});
});
it("should trigger at specified sun time on next day", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "sun", value: "sunrise", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], outputs: 1}, hlpNode, cfgNode];
sinon.stub(chronos, "getTime")
.onFirstCall()
.returns(moment.utc(1800000))
.onSecondCall()
.returns(moment.utc(88200000));
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", "test");
done();
}
catch (e)
{
done(e);
}
});
clock.tick(84600000); // advance clock by 23h and 30 mins
}
catch (e)
{
done(e);
}
});
});
it("should trigger at specified sun time on next day with offset", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "sun", value: "sunrise", offset: 10, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], outputs: 1}, hlpNode, cfgNode];
sinon.stub(chronos, "getTime")
.onFirstCall()
.returns(moment.utc(1800000))
.onSecondCall()
.returns(moment.utc(88200000));
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", "test");
done();
}
catch (e)
{
done(e);
}
});
clock.tick(85200000); // advance clock by 23h and 40 mins
}
catch (e)
{
done(e);
}
});
});
it("should trigger at specified sun time on next day with random offset", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"]], schedule: [{trigger: {type: "sun", value: "sunrise", offset: 20, random: true}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], outputs: 1}, hlpNode, cfgNode];
sinon.stub(chronos, "getTime")
.onFirstCall()
.returns(moment.utc(1800000))
.onSecondCall()
.returns(moment.utc(88200000));
sinon.stub(Math, "random").returns(0.5);
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", "test");
done();
}
catch (e)
{
done(e);
}
});
clock.tick(85200000); // advance clock by 23h and 40 mins
}
catch (e)
{
done(e);
}
});
});
});
context("next event port", 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 emit message on next event port", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [[], ["hn1"]], schedule: [{trigger: {type: "time", value: "00:30", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}, {trigger: {type: "time", value: "01:30", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], nextEventPort: true, outputs: 2}, hlpNode, cfgNode];
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", 1800000);
msg.should.have.property("events");
msg.events.should.be.an.Array().and.have.length(2);
msg.events[0].should.equal(1800000);
msg.events[1].should.equal(5400000);
done();
}
catch (e)
{
done(e);
}
});
}
catch (e)
{
done(e);
}
});
});
it("should emit message on next event port with unavailable trigger time", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [[], ["hn1"]], schedule: [{trigger: {type: "time", value: "00:30", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}, {trigger: {type: "crontab", value: "* * * * * * 1960"}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], nextEventPort: true, outputs: 2}, hlpNode, cfgNode];
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", 1800000);
msg.should.have.property("events");
msg.events.should.be.an.Array().and.have.length(2);
msg.events[0].should.equal(1800000);
should(msg.events[1]).be.null();
done();
}
catch (e)
{
done(e);
}
});
}
catch (e)
{
done(e);
}
});
});
it("should emit message on next event port after reset", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [[], ["hn1"]], schedule: [{trigger: {type: "time", value: "00:30", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}, {trigger: {type: "time", value: "01:30", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test"}}}], nextEventPort: true, outputs: 2}, hlpNode, cfgNode];
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const sn1 = helper.getNode("sn1");
const hn1 = helper.getNode("hn1");
numReceived = 0;
hn1.on("input", function(msg)
{
try
{
msg.should.have.property("payload", 1800000);
msg.should.have.property("events");
msg.events.should.be.an.Array().and.have.length(2);
msg.events[0].should.equal(1800000);
msg.events[1].should.equal(5400000);
++numReceived;
if (numReceived == 1)
{
sn1.receive({payload: "reload"});
}
else if (numReceived == 2)
{
sn1.receive({payload: ["reload", "reload"]});
}
else if (numReceived == 3)
{
done();
}
}
catch (e)
{
done(e);
}
});
}
catch (e)
{
done(e);
}
});
});
it("should emit message scheduled message and information on next event port", function(done)
{
const flow = [{id: "sn1", type: "chronos-scheduler", name: "scheduler", config: "cn1", wires: [["hn1"], ["hn2"]], schedule: [{trigger: {type: "time", value: "00:30", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test1"}}}, {trigger: {type: "time", value: "01:30", offset: 0, random: false}, output: {type: "msg", property: {name: "payload", type: "string", value: "test2"}}}], nextEventPort: true, outputs: 2}, hlpNode, {id: "hn2", type: "helper"}, cfgNode];
helper.load([configNode, schedulerNode], flow, credentials, function()
{
try
{
const hn1 = helper.getNode("hn1");
const hn2 = helper.getNode("hn2");