Alex2Node/test/transport.test.js
David 29ee1a315e bridge: a discovery request that arrives twice is answered once
A broker that mirrors another delivers every message twice, and the bridge
answered both copies of a discovery request. A request is now remembered
by its messageId for 60 s, as a directive is, and its copy is dropped with
a log line. The messageId is read from the header of the Discover
directive or from the request itself when that is the header alone.
A request without a messageId is answered every time, as before.

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

126 lines
6.5 KiB
JavaScript

"use strict";
// Publishing without a broker: MemoryPublisher under a device, a device on no bridge, and the topics by name.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { Alex2MQTT, MemoryPublisher, PowerController, topics } = require("alex2node");
const { endpoint } = require("./helpers/endpoint.js");
test("the topics of the contract, \"alexaResponce\" spelt as the backend spells it", () => {
assert.equal(topics.discoverReply("root"), "root/discover_r");
assert.equal(topics.response("root", "lamp-1"), "root/lamp-1/alexaResponce");
assert.equal(topics.deferred("root", "lamp-1"), "root/lamp-1/deferredResponse");
assert.equal(topics.changeReport("root"), "root/changeReport");
});
test("MemoryPublisher: what a device sends, by topic, as a subscriber parses it", async () => {
const lamp = endpoint("lamp-1");
const sent = lamp.publisher = new MemoryPublisher();
assert.equal(await lamp.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send(), "root/lamp-1/alexaResponce");
assert.equal(await lamp.getErrorMessage("ct").send(true), "root/lamp-1/deferredResponse");
assert.equal(await lamp.sendSceneResponse("ct", true), "root/lamp-1/alexaResponce");
assert.equal(await lamp.getChangeReport().addPowerControllerProp(PowerController.OFF).send(), "root/changeReport");
assert.deepEqual(sent.published.map(({ topic, message }) => [topic, message.event.header.name]), [
["root/lamp-1/alexaResponce", "Response"],
["root/lamp-1/deferredResponse", "ErrorResponse"],
["root/lamp-1/alexaResponce", "ActivationStarted"],
["root/changeReport", "ChangeReport"],
]);
assert.deepEqual(await sent.publish("root/x", { a: 1, b: undefined }), { ok: true, topic: "root/x" });
assert.deepEqual(sent.published[4].message, { a: 1 });
});
test("MemoryPublisher.failWith: send() resolves \"\" and the error is reported, nothing is kept", async () => {
const lamp = endpoint("lamp-1");
const sent = lamp.publisher = new MemoryPublisher();
sent.failWith = new Error("broker gone");
const reported = [];
lamp.onPublishError = (err) => reported.push(err.message);
assert.equal(await lamp.sendSceneResponse("ct", false), "");
assert.equal(await lamp.getErrorMessage("ct").send(), "");
assert.deepEqual(reported, ["broker gone", "broker gone"]);
assert.deepEqual(sent.published, []);
});
test("a device on no bridge: send() resolves \"\" and the error says to register it", async () => {
const lamp = endpoint("lamp-1");
const reported = [];
lamp.onPublishError = (err) => reported.push(err.message);
assert.equal(await lamp.getStatusMessage("ct", true).send(), "");
assert.deepEqual(reported, [
"nothing was published to root/lamp-1/alexaResponce: the device is on no bridge, register it with addDevice() or registerDevice()",
]);
});
test("unregisterDevice and clearDevices take the publisher from the device", () => {
const bridge = new Alex2MQTT("u", "p", "root");
const lamp = bridge.registerDevice("Lamp", "lamp-1", null);
const plug = bridge.registerDevice("Plug", "plug-1", null);
assert.ok(lamp.publisher && lamp.publisher === plug.publisher, "one publisher for the devices of a bridge");
bridge.unregisterDevice("lamp-1");
assert.equal(lamp.publisher, null);
bridge.clearDevices();
assert.equal(plug.publisher, null);
});
test("a discovery answer that cannot be published: receive() resolves and the \"error\" event says why", async () => {
const gone = new MemoryPublisher();
gone.failWith = new Error("broker gone");
const publishers = {
"fails": gone,
"throws": { publish() { throw new Error("publisher threw"); } },
"rejects": { publish: () => Promise.reject(new Error("publisher rejected")) },
};
const expected = { "fails": "broker gone", "throws": "publisher threw", "rejects": "publisher rejected" };
for (const [how, publisher] of Object.entries(publishers)) {
const bridge = new Alex2MQTT("user", "password", "root", false, { publisher });
bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] }).add(PowerController);
const errors = [];
const discovered = [];
bridge.on("error", (err) => errors.push(err.message));
bridge.on("discover", (n) => discovered.push(n));
await bridge.receive("root/discover", "");
assert.deepEqual(errors, [expected[how]], how);
assert.deepEqual(discovered, [], how);
// Without a listener the error has nowhere to go, and still nothing is thrown or rejected
bridge.removeAllListeners("error");
await bridge.receive("root/discover", "");
}
});
test("a discovery request that arrives twice is answered once, by its messageId", async () => {
const sent = new MemoryPublisher();
const logged = [];
const bridge = new Alex2MQTT("user", "password", "root", false, { publisher: sent, log: (message) => logged.push(message) });
bridge.addDevice({ endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] }).add(PowerController);
const header = (messageId) => ({ namespace: "Alexa.Discovery", name: "Discover", payloadVersion: "3", messageId });
const answers = () => sent.published.filter(({ topic }) => topic === "root/discover_r").length;
// The request as the Discover directive of Alexa, with its header alone, and the header by itself
const requests = [{ directive: { header: header("d-1"), payload: {} } }, { header: header("d-2") }, header("d-3")];
for (const [i, request] of requests.entries()) {
await bridge.receive("root/discover", JSON.stringify(request));
await bridge.receive("root/discover", JSON.stringify(request));
assert.equal(answers(), i + 1, `d-${i + 1}`);
assert.ok(logged.includes(`the discovery request d-${i + 1} is dropped: it arrived before`));
}
// A request without a messageId cannot be told from the next one: each is answered
for (const request of ["", "{}", "not JSON", JSON.stringify({ namespace: "Alexa.Discovery", name: "Discover" })]) {
await bridge.receive("root/discover", request);
await bridge.receive("root/discover", request);
}
assert.equal(answers(), 3 + 8);
// The messageId of a discovery request is not the one of a directive to an endpoint
const called = [];
bridge.getDevice("lamp-1").capability("Alexa.PowerController").on("TurnOn", (ctx) => { called.push(ctx.name); return ctx.respond(); });
await bridge.receive("root/lamp-1/alexaDirective", JSON.stringify({
header: { namespace: "Alexa.PowerController", name: "TurnOn", messageId: "d-1", correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
payload: {},
}));
assert.deepEqual(called, ["TurnOn"]);
});