messages: pure builders for every event the bridge sends

src/messages builds Response, StateReport, DeferredResponse, ErrorResponse, ChangeReport and the scene, doorbell
and simple events from plain values; messageId and times are parameters, so a test compares whole objects.
StateBuilder collects properties checked by the descriptors, AlexaError and AlexaErrors carry the payload fields
of an error type. AlexaStatusMessage and AlexaErrorResponse move to src/compat and are written on the builders.
On the wire, against 1.5.2: a DeferredResponse has no context key, a ChangeReport has no correlationToken, the
namespace of an ErrorResponse follows its type, and a ChangeReport without a changed property is not published:
send() resolves "" and the bridge reports an error that names the endpoint.
143 tests pass (108 before).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 19:28:05 +00:00
parent 2558e21787
commit 46fa06728c
68 changed files with 3410 additions and 1208 deletions

165
test/messages/build.test.js Normal file
View file

@ -0,0 +1,165 @@
"use strict";
// The builders of src/messages/build.ts. Given a messageId and the times, a builder returns the same object every
// time, so each test compares the whole message.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { messages, MessageError } = require("alex2node");
const { UUID_V4 } = require("../helpers/harness.js");
const AT = "2026-09-28T10:00:00.000Z";
const messageId = "0c2bd0a4-6c1c-4b7a-9d3e-5f0a3a1b2c3d";
const powerState = messages.property("Alexa.PowerController", "powerState", "ON", { timeOfSample: AT });
const brightness = messages.property("Alexa.BrightnessController", "brightness", 40, { timeOfSample: AT, uncertaintyInMilliseconds: 500 });
test("response: the event and the state of the endpoint as its context", () => {
assert.deepEqual(messages.response({ endpointId: "lamp-1", correlationToken: "ct", messageId, context: [powerState, brightness] }), {
event: {
header: { namespace: "Alexa", name: "Response", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
payload: {},
},
context: {
properties: [
{ namespace: "Alexa.PowerController", name: "powerState", value: "ON", timeOfSample: AT, uncertaintyInMilliseconds: 0 },
{ namespace: "Alexa.BrightnessController", name: "brightness", value: 40, timeOfSample: AT, uncertaintyInMilliseconds: 500 },
],
},
});
});
test("response: nothing to report is an empty list of properties; an interface names its own response", () => {
const armed = messages.response({
namespace: "Alexa.SecurityPanelController", name: "Arm.Response", endpointId: "panel", correlationToken: "ct", messageId,
payload: { exitDelayInSeconds: 30 },
});
assert.deepEqual(armed, {
event: {
header: { namespace: "Alexa.SecurityPanelController", name: "Arm.Response", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "panel" },
payload: { exitDelayInSeconds: 30 },
},
context: { properties: [] },
});
});
test("stateReport: a Response by the name of StateReport", () => {
assert.deepEqual(messages.stateReport({ endpointId: "lamp-1", correlationToken: "ct", messageId, context: [powerState] }), {
event: {
header: { namespace: "Alexa", name: "StateReport", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
payload: {},
},
context: { properties: [powerState] },
});
});
test("deferredResponse: no context key, the estimate only when given", () => {
const event = {
header: { namespace: "Alexa", name: "DeferredResponse", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
};
assert.deepEqual(messages.deferredResponse({ endpointId: "lamp-1", correlationToken: "ct", messageId }), {
event: { ...event, payload: {} },
});
assert.deepEqual(
messages.deferredResponse({ endpointId: "lamp-1", correlationToken: "ct", messageId, estimatedDeferralInSeconds: 20 }),
{ event: { ...event, payload: { estimatedDeferralInSeconds: 20 } } }
);
});
test("errorResponse: the event alone, the fields of the type next to type and message", () => {
const answer = messages.errorResponse({
endpointId: "lamp-1", correlationToken: "ct", messageId,
type: "VALUE_OUT_OF_RANGE", message: "too high", extra: { validRange: { minimumValue: 1, maximumValue: 10 } },
});
assert.deepEqual(answer, {
event: {
header: { namespace: "Alexa", name: "ErrorResponse", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
payload: { type: "VALUE_OUT_OF_RANGE", message: "too high", validRange: { minimumValue: 1, maximumValue: 10 } },
},
});
});
test("changeReport: what changed in payload.change, the rest in the context, no correlationToken", () => {
const report = messages.changeReport({ endpointId: "lamp-1", messageId, cause: "APP_INTERACTION", changed: [powerState], context: [brightness] });
assert.deepEqual(report, {
event: {
header: { namespace: "Alexa", name: "ChangeReport", messageId, payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
payload: { change: { cause: { type: "APP_INTERACTION" }, properties: [powerState] } },
},
context: { properties: [brightness] },
});
});
test("changeReport: a property that changed is not repeated in the context; instances are told apart", () => {
const lift = (instance, value) => messages.property("Alexa.RangeController", "rangeValue", value, { instance, timeOfSample: AT });
const report = messages.changeReport({
endpointId: "blind", messageId,
changed: [lift("Blind.Lift", 40)],
context: [lift("Blind.Lift", 30), lift("Blind.Tilt", 5)],
});
assert.equal(report.event.payload.change.cause.type, "PHYSICAL_INTERACTION");
assert.deepEqual(report.context.properties, [lift("Blind.Tilt", 5)]);
});
test("changeReport: without a changed property it is refused, the error names the endpoint and what to do", () => {
assert.throws(
() => messages.changeReport({ endpointId: "lamp-1", changed: [], context: [brightness] }),
(err) => {
assert.ok(err instanceof MessageError);
assert.equal(err.endpointId, "lamp-1");
assert.match(err.message, /^lamp-1: a ChangeReport needs at least one property that changed/);
assert.match(err.message, /send no report when nothing changed/);
return true;
}
);
});
test("sceneEvent: ActivationStarted and DeactivationStarted with the cause and the time", () => {
const started = messages.sceneEvent({ endpointId: "scene-1", correlationToken: "ct", messageId, activated: true, timestamp: AT });
assert.deepEqual(started, {
event: {
header: { namespace: "Alexa.SceneController", name: "ActivationStarted", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "scene-1" },
payload: { cause: { type: "VOICE_INTERACTION" }, timestamp: AT },
},
context: {},
});
const stopped = messages.sceneEvent({
endpointId: "scene-1", correlationToken: "ct", messageId, activated: false, cause: "APP_INTERACTION", timestamp: new Date(AT),
});
assert.equal(stopped.event.header.name, "DeactivationStarted");
assert.deepEqual(stopped.event.payload, { cause: { type: "APP_INTERACTION" }, timestamp: AT });
});
test("doorbellPress: an event without a correlationToken and without a context", () => {
assert.deepEqual(messages.doorbellPress({ endpointId: "door", messageId, timestamp: AT }), {
event: {
header: { namespace: "Alexa.DoorbellEventSource", name: "DoorbellPress", messageId, payloadVersion: "3" },
endpoint: { endpointId: "door" },
payload: { cause: { type: "PHYSICAL_INTERACTION" }, timestamp: AT },
},
});
});
test("simpleEvent: the instance in the header, payloadVersion 1.0", () => {
assert.deepEqual(messages.simpleEvent({ endpointId: "button", instance: "Button.Front", id: "Button.SinglePush.1", messageId, timestamp: AT }), {
event: {
header: { namespace: "Alexa.SimpleEventSource", name: "Event", instance: "Button.Front", messageId, payloadVersion: "1.0" },
endpoint: { endpointId: "button" },
payload: { id: "Button.SinglePush.1", timestamp: AT },
},
});
});
test("left out, the messageId is a new version 4 UUID and the time is now", () => {
const before = Date.now();
const first = messages.doorbellPress({ endpointId: "door" });
const second = messages.doorbellPress({ endpointId: "door" });
assert.match(first.event.header.messageId, UUID_V4);
assert.notEqual(first.event.header.messageId, second.event.header.messageId);
const at = Date.parse(first.event.payload.timestamp);
assert.ok(at >= before && at <= Date.now(), first.event.payload.timestamp);
});