@essenius/node-red-openhab4
Version:
OpenHAB 4 integration nodes for Node-RED
310 lines (259 loc) • 14.5 kB
JavaScript
// Copyright 2025 Rik Essenius
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software distributed under the License is
// distributed on an "AS IS" BASIS WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and limitations under the License.
;
const { expect } = require("chai");
const sinon = require("sinon");
const proxyquire = require("proxyquire");
describe("controllerLogic.setupControllerNode", function () {
let setupControllerNode, controlItemStub, startEventSourceStub, testIfLiveStub, OpenhabConnectionStub, node, config;
function setupOpenhabConnectionWithGetItems(getItemsStub) {
OpenhabConnectionStub.returns({
getItems: getItemsStub,
startEventSource: startEventSourceStub,
controlItem: controlItemStub,
close: sinon.stub(),
testIfLive: testIfLiveStub
});
}
beforeEach(() => {
controlItemStub = sinon.stub().callsFake(async (_itemname, topic, _payload) => {
if (topic === "error") {
throw new Error("Simulated error");
}
return "ok";
});
startEventSourceStub = sinon.stub();
testIfLiveStub = sinon.stub().resolves(true);
OpenhabConnectionStub = sinon.stub();
// Stub OpenhabConnection so no real connection is made
setupOpenhabConnectionWithGetItems(sinon.stub().resolves([{ name: "Item1", state: "ON" }]));
({ setupControllerNode } = proxyquire("../lib/controllerLogic", {
"./openhabConnection": { OpenhabConnection: OpenhabConnectionStub },
}));
node = {
_closed: false,
error: sinon.spy(),
warn: sinon.spy(),
log: sinon.spy(),
setStatus: sinon.spy(),
emit: sinon.spy(),
on: sinon.stub(),
};
config = {
host: "localhost",
protocol: "http",
port: 8080
};
});
it("should log connection info and create OpenhabConnection", function () {
setupControllerNode(node, config);
expect(node.log.calledWithMatch("OpenHAB Controller connecting to: http://localhost:8080")).to.be.true;
expect(OpenhabConnectionStub.calledOnce).to.be.true;
});
it("should add getConfig method to node", function () {
setupControllerNode(node, config);
expect(node.getConfig).to.be.a("function");
expect(node.getConfig()).to.equal(config);
});
it("should register a close handler that cleans up timers and connection", function () {
setupControllerNode(node, config);
// call the close handler (the first call to node.on in setupControllerNode, and args[1] is the close handler)
node.on.getCall(0).args[1](false, () => { });
// Should call log, emit, and set connection to null
expect(node.log.calledWithMatch("CONTROLLER CLOSE EVENT")).to.be.true;
expect(node.emit.calledWithMatch(sinon.match.string, sinon.match.any)).to.be.true;
});
async function runEventSourceOnOpenCallback() {
// Manually trigger the onOpen callback
const startEventSourceArgs = startEventSourceStub.getCall(0).args[0];
if (startEventSourceArgs && typeof startEventSourceArgs.onOpen === "function") {
await startEventSourceArgs.onOpen();
}
// Wait for getStateOfItems to finish
await new Promise(resolve => setImmediate(resolve));
}
it("should start EventSource and get state of items when openHAB is ready (happy path)", async function () {
setupControllerNode(node, config, { maxAttempts: 1, interval: 0 });
// Wait for async code to run
await new Promise(resolve => setImmediate(resolve));
// get the items via the onOpen callback
await runEventSourceOnOpenCallback();
expect(startEventSourceStub.calledOnce, "EventSource should be started").to.be.true;
const emitCalls = node.emit.getCalls();
const stateEventCall = emitCalls.find(call => call.args[0] === "Item1/StateEvent");
expect(stateEventCall, "Should emit state event for Item1").to.exist;
expect(stateEventCall.args[1]).to.deep.include({ type: "ItemStateEvent", state: "ON" });
// call the control function to simulate a control command
let result = await node.control("Item1", "command", "OFF");
expect(result).to.equal("ok", "Result of control command should be 'ok'");
// now a command that should raise an error
try {
result = await node.control("Item1", "error", "OFF");
expect.fail("Expected control command to throw an error");
} catch (error) {
expect(error.message).to.equal("Simulated error", "Error message should match simulated error");
}
});
it("should handle error in getItems appropriately", async function () {
const setTimeoutStub = sinon.stub(global, "setTimeout").returns(42);
const clearTimeoutStub = sinon.stub(global, "clearTimeout");
try {
const errorGetItemsStub = sinon.stub().rejects(new Error("Failed to fetch items"));
setupOpenhabConnectionWithGetItems(errorGetItemsStub);
setupControllerNode(node, config, { maxAttempts: 1, interval: 0 });
// Wait for async code to run
await new Promise(resolve => setImmediate(resolve));
await runEventSourceOnOpenCallback();
/// call the close handler
expect(node.on.calledOnce).to.be.true;
expect(node.on.getCall(0).args[0]).to.equal("close");
node.on.getCall(0).args[1](false, () => { });
expect(errorGetItemsStub.calledOnce, "GetItems called").to.be.true;
expect(node.error.calledWithMatch("Failed to fetch items"), "node error").to.be.true;
expect(node.emit.calledWithMatch("ConnectionError"), "ConnectionError emitted").to.be.true;
expect(node.emit.calledWithMatch("ConnectionStatus"), "ConnectionStatus emitted").to.be.true;
expect(setTimeoutStub.calledOnce).to.be.true; // retry should be scheduled
expect(clearTimeoutStub.calledOnce).to.be.true; // retry timer should be cleared (in close handler)
} finally {
setTimeoutStub.restore();
clearTimeoutStub.restore();
}
});
async function runEventSourceOnErrorCallback(status, message, shortMessage) {
// Manually trigger the onError callback
const startEventSourceArgs = startEventSourceStub.getCall(0).args[0];
if (startEventSourceArgs && typeof startEventSourceArgs.onError === "function") {
await startEventSourceArgs.onError(status, message, shortMessage);
}
// Wait for any async error handling to finish
await new Promise(resolve => setImmediate(resolve));
}
it("should emit a connection error on Error event if short message is not empty", async function () {
setupControllerNode(node, config, { maxAttempts: 1, interval: 0 });
await new Promise(resolve => setImmediate(resolve));
node.emit.resetHistory();
await runEventSourceOnErrorCallback(503, "The service is unavailable right now", "Service Unavailable");
expect(node.warn.calledWithMatch("Error 503: The service is unavailable right now"), "Warning should be logged").to.be.true;
expect(node.emit.calledWith("ConnectionError", "Service Unavailable"), "ConnectionError should be emitted").to.be.true;
});
it("should not emit a connection error on Error event if short message is empty", async function () {
setupControllerNode(node, config, { maxAttempts: 1, interval: 0 });
await new Promise(resolve => setImmediate(resolve));
node.emit.resetHistory();
await runEventSourceOnErrorCallback(503, "The service is unavailable right now", "");
expect(node.warn.calledWithMatch("Error 503: The service is unavailable right now"), "Warning should be logged").to.be.true;
expect(node.emit.notCalled, "ConnectionError should not be emitted").to.be.true;
});
it("should use SSE error on Error event if short message is null or undefined", async function () {
setupControllerNode(node, config, { maxAttempts: 1, interval: 0 });
await new Promise(resolve => setImmediate(resolve));
node.emit.resetHistory();
await runEventSourceOnErrorCallback(503, "The service is unavailable right now", undefined);
expect(node.warn.calledWithMatch("Error 503: The service is unavailable right now"), "Warning should be logged").to.be.true;
expect(node.emit.calledWith("ConnectionError", "error 503"), "ConnectionError should be emitted").to.be.true;
});
describe("Message handling tests", function () {
function simulateEventSourceMessage(message) {
const args = startEventSourceStub.getCall(0).args[0];
args.onMessage(message);
}
beforeEach(async function () {
setupControllerNode(node, config, { maxAttempts: 1, interval: 0 });
await new Promise(resolve => setImmediate(resolve));
node.emit.resetHistory();
this.startEventSourceArgs = startEventSourceStub.getCall(0).args[0];
});
this.afterEach(function () {
// call the close handler
if (node.on.callCount > 0) {
expect(node.on.getCall(0).args[0]).to.equal("close", "First on handler should be 'close'");
node.on.getCall(0).args[1](false, () => { });
}
});
it("should emit correct events for a valid ItemStateEvent", function () {
const message = {
data: JSON.stringify({
type: "ItemStateEvent",
topic: "openhab/items/Item1/StateEvent",
payload: JSON.stringify({ value: 'ON' })
})
};
simulateEventSourceMessage(message);
expect(node.emit.calledWith("RawEvent", sinon.match.has("topic", "openhab/items/Item1/StateEvent")), "RawEvent emitted").to.be.true;
expect(node.emit.calledWith("Item1/RawEvent", sinon.match.has("type", "ItemStateEvent")), "Item1/RawEvent emitted").to.be.true;
expect(node.emit.calledWith("Item1/StateEvent", { type: 'ItemStateEvent', state: 'ON' }), "Item1/StateEvent emitted").to.be.true;
});
it("should not emit empty message", function () {
simulateEventSourceMessage(JSON.stringify({}));
expect(node.emit.callCount).to.equal(0);
});
it("should raise an error and emit an error for invalid JSON", function () {
simulateEventSourceMessage({ data: "This is not a valid JSON string" });
expect(node.error.calledWithMatch("Unexpected token 'T'"), "Unexpected token in error").to.be.true;
expect(node.emit.calledWithMatch(
"ConnectionError",
sinon.match((val) => val.startsWith("Unexpected token"))
), "ConnectionError emitted").to.be.true;
});
[
{
desc: "numeric payloads",
payload: 25,
expectedPayload: 25,
},
{
desc: "numeric payloads in string",
payload: "25",
expectedPayload: 25,
},
{
desc: "non-object payloads",
payload: "foo",
expectedPayload: "foo",
expectedWarning: "Could not parse string payload as JSON: foo"
}
].forEach(({ desc, payload, expectedPayload, expectedWarning }) => {
it(`should emit RawEvents for ${desc}`, function () {
const message = {
data: JSON.stringify({
type: "RawEvent",
topic: "openhab/items/Item1/StateEvent",
payload: payload
})
};
simulateEventSourceMessage(message);
expect(node.emit.callCount).to.equal(2, `Two events should be emitted for ${desc} (RawEvent and Item1/RawEvent)`);
expect(node.emit.calledWith("Item1/RawEvent", sinon.match.has("payload", expectedPayload)), `Item1/RawEvent for ${desc}`).to.be.true;
expect(node.emit.calledWith("RawEvent", sinon.match.has("payload", expectedPayload)), `RawEvent for ${desc}`).to.be.true;
if (expectedWarning) {
expect(node.warn.calledWithMatch(expectedWarning), `Warning logged for ${desc}`).to.be.true;
} else {
expect(node.warn.callCount).to.equal(0, `No warning logged for ${desc}`);
}
expect(node.error.callCount).to.equal(0, `No error should be logged for ${desc}`);
});
});
it("should not emit events after node is closed", function () {
// Simulate node being closed
node._closed = true;
simulateEventSourceMessage({
data: JSON.stringify({
type: "ItemStateEvent",
topic: "openhab/items/Item1/StateEvent",
payload: JSON.stringify({ value: 'ON' })
})
});
expect(node.emit.callCount).to.equal(0, "No events should be emitted after node is closed");
expect(node.warn.callCount).to.equal(0, "No warnings should be logged after node is closed");
expect(node.error.callCount).to.equal(0, "No errors should be logged after node is closed");
});
});
});