Alex2Node/test/dispatch/interfaces.test.js
David 3a07c861b1 registry: SceneController events, DoorbellEventSource, SimpleEventSource, TimeHoldController, InventoryLevelSensor, WakeOnLANController
Six descriptors written from their pages replace the last stubs of tiers 1 and 2. A scene answers Activate and
Deactivate through ctx.respond() with ActivationStarted and DeactivationStarted, the time and the cause filled
in. device.raise(descriptor, name, payload) publishes DoorbellPress and the Event of a button on <root>/event
with the endpoint and a new messageId; it throws a MessageError for an interface or instance the device did
not declare, an event that answers a directive, a payload that does not fit and a message over 16000 bytes.
TurnOn of a device with WakeOnLANController is deferred without the warning.

On the wire: a doorbell has no properties object and proactivelyReported on the capability; SimpleEventSource
is version 1.0, InventoryLevelSensor and WakeOnLANController version 3 (1.5.2: 1). A scene declared without
options is announced as before. Alex2MQTT has no topic yet for the WakeUp event.
24 examples of the six pages are saved as fixtures. 267 tests pass, 241 before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 21:01:53 +00:00

250 lines
14 KiB
JavaScript

"use strict";
// The interfaces of test/fixtures/interfaces.js, given the directives of their pages: the handler gets the payload as
// the page prints it, and a payload that does not fit is answered with INVALID_VALUE without the handler.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const {
Alex2MQTT, AlexaErrors, LockController, MemoryPublisher, PowerController, PowerLevelController, SceneController,
SecurityPanelController, ThermostatController, TimeHoldController, WakeOnLANController, ThermostatControllerSchedule, registry,
} = require("alex2node");
const interfaces = require("../fixtures/interfaces.js");
const { doc, examples } = require("../helpers/fixtures.js");
const { directive } = require("../helpers/harness.js");
function bridgeWithout() {
const sent = new MemoryPublisher();
const logged = [];
const bridge = new Alex2MQTT("u", "p", "root", false, { publisher: sent, log: (line) => logged.push(line) });
const device = bridge.addDevice({ endpointId: "device-1", name: "Device", categories: ["OTHER"] });
let count = 0;
// The examples of a page share one message id and one correlation token
const receive = ({ header, payload }) => {
count += 1;
const unique = { ...header, messageId: `message-${count}`, correlationToken: `ct-${count}` };
const message = { header: unique, endpoint: { endpointId: "device-1" }, payload };
return bridge.receive("root/device-1/alexaDirective", JSON.stringify(message));
};
/** What was published, as [the last part of the topic, the namespace and the name of the event, its payload]. */
const answers = () => sent.published.map(({ topic, message: { event } }) => [topic.split("/").pop(), event.header.namespace, event.header.name, event.payload]);
return { device, sent, logged, receive, answers };
}
for (const { namespace, page, declared, refused } of interfaces.filter((entry) => entry.refused)) {
test(`${namespace}: the directives of ${page}.html reach the handler, a payload that does not fit is answered with INVALID_VALUE`, async () => {
const { device, receive, answers } = bridgeWithout();
const seen = [];
const descriptor = registry.get(namespace);
device.add(descriptor, declared[0].options).on("*", (ctx) => {
seen.push([ctx.name, ctx.payload]);
return ctx.respond();
});
const printed = examples(page, ".directive").map((name) => doc(page, name).directive)
.filter((example) => example.header.namespace === namespace);
for (const example of printed) await receive(example);
assert.deepEqual(seen, printed.map(({ header, payload }) => [header.name, payload]));
assert.deepEqual(answers(), printed.map(({ header }) => {
const own = descriptor.responseFor && descriptor.responseFor(header.name);
return ["alexaResponce", own ? own.namespace : "Alexa", own ? own.name : "Response", {}];
}));
for (const [name, payload] of refused) await receive(directive(namespace, name, "device-1", "ct-1", payload));
assert.deepEqual(
answers().slice(printed.length),
refused.map(([, , message]) => ["alexaResponce", "Alexa", "ErrorResponse", { type: "INVALID_VALUE", message }])
);
assert.equal(seen.length, printed.length, "the handler is not called with a payload that does not fit");
});
}
test("Alexa.LockController: a slow lock defers without a warning, and answers as the page does", async () => {
const { device, sent, logged, receive, answers } = bridgeWithout();
const lock = device.add(LockController);
lock.on("Lock", async (ctx) => {
await ctx.defer(15);
return ctx.respond((s) => s.set(lock, "lockState", "LOCKED"));
});
const level = device.add(PowerLevelController);
level.on("SetPowerLevel", async (ctx) => {
await ctx.defer();
return ctx.respond((s) => s.set(level, "powerLevel", ctx.payload.powerLevel));
});
await receive(doc("alexa-lockcontroller", "Lock.directive").directive);
const deferred = doc("alexa-lockcontroller", "Lock-slow.deferred");
const answer = doc("alexa-lockcontroller", "Lock-slow.response");
assert.deepEqual(answers(), [
["alexaResponce", "Alexa", "DeferredResponse", deferred.event.payload],
["deferredResponse", "Alexa", "Response", answer.event.payload],
]);
assert.equal("context" in sent.published[0].message, false);
const [{ namespace, name, value }] = answer.context.properties;
const reported = sent.published[1].message.context.properties.find((property) => property.namespace === namespace);
assert.deepEqual([reported.name, reported.value], [name, value]);
// The page of the PowerLevelController has no DeferredResponse
await receive(doc("alexa-powerlevelcontroller", "SetPowerLevel.directive").directive);
assert.deepEqual(logged.filter((line) => line.includes("DeferredResponse")), [
"warning: Amazon documents no DeferredResponse for Alexa.PowerLevelController, Alexa may not wait for the answer",
]);
});
test("Alexa.ThermostatController: an error of the thermostat goes under its namespace, a temperature out of range under Alexa", async () => {
const { device, receive, answers } = bridgeWithout();
const celsius = (value) => ({ value, scale: "CELSIUS" });
const thrown = [
AlexaErrors.setpointsTooClose("The setpoints are too close.", celsius(2)),
AlexaErrors.of("THERMOSTAT_IS_OFF", "The thermostat is currently off."),
AlexaErrors.temperatureOutOfRange("The requested temperature of -15 is out of range.", { minimumValue: celsius(15), maximumValue: celsius(30) }),
];
let count = 0;
device.add(ThermostatController).on("SetTargetTemperature", () => { throw thrown[count++]; });
device.add(ThermostatControllerSchedule, { supportedFanModes: ["AUTO"], supportsAdaptiveRecovery: false })
.on("SetWeeklySchedule", (ctx) => ctx.error("INSUFFICIENT_SPACE", "the thermostat keeps four entries a day"));
const set = doc("alexa-thermostatcontroller", "SetTargetTemperature-single.directive").directive;
for (let i = 0; i < thrown.length; i += 1) await receive(set);
const printed = ["REQUESTED_SETPOINTS_TOO_CLOSE", "THERMOSTAT_IS_OFF", "TEMPERATURE_VALUE_OUT_OF_RANGE"]
.map((type) => doc("alexa-thermostatcontroller-errorresponse", `${type}.error`).event);
assert.deepEqual(answers(), printed.map(({ header, payload }) => ["alexaResponce", header.namespace, "ErrorResponse", payload]));
assert.deepEqual(printed.map(({ header }) => header.namespace), ["Alexa.ThermostatController", "Alexa.ThermostatController", "Alexa"]);
await receive(directive("Alexa.ThermostatController.Schedule", "SetWeeklySchedule", "device-1", "ct", {}));
assert.deepEqual(answers().at(-1), [
"alexaResponce", "Alexa.ThermostatController.Schedule", "ErrorResponse",
{ type: "INSUFFICIENT_SPACE", message: "the thermostat keeps four entries a day" },
]);
// Declared without adaptive recovery, the thermostat is not sent the directive that switches it
await receive(doc("alexa-thermostatcontroller-schedule", "SetAdaptiveRecovery.directive").directive);
assert.deepEqual(answers().at(-1), ["alexaResponce", "Alexa", "ErrorResponse", {
type: "INVALID_DIRECTIVE",
message: "Alexa.ThermostatController.Schedule as device-1 declares it does not take SetAdaptiveRecovery",
}]);
});
test("Alexa.SecurityPanelController: Arm is answered with Arm.Response, Disarm with a Response when the PIN is the one of the panel", async () => {
const { device, sent, receive, answers } = bridgeWithout();
const page = "alexa-securitypanelcontroller";
const errors = "alexa-securitypanelcontroller-errorresponse";
const open = doc(errors, "BYPASS_NEEDED.error").event.payload;
const bypassed = doc(page, "Arm-bypass.response").event.payload;
let armState = "DISARMED";
const panel = device.add(SecurityPanelController, { supportedAuthorizationTypes: ["FOUR_DIGIT_PIN"], alarms: ["burglaryAlarm"] });
device.state((s) => s.set(panel, "armState", armState).set(panel, "burglaryAlarm", { value: "OK" }));
panel.on("Arm", (ctx) => {
if (armState === "ARMED_AWAY" && ctx.payload.armState !== "ARMED_AWAY") {
throw AlexaErrors.of("AUTHORIZATION_REQUIRED", "You must disarm the system before you can set the requested arm state.");
}
if (!ctx.payload.bypassType) throw AlexaErrors.bypassNeeded(open.message, open.endpointsNeedingBypass);
armState = ctx.payload.armState;
return ctx.respond(undefined, { payload: bypassed });
});
panel.on("Disarm", (ctx) => {
const { authorization } = ctx.payload;
if (!authorization || authorization.value !== "1234") throw AlexaErrors.of("UNAUTHORIZED", "The PIN code is not correct.");
armState = "DISARMED";
return ctx.respond();
});
await receive(doc(page, "Arm.directive").directive);
await receive(doc(page, "Arm-bypass.directive").directive);
await receive(directive(panel.namespace, "Arm", "device-1", "ct", { armState: "ARMED_NIGHT" }));
await receive(directive(panel.namespace, "Disarm", "device-1", "ct", { authorization: { type: "FOUR_DIGIT_PIN", value: "4321" } }));
await receive(directive(panel.namespace, "Disarm", "device-1", "ct", {}));
await receive(doc(page, "Disarm.directive").directive);
const refused = (type) => {
const { header, payload } = doc(errors, `${type}.error`).event;
return ["alexaResponce", header.namespace, header.name, payload];
};
assert.deepEqual(answers(), [
refused("BYPASS_NEEDED"),
["alexaResponce", "Alexa.SecurityPanelController", "Arm.Response", bypassed],
refused("AUTHORIZATION_REQUIRED"),
refused("UNAUTHORIZED"),
refused("UNAUTHORIZED"),
["alexaResponce", "Alexa", "Response", {}],
]);
assert.equal(refused("UNAUTHORIZED")[1], "Alexa.SecurityPanelController");
const states = sent.published.filter(({ message }) => message.context)
.map(({ message }) => message.context.properties.find((property) => property.name === "armState").value);
assert.deepEqual(states, ["ARMED_AWAY", "DISARMED"]);
});
test("Alexa.SecurityPanelController: a payload that is no Arm.Response is not sent, the directive is answered with INTERNAL_ERROR", async () => {
const { device, receive, answers } = bridgeWithout();
device.add(SecurityPanelController).on("Arm", (ctx) => ctx.respond(undefined, { payload: { exitDelayInSeconds: 300 } }));
await receive(doc("alexa-securitypanelcontroller", "Arm.directive").directive);
assert.deepEqual(answers(), [["alexaResponce", "Alexa", "ErrorResponse", {
type: "INTERNAL_ERROR",
message: "payload.exitDelayInSeconds: expected an integer from 0 to 255, got 300",
}]]);
});
test("Alexa.SceneController: respond() answers Activate with ActivationStarted and Deactivate with DeactivationStarted", async () => {
const sent = new MemoryPublisher();
const bridge = new Alex2MQTT("u", "p", "root", false, { publisher: sent, log: () => {} });
const scenes = ["party", "bedtime"].map((endpointId) => bridge.addDevice({
endpointId, name: endpointId, categories: ["SCENE_TRIGGER"], description: `The ${endpointId} scene`,
}));
scenes[0].add(SceneController).on("*", (ctx) => ctx.respond());
scenes[1].add(SceneController, { supportsDeactivation: false }).on("*", (ctx) => ctx.respond(undefined, { payload: { timestamp: "2017-02-03T16:20:50Z" } }));
const before = Date.now();
let count = 0;
for (const [endpointId, name] of [["party", "Activate"], ["party", "Deactivate"], ["bedtime", "Activate"], ["bedtime", "Deactivate"]]) {
count += 1;
const { header, payload } = doc("alexa-scenecontroller", `${name}.directive`).directive;
const message = { header: { ...header, messageId: `message-${count}`, correlationToken: `ct-${count}` }, endpoint: { endpointId }, payload };
await bridge.receive(`root/${endpointId}/alexaDirective`, JSON.stringify(message));
}
const events = sent.published.map(({ message }) => message.event);
assert.deepEqual(sent.published.map(({ topic }) => topic), ["root/party/alexaResponce", "root/party/alexaResponce", "root/bedtime/alexaResponce", "root/bedtime/alexaResponce"]);
assert.deepEqual(events.map(({ header }) => [header.namespace, header.name, header.correlationToken]), [
["Alexa.SceneController", "ActivationStarted", "ct-1"],
["Alexa.SceneController", "DeactivationStarted", "ct-2"],
["Alexa.SceneController", "ActivationStarted", "ct-3"],
["Alexa", "ErrorResponse", "ct-4"],
]);
assert.deepEqual(events[2].payload, doc("alexa-scenecontroller", "ActivationStarted.event").event.payload);
assert.equal(events[3].payload.message, "Alexa.SceneController as bedtime declares it does not take Deactivate");
for (const { payload } of events.slice(0, 2)) {
assert.deepEqual(payload.cause, { type: "VOICE_INTERACTION" });
assert.ok(Date.parse(payload.timestamp) >= before && Date.parse(payload.timestamp) <= Date.now(), payload.timestamp);
}
});
test("Alexa.TimeHoldController: Resume reaches a device declared with allowRemoteResume only", async () => {
const { device, receive, answers } = bridgeWithout();
device.add(TimeHoldController, { allowRemoteResume: false }).on("*", (ctx) => ctx.respond());
await receive(doc("alexa-timeholdcontroller", "Hold.directive").directive);
await receive(doc("alexa-timeholdcontroller", "Resume.directive").directive);
assert.deepEqual(answers(), [
["alexaResponce", "Alexa", "Response", {}],
["alexaResponce", "Alexa", "ErrorResponse", {
type: "INVALID_DIRECTIVE",
message: "Alexa.TimeHoldController as device-1 declares it does not take Resume",
}],
]);
});
test("Alexa.WakeOnLANController: TurnOn is deferred without a warning, TurnOff with the one of the PowerController", async () => {
const { device, logged, receive, answers } = bridgeWithout();
device.add(WakeOnLANController, { macAddresses: ["00-14-22-01-23-45"] });
const power = device.add(PowerController);
power.on("*", async (ctx) => {
await ctx.defer(15);
return ctx.respond((s) => s.set(power, "powerState", ctx.name === "TurnOn" ? "ON" : "OFF"));
});
await receive(doc("alexa-wakeonlancontroller", "TurnOn.directive").directive);
assert.deepEqual(answers(), [
["alexaResponce", "Alexa", "DeferredResponse", doc("alexa-wakeonlancontroller", "TurnOn.deferred").event.payload],
["deferredResponse", "Alexa", "Response", doc("alexa-wakeonlancontroller", "TurnOn.response").event.payload],
]);
const warnings = () => logged.filter((line) => line.includes("DeferredResponse"));
assert.deepEqual(warnings(), []);
await receive(doc("alexa-powercontroller", "TurnOff.directive").directive);
assert.deepEqual(warnings(), ["warning: Amazon documents no DeferredResponse for Alexa.PowerController, Alexa may not wait for the answer"]);
});