// Host tests of dispatch: which device and which capability a directive reaches, what the handler is given, what // a device holds, and what the device answers itself (src/AlexaDevice.cpp). The transport of the devices is a // fake that keeps what would have been published. // pio test -e native #include #include #include #include #include #include #include "AlexaDevice.h" #include "AlexaLog.h" class FakeTransport : public AlexaTransport { public: struct Message { std::string topic; std::string json; }; std::vector sent; AlexaSendResult publish(const char *topic, JsonDocument &doc) override { Message message; message.topic = topic; serializeJson(doc, message.json); sent.push_back(message); return AlexaSendResult::OK; } void timestamp(char *buffer, size_t size) override { snprintf(buffer, size, "2026-09-28T13:05:09Z"); } }; class CapturedLog : public Print { public: std::string text; size_t write(const uint8_t *buffer, size_t size) override { text.append(reinterpret_cast(buffer), size); return size; } }; static void assertLogged(const CapturedLog &log, const char *expected) { TEST_ASSERT_NOT_NULL_MESSAGE(strstr(log.text.c_str(), expected), log.text.c_str()); } // What the handler of the tests was given, copied: the texts of a directive end with the handler struct Seen { int calls; AlexaDevice *device; AlexaCapability *capability; AlexaInterfaceType type; std::string ns; std::string name; std::string instance; std::string correlationToken; int rangeValue; bool reportState; }; static Seen seen; static bool handlerAnswers; static void onDirective(AlexaDirective &directive) { seen.calls++; seen.device = directive.device; seen.capability = directive.capability; seen.type = directive.type; seen.ns = directive.ns; seen.name = directive.name; seen.instance = directive.instance; seen.correlationToken = directive.correlationToken; seen.rangeValue = directive.payload["rangeValue"] | -1; seen.reportState = directive.isReportState(); if (!handlerAnswers) { return; } if (directive.isReportState()) { directive.stateReport().AddPowerControllerProp(PowerController::OFF).send(); } else { directive.response().AddPowerControllerProp(directive.is("TurnOn") ? PowerController::ON : PowerController::OFF).send(); } } static int eventCalls; static int reportStateCalls; static AlexaInterfaceType eventType; static void onEvent(const JsonDocument &, const AlexaInterfaceType &type) { eventCalls++; eventType = type; } static void onReportState(const JsonDocument &, const AlexaInterfaceType &) { reportStateCalls++; } // The directive as Alex2MQTT publishes it on //alexaDirective static void directiveFor(JsonDocument &message, const char *endpointId, const char *ns, const char *name, const char *instance = nullptr) { message.clear(); message["header"]["namespace"] = ns; message["header"]["name"] = name; message["header"]["payloadVersion"] = "3"; message["header"]["messageId"] = "5f8a426e-01e4-4cc9-8b79-65f8bd0fd8a4"; message["header"]["correlationToken"] = "token-of-the-directive"; if (instance != nullptr) { message["header"]["instance"] = instance; } message["endpoint"]["endpointId"] = endpointId; message["payload"].to(); } static void assertErrorResponse(const FakeTransport::Message &message, const char *topic) { TEST_ASSERT_EQUAL_STRING(topic, message.topic.c_str()); JsonDocument answer; TEST_ASSERT_TRUE(deserializeJson(answer, message.json) == DeserializationError::Ok); TEST_ASSERT_EQUAL_STRING("Alexa", answer["event"]["header"]["namespace"] | ""); TEST_ASSERT_EQUAL_STRING("ErrorResponse", answer["event"]["header"]["name"] | ""); TEST_ASSERT_EQUAL_STRING("token-of-the-directive", answer["event"]["header"]["correlationToken"] | ""); TEST_ASSERT_EQUAL_STRING("INVALID_DIRECTIVE", answer["event"]["payload"]["type"] | ""); TEST_ASSERT_TRUE(strlen(answer["event"]["payload"]["message"] | "") > 0); TEST_ASSERT_TRUE(answer["context"].isNull()); // alexa-errorresponse.html: an ErrorResponse has no context } void setUp(void) { AlexaLog::setOutput(nullptr); seen = Seen(); handlerAnswers = true; eventCalls = 0; reportStateCalls = 0; eventType = AlexaInterfaceType::UNKNOWN; } void tearDown(void) {} void test_devices_are_found_by_their_endpoint_id_in_the_order_they_were_created(void) { AlexaDeviceList devices; TEST_ASSERT_NULL(devices.first()); AlexaDevice *lamp = devices.add("Lamp", "root", "ESP-01", nullptr); AlexaDevice *fan = devices.add("Fan", "root", "ESP-02", nullptr); AlexaDevice *blind = devices.add("Blind", "root", "ESP-03", nullptr); TEST_ASSERT_EQUAL(3, devices.size()); TEST_ASSERT_EQUAL_PTR(lamp, devices.first()); TEST_ASSERT_EQUAL_PTR(fan, lamp->nextDevice()); TEST_ASSERT_EQUAL_PTR(blind, fan->nextDevice()); TEST_ASSERT_NULL(blind->nextDevice()); TEST_ASSERT_EQUAL_PTR(fan, devices.find("ESP-02", 6)); // The id is a part of a topic: "ESP-02/alexaDirective" TEST_ASSERT_EQUAL_PTR(fan, devices.find("ESP-02/alexaDirective", 6)); // An endpoint of another board TEST_ASSERT_NULL(devices.find("ESP-04", 6)); TEST_ASSERT_NULL(devices.find("ESP-0", 5)); } void test_one_handler_serves_two_devices_and_is_told_which(void) { FakeTransport transport; AlexaDeviceList devices; AlexaDevice *lamp = devices.add("Lamp", "root", "ESP-01", &transport); AlexaDevice *fan = devices.add("Fan", "root", "ESP-02", &transport); lamp->addCapability(AlexaInterfaces::PowerController); AlexaCapability *fanPower = fan->addCapability(AlexaInterfaces::PowerController); lamp->onDirective(onDirective); fan->onDirective(onDirective); JsonDocument message; directiveFor(message, "ESP-02", "Alexa.PowerController", "TurnOn"); devices.find("ESP-02", 6)->handleDirective(message); TEST_ASSERT_EQUAL(1, seen.calls); TEST_ASSERT_EQUAL_PTR(fan, seen.device); TEST_ASSERT_EQUAL_PTR(fanPower, seen.capability); TEST_ASSERT_TRUE(AlexaInterfaceType::POWER_CONTROLLER == seen.type); TEST_ASSERT_EQUAL_STRING("Alexa.PowerController", seen.ns.c_str()); TEST_ASSERT_EQUAL_STRING("TurnOn", seen.name.c_str()); TEST_ASSERT_EQUAL_STRING("", seen.instance.c_str()); TEST_ASSERT_EQUAL_STRING("token-of-the-directive", seen.correlationToken.c_str()); TEST_ASSERT_FALSE(seen.reportState); TEST_ASSERT_EQUAL(1, transport.sent.size()); TEST_ASSERT_EQUAL_STRING("root/ESP-02/alexaResponce", transport.sent[0].topic.c_str()); JsonDocument answer; TEST_ASSERT_TRUE(deserializeJson(answer, transport.sent[0].json) == DeserializationError::Ok); TEST_ASSERT_EQUAL_STRING("Response", answer["event"]["header"]["name"] | ""); TEST_ASSERT_EQUAL_STRING("token-of-the-directive", answer["event"]["header"]["correlationToken"] | ""); TEST_ASSERT_EQUAL_STRING("ESP-02", answer["event"]["endpoint"]["endpointId"] | ""); TEST_ASSERT_EQUAL_STRING("ON", answer["context"]["properties"][0]["value"] | ""); TEST_ASSERT_EQUAL_STRING("2026-09-28T13:05:09Z", answer["context"]["properties"][0]["timeOfSample"] | ""); } void test_directive_with_an_instance_reaches_the_capability_with_that_instance(void) { FakeTransport transport; AlexaDevice blind("Blind", "root", "ESP-01", &transport); AlexaCapability *lift = blind.addCapability(AlexaInterfaces::RangeController, "Blind.Lift"); AlexaCapability *tilt = blind.addCapability(AlexaInterfaces::RangeController, "Blind.Tilt"); blind.onDirective(onDirective); JsonDocument message; directiveFor(message, "ESP-01", "Alexa.RangeController", "SetRangeValue", "Blind.Tilt"); message["payload"]["rangeValue"] = 40; blind.handleDirective(message); TEST_ASSERT_EQUAL_PTR(tilt, seen.capability); TEST_ASSERT_TRUE(AlexaInterfaceType::RANGE_CONTROLLER == seen.type); TEST_ASSERT_EQUAL_STRING("Blind.Tilt", seen.instance.c_str()); TEST_ASSERT_EQUAL(40, seen.rangeValue); TEST_ASSERT_EQUAL_PTR(lift, blind.findCapability("Alexa.RangeController", "Blind.Lift")); TEST_ASSERT_NULL(blind.findCapability("Alexa.RangeController", "Blind.Slat")); TEST_ASSERT_NULL(blind.findCapability("Alexa.RangeController", "")); TEST_ASSERT_NULL(blind.findCapability("Alexa.ModeController", "Blind.Lift")); } void test_report_state_is_handed_over_without_a_capability_and_answered_with_a_state_report(void) { FakeTransport transport; AlexaDevice lamp("Lamp", "root", "ESP-01", &transport); lamp.addCapability(AlexaInterfaces::PowerController); lamp.onDirective(onDirective); JsonDocument message; directiveFor(message, "ESP-01", "Alexa", "ReportState"); lamp.handleDirective(message); TEST_ASSERT_TRUE(seen.reportState); TEST_ASSERT_NULL(seen.capability); TEST_ASSERT_EQUAL(1, transport.sent.size()); JsonDocument answer; TEST_ASSERT_TRUE(deserializeJson(answer, transport.sent[0].json) == DeserializationError::Ok); TEST_ASSERT_EQUAL_STRING("StateReport", answer["event"]["header"]["name"] | ""); TEST_ASSERT_EQUAL_STRING("OFF", answer["context"]["properties"][0]["value"] | ""); } void test_one_capability_more_than_a_device_holds_is_refused_with_an_error(void) { CapturedLog log; AlexaDevice device("Panel", "root", "ESP-01"); AlexaCapability *first = nullptr; for (int i = 0; i < ALEX2ESP_MAX_CAPABILITIES; i++) { char instance[16]; snprintf(instance, sizeof(instance), "Panel.%d", i); AlexaCapability *added = device.addCapability(AlexaInterfaces::ToggleController, instance); TEST_ASSERT_NOT_NULL(added); added->addFriendlyName("Switch", "en-US"); if (i == 0) { first = added; } } std::string full; serializeJson(device.getDeviceJSON(), full); AlexaLog::setOutput(&log); TEST_ASSERT_NULL(device.addCapability(AlexaInterfaces::PowerController)); TEST_ASSERT_NULL(device.addCapability(AlexaInterfaceType::BRIGHTNESS_CONTROLLER)); char expected[160]; snprintf(expected, sizeof(expected), "error: ESP-01: capability Alexa.PowerController not added: the device has %d already " "(ALEX2ESP_MAX_CAPABILITIES)", ALEX2ESP_MAX_CAPABILITIES); assertLogged(log, expected); assertLogged(log, "error: ESP-01: capability Alexa.BrightnessController not added"); // What it has is still found and announced TEST_ASSERT_EQUAL_PTR(first, device.addCapability(AlexaInterfaces::ToggleController, "Panel.0")); TEST_ASSERT_NULL(device.findCapability("Alexa.PowerController", "")); std::string announced; serializeJson(device.getDeviceJSON(), announced); TEST_ASSERT_EQUAL_STRING(full.c_str(), announced.c_str()); } void test_directive_for_an_interface_the_device_lacks_is_answered_with_invalid_directive(void) { CapturedLog log; AlexaLog::setOutput(&log); FakeTransport transport; AlexaDevice lamp("Lamp", "root", "ESP-01", &transport); lamp.addCapability(AlexaInterfaces::PowerController); lamp.onDirective(onDirective); handlerAnswers = false; // as a handler that knows its own interfaces and leaves the others alone JsonDocument message; directiveFor(message, "ESP-01", "Alexa.LockController", "Lock"); lamp.handleDirective(message); // The handler is asked first: it sees that the device has no such capability TEST_ASSERT_EQUAL(1, seen.calls); TEST_ASSERT_NULL(seen.capability); TEST_ASSERT_TRUE(AlexaInterfaceType::UNKNOWN == seen.type); TEST_ASSERT_EQUAL(1, transport.sent.size()); assertErrorResponse(transport.sent[0], "root/ESP-01/alexaResponce"); assertLogged(log, "error: ESP-01: Alexa.LockController.Lock refused as INVALID_DIRECTIVE: " "the device has no such capability"); } void test_instance_the_device_lacks_is_answered_with_invalid_directive(void) { FakeTransport transport; AlexaDevice blind("Blind", "root", "ESP-01", &transport); blind.addCapability(AlexaInterfaces::RangeController, "Blind.Lift"); blind.onDirective(onDirective); handlerAnswers = false; JsonDocument message; directiveFor(message, "ESP-01", "Alexa.RangeController", "SetRangeValue", "Blind.Tilt"); blind.handleDirective(message); TEST_ASSERT_NULL(seen.capability); TEST_ASSERT_EQUAL(1, transport.sent.size()); assertErrorResponse(transport.sent[0], "root/ESP-01/alexaResponce"); } void test_answer_of_the_handler_to_an_interface_the_device_lacks_is_the_only_answer(void) { FakeTransport transport; AlexaDevice lamp("Lamp", "root", "ESP-01", &transport); lamp.addCapability(AlexaInterfaces::PowerController); lamp.onDirective(onDirective); JsonDocument message; directiveFor(message, "ESP-01", "Alexa.LockController", "Lock"); lamp.handleDirective(message); TEST_ASSERT_EQUAL(1, transport.sent.size()); JsonDocument answer; TEST_ASSERT_TRUE(deserializeJson(answer, transport.sent[0].json) == DeserializationError::Ok); TEST_ASSERT_EQUAL_STRING("Response", answer["event"]["header"]["name"] | ""); } void test_device_without_a_handler_answers_with_invalid_directive(void) { CapturedLog log; AlexaLog::setOutput(&log); FakeTransport transport; AlexaDevice lamp("Lamp", "root", "ESP-01", &transport); lamp.addCapability(AlexaInterfaces::PowerController); JsonDocument message; directiveFor(message, "ESP-01", "Alexa.PowerController", "TurnOn"); lamp.handleDirective(message); directiveFor(message, "ESP-01", "Alexa", "ReportState"); lamp.handleDirective(message); TEST_ASSERT_EQUAL(2, transport.sent.size()); assertErrorResponse(transport.sent[0], "root/ESP-01/alexaResponce"); assertErrorResponse(transport.sent[1], "root/ESP-01/alexaResponce"); assertLogged(log, "error: ESP-01: Alexa.PowerController.TurnOn refused as INVALID_DIRECTIVE: " "the device has no handler, give it one with onDirective()"); } void test_handler_that_sends_nothing_for_a_capability_of_the_device_is_reported(void) { CapturedLog log; AlexaLog::setOutput(&log); FakeTransport transport; AlexaDevice lamp("Lamp", "root", "ESP-01", &transport); lamp.addCapability(AlexaInterfaces::PowerController); lamp.onDirective(onDirective); handlerAnswers = false; JsonDocument message; directiveFor(message, "ESP-01", "Alexa.PowerController", "TurnOn"); lamp.handleDirective(message); TEST_ASSERT_EQUAL(1, seen.calls); TEST_ASSERT_EQUAL(0, transport.sent.size()); assertLogged(log, "error: ESP-01: the handler sent no answer to Alexa.PowerController.TurnOn"); } void test_handlers_of_1x_get_report_state_and_the_type_of_every_other_directive(void) { FakeTransport transport; AlexaDevice blind("Blind", "root", "ESP-01", &transport); blind.addCapability(AlexaInterfaceType::POWER_CONTROLLER); blind.addCapability(AlexaInterfaceType::TOGGLE_CONTROLLER)->setInstance("ESP-01.Toggle"); blind.registerEvent("ReportState", onReportState); blind.registerEvent("Event", onEvent); JsonDocument message; directiveFor(message, "ESP-01", "Alexa.ToggleController", "TurnOn", "ESP-01.Toggle"); blind.handleDirective(message); TEST_ASSERT_EQUAL(1, eventCalls); TEST_ASSERT_EQUAL(0, reportStateCalls); TEST_ASSERT_TRUE(AlexaInterfaceType::TOGGLE_CONTROLLER == eventType); directiveFor(message, "ESP-01", "Alexa", "ReportState"); blind.handleDirective(message); TEST_ASSERT_EQUAL(1, eventCalls); TEST_ASSERT_EQUAL(1, reportStateCalls); // With a handler of 2.0 the device calls that one blind.onDirective(onDirective); directiveFor(message, "ESP-01", "Alexa.PowerController", "TurnOff"); blind.handleDirective(message); TEST_ASSERT_EQUAL(1, eventCalls); TEST_ASSERT_EQUAL(1, seen.calls); } int main(int, char **) { UNITY_BEGIN(); RUN_TEST(test_devices_are_found_by_their_endpoint_id_in_the_order_they_were_created); RUN_TEST(test_one_handler_serves_two_devices_and_is_told_which); RUN_TEST(test_directive_with_an_instance_reaches_the_capability_with_that_instance); RUN_TEST(test_report_state_is_handed_over_without_a_capability_and_answered_with_a_state_report); RUN_TEST(test_one_capability_more_than_a_device_holds_is_refused_with_an_error); RUN_TEST(test_directive_for_an_interface_the_device_lacks_is_answered_with_invalid_directive); RUN_TEST(test_instance_the_device_lacks_is_answered_with_invalid_directive); RUN_TEST(test_answer_of_the_handler_to_an_interface_the_device_lacks_is_the_only_answer); RUN_TEST(test_device_without_a_handler_answers_with_invalid_directive); RUN_TEST(test_handler_that_sends_nothing_for_a_capability_of_the_device_is_reported); RUN_TEST(test_handlers_of_1x_get_report_state_and_the_type_of_every_other_directive); return UNITY_END(); }