"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, PowerLevelController, SecurityPanelController, ThermostatController, 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", }]]); });