Alex2Node/test/compat/messages.test.js
David 6789a1a077 bridge: publish through a Publisher; devices no longer hold the broker client
src/transport.ts has the Publisher interface, MqttPublisher (the client the bridge has at the time) and
MemoryPublisher (tests without a broker); src/topics.ts names the four topics the library publishes to.
Device, AlexaStatusMessage, AlexaErrorResponse and sendSceneResponse shared three copies of the
publish-and-report code: they now call one send() that resolves the topic or "" and never rejects.
registerDevice() and addDevice() work before connect(); a send() without a connection resolves "" and the
"error" event says to call connect(). unregisterDevice() and clearDevices() take the publisher from the device.
Device.setMqttClient() is gone, the first constructor argument of Device is ignored, and the message classes
take a Publisher where they took the client. Tests: 108 -> 113.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 19:34:39 +00:00

187 lines
9.5 KiB
JavaScript

"use strict";
// AlexaStatusMessage and AlexaErrorResponse, the message classes of 1.x: the JSON of 1.5.2, and the four places
// where 2.0 differs from it on the wire (DESIGN.md, 6.1).
const { test } = require("node:test");
const assert = require("node:assert/strict");
const {
Alex2MQTT, AlexaErrorResponse, AlexaErrorType, AlexaInterfaceType, AlexaStatusMessage, DisplayCategory, EndpointHealth,
MemoryPublisher, PowerController, TemperatureSensorScale,
} = require("alex2node");
const { setup, sleep, UUID_V4 } = require("../helpers/harness.js");
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
// The message with its id and its times checked and replaced by their names, so that the rest compares as a whole
function settled(message) {
const json = JSON.parse(JSON.stringify(message));
assert.match(json.event.header.messageId, UUID_V4);
json.event.header.messageId = "<id>";
const lists = [json.context?.properties, json.event.payload.change?.properties].filter(Boolean);
for (const property of lists.flat()) {
assert.match(property.timeOfSample, ISO_TIME);
property.timeOfSample = "<time>";
}
return json;
}
const property = (namespace, name, value, more = {}) => (
{ namespace, name, value, timeOfSample: "<time>", uncertaintyInMilliseconds: 0, ...more }
);
test("a Response with every add...Prop helper: the JSON of 1.5.2", () => {
const message = new AlexaStatusMessage("ct", "root", "5020AA", null, true)
.addPowerControllerProp(PowerController.ON)
.addBrightnessControllerProp(40, 500)
.addColorTemperatureControllerProp(2700)
.addToggleControllerProp(PowerController.OFF, "Oven.Light")
.addModeControllerProp("Washer.Cycle", "Cycle.Delicates")
.addThermostatModeProp("HEAT")
.addThermostatControllerProp("targetSetpoint", TemperatureSensorScale.CELSIUS, 21.5)
.addTemperatureSensorProp(TemperatureSensorScale.FAHRENHEIT, 212)
.addHealthProp(EndpointHealth.OK)
.addContextProp({ namespace: "Alexa.Vendor", name: "level", value: 1, timeOfSample: "2026-09-28T10:00:00.000Z", uncertaintyInMilliseconds: 0 });
assert.equal(message.isChangeReport(), false);
assert.deepEqual(settled(message), {
event: {
header: { namespace: "Alexa", name: "Response", payloadVersion: "3", messageId: "<id>", correlationToken: "ct" },
endpoint: { endpointId: "5020AA" },
payload: {},
},
context: {
properties: [
property("Alexa.PowerController", "powerState", "ON"),
property("Alexa.BrightnessController", "brightness", 40, { uncertaintyInMilliseconds: 500 }),
property("Alexa.ColorTemperatureController", "colorTemperatureInKelvin", 2700),
property("Alexa.ToggleController", "toggleState", "OFF", { instance: "Oven.Light" }),
property("Alexa.ModeController", "mode", "Cycle.Delicates", { instance: "Washer.Cycle" }),
property("Alexa.ThermostatController", "thermostatMode", "HEAT"),
property("Alexa.ThermostatController", "targetSetpoint", { value: 21.5, scale: "CELSIUS" }),
property("Alexa.TemperatureSensor", "temperature", { value: 100, scale: "CELSIUS" }),
property("Alexa.EndpointHealth", "connectivity", { value: "OK" }),
property("Alexa.Vendor", "level", 1),
],
},
});
});
test("a StateReport is the default; the messageId stays the same from one toJSON() to the next", () => {
const message = new AlexaStatusMessage("ct", "root", "5020AA", null).addPowerControllerProp(PowerController.OFF);
assert.equal(message.toJSON().event.header.name, "StateReport");
assert.equal(message.toJSON().event.header.messageId, message.toJSON().event.header.messageId);
});
test("wire change: a DeferredResponse has no context key (1.5.2: \"context\": null)", () => {
const message = new AlexaStatusMessage("ct", "root", "5020AA", null, false, true).addEstimatedDeferralTime(20);
assert.deepEqual(settled(message), {
event: {
header: { namespace: "Alexa", name: "DeferredResponse", payloadVersion: "3", messageId: "<id>", correlationToken: "ct" },
endpoint: { endpointId: "5020AA" },
payload: { estimatedDeferralInSeconds: 20 },
},
});
assert.equal("context" in message.toJSON(), false);
});
test("wire change: a ChangeReport has no correlationToken (1.5.2: an empty one)", () => {
const message = new AlexaStatusMessage("", "root", "5020AA", null, false, false, "APP_INTERACTION")
.addPowerControllerProp(PowerController.ON)
.unchanged()
.addBrightnessControllerProp(40)
.changed()
.addHealthProp("OK")
.addContextProp(property("Alexa.Vendor", "level", 1, { timeOfSample: "2026-09-28T10:00:00.000Z" }));
assert.equal(message.isChangeReport(), true);
assert.deepEqual(settled(message), {
event: {
header: { namespace: "Alexa", name: "ChangeReport", payloadVersion: "3", messageId: "<id>" },
endpoint: { endpointId: "5020AA" },
payload: {
change: {
cause: { type: "APP_INTERACTION" },
properties: [
property("Alexa.PowerController", "powerState", "ON"),
property("Alexa.EndpointHealth", "connectivity", { value: "OK" }),
],
},
},
},
context: { properties: [property("Alexa.BrightnessController", "brightness", 40), property("Alexa.Vendor", "level", 1)] },
});
});
test("wire change: the namespace of an ErrorResponse follows the type (1.5.2: always Alexa)", () => {
const header = (type, options) => {
const error = new AlexaErrorResponse("ct", "root", "5020AA", null);
error.setErrorMessage(type, "m", {}, options);
return error.toJSON().event.header.namespace;
};
assert.equal(header(AlexaErrorType.ENDPOINT_UNREACHABLE), "Alexa");
assert.equal(header(AlexaErrorType.THERMOSTAT_IS_OFF), "Alexa.ThermostatController");
assert.equal(header(AlexaErrorType.UNAUTHORIZED), "Alexa.SecurityPanelController");
assert.equal(header(AlexaErrorType.OBSTACLE_DETECTED), "Alexa.Safety");
assert.equal(header(AlexaErrorType.THERMOSTAT_IS_OFF, { namespace: "Alexa" }), "Alexa");
// The idiom of the consumer: a type the enum does not have is sent as it is
assert.equal(header(AlexaErrorType.SOMETHING_NEW || "SOMETHING_NEW"), "Alexa");
});
test("an ErrorResponse with the fields of its type: the JSON of 1.5.2, sent to the topic asked for", async () => {
const sent = new MemoryPublisher();
const error = new AlexaErrorResponse("ct", "root", "5020AA", sent);
error.setErrorMessage(AlexaErrorType.VALUE_OUT_OF_RANGE, "too high", { validRange: { minimumValue: 1, maximumValue: 10 } });
const expected = {
event: {
header: { namespace: "Alexa", name: "ErrorResponse", messageId: "<id>", correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "5020AA" },
payload: { type: "VALUE_OUT_OF_RANGE", message: "too high", validRange: { minimumValue: 1, maximumValue: 10 } },
},
};
assert.deepEqual(settled(error), expected);
assert.equal(await error.send(), "root/5020AA/alexaResponce");
assert.equal(await error.send(true), "root/5020AA/deferredResponse");
assert.deepEqual(sent.published.map(({ topic, message }) => [topic, settled(message)]), [
["root/5020AA/alexaResponce", expected],
["root/5020AA/deferredResponse", expected],
]);
});
test("an ErrorResponse nobody gave a type is an INTERNAL_ERROR", () => {
const { payload } = new AlexaErrorResponse("ct", "root", "5020AA", null).toJSON().event;
assert.equal(payload.type, "INTERNAL_ERROR");
assert.match(payload.message, /did not say which/);
});
test("send: the topic of the message; a publish that fails resolves \"\" and is reported, never rejected", async () => {
const sent = new MemoryPublisher();
const status = (...flags) => new AlexaStatusMessage("ct", "root", "5020AA", sent, ...flags).addPowerControllerProp(PowerController.ON);
assert.equal(await status(true).send(), "root/5020AA/alexaResponce");
assert.equal(await status(true).send(true), "root/5020AA/deferredResponse");
assert.equal(await status(false, false, "PHYSICAL_INTERACTION").send(), "root/changeReport");
assert.deepEqual(sent.published.map(({ message }) => message.event.header.name), ["Response", "Response", "ChangeReport"]);
const gone = new MemoryPublisher();
gone.failWith = new Error("broker gone");
const failing = new AlexaStatusMessage("ct", "root", "5020AA", gone, true);
const reported = [];
failing.onPublishError = (err) => reported.push(err.message);
assert.equal(await failing.send(), "");
assert.deepEqual(reported, ["broker gone"]);
});
test("wire change: a ChangeReport without a changed property is not published, the bridge reports which endpoint", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const errors = [];
bridge.on("error", (err) => errors.push(err));
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
const report = lamp.getChangeReport().unchanged().addBrightnessControllerProp(40);
assert.throws(() => report.toJSON(), /^MessageError: 5020AA: a ChangeReport needs at least one property that changed/);
assert.equal(await report.send(), "");
assert.equal(errors.length, 1);
assert.match(errors[0].message, /^5020AA: a ChangeReport needs at least one property that changed/);
// The report that follows arrives alone: the empty one was not published before it
assert.equal(await lamp.getChangeReport().addPowerControllerProp(PowerController.ON).send(), "root/changeReport");
await sleep(100);
assert.deepEqual(alexa.on("root/changeReport").map((m) => m.event.payload.change.properties.map((p) => p.name)), [["powerState"]]);
});