Files
meshcore-phone-mode/test_controller.py
T
zorruno 593f86e6dd Configure full MQTT topics in the private INI
Use configured topics for commands, state, availability and Last Will. Replace shared installation prefixes with generic examples. Validate configuration before stopping an existing service; add tests for custom and invalid topics.
2026-10-03 15:31:08 +13:00

238 lines
10 KiB
Python

"""Behaviour tests with mocked MQTT and Docker; no live services are changed."""
import json
import configparser
import tempfile
from pathlib import Path
import sys
import unittest
from types import SimpleNamespace, ModuleType
from unittest.mock import Mock, patch
from controller import (
CONTAINER, Topics, Controller, DockerControl, phone_mode, load_config, main,
)
TEST_TOPICS = Topics("custom/command", "custom/state", "custom/availability")
class ControllerTests(unittest.TestCase):
def setUp(self):
self.client = Mock()
self.client.subscribe.return_value = (0, 42)
publication = Mock(rc=0)
publication.is_published.return_value = True
self.client.publish.return_value = publication
self.docker = Mock()
self.docker.status.return_value = "running"
self.control = Controller(self.client, self.docker, TEST_TOPICS)
def ready(self):
self.control.on_connect(self.client, None, None, 0, None)
self.control.on_subscribe(self.client, None, 42, [1], None)
def test_connect_and_reconnect_never_change_container(self):
self.ready()
self.control.report()
self.control.on_disconnect(self.client, None, None, 1, None)
self.ready()
self.control.report()
self.docker.set_mode.assert_not_called()
self.client.subscribe.assert_called_with(TEST_TOPICS.command, qos=1)
def test_shutdown_publishes_to_configured_availability_topic(self):
self.ready()
self.control.stopping.set()
self.client.publish.reset_mock()
self.control.run()
self.client.publish.assert_called_once_with(
TEST_TOPICS.availability, "offline", qos=1, retain=True,
)
def test_subscription_denial_keeps_controller_unavailable(self):
self.control.on_connect(self.client, None, None, 0, None)
self.control.on_subscribe(self.client, None, 42, [128], None)
self.assertFalse(self.control.connected.is_set())
self.assertNotIn("online", [call.args[1] for call in self.client.publish.call_args_list])
def test_initial_connection_does_not_assume_subscription_succeeded(self):
self.control.on_connect(self.client, None, None, 0, None)
self.assertFalse(self.control.connected.is_set())
def test_retained_and_invalid_commands_do_not_run_docker(self):
self.ready()
for topic, payload, retained in [
(TEST_TOPICS.command, b"ON", True),
(TEST_TOPICS.command, b"ON; docker rm mqtt5", False),
(TEST_TOPICS.command, b"off", False),
(TEST_TOPICS.command, b"\xff", False),
("another/topic", b"ON", False),
]:
self.control.on_message(self.client, None, SimpleNamespace(
topic=topic, payload=payload, retain=retained,
))
self.assertTrue(self.control.commands.empty())
self.docker.set_mode.assert_not_called()
def test_live_command_waits_for_confirmed_docker_state(self):
self.ready()
self.control.on_message(self.client, None, SimpleNamespace(
topic=TEST_TOPICS.command, payload=b"ON", retain=False,
))
self.docker.set_mode.return_value = "exited"
self.control.process_command(self.control.commands.get_nowait())
self.docker.set_mode.assert_called_once_with("ON")
states = [json.loads(c.args[1]) for c in self.client.publish.call_args_list if c.args[0] == TEST_TOPICS.state]
self.assertEqual(states[-1]["phone_app_mode"], "ON")
self.assertEqual(states[-1]["container_status"], "exited")
def test_failed_stop_reports_actual_running_state(self):
self.ready()
self.docker.set_mode.side_effect = RuntimeError("stop failed")
self.control.process_command("ON")
states = [json.loads(c.args[1]) for c in self.client.publish.call_args_list if c.args[0] == TEST_TOPICS.state]
self.assertEqual(states[-1]["phone_app_mode"], "OFF")
def test_missing_container_reports_offline(self):
self.ready()
self.docker.status.side_effect = RuntimeError("missing container")
self.assertFalse(self.control.report())
self.client.publish.assert_called_with(TEST_TOPICS.availability, "offline", qos=1, retain=True)
def test_unacknowledged_state_does_not_report_online(self):
self.ready()
self.client.publish.reset_mock()
self.client.publish.return_value.is_published.return_value = False
self.assertFalse(self.control.report())
self.assertNotIn("online", [call.args[1] for call in self.client.publish.call_args_list])
def test_disconnect_drops_queued_commands(self):
self.ready()
self.control.commands.put_nowait("OFF")
self.control.on_disconnect(self.client, None, None, 1, None)
self.assertTrue(self.control.commands.empty())
self.control.process_command("OFF")
self.docker.set_mode.assert_not_called()
def test_manual_container_changes_are_reported(self):
self.ready()
self.control.report("running")
self.control.report("exited")
states = [json.loads(c.args[1])["phone_app_mode"] for c in self.client.publish.call_args_list if c.args[0] == TEST_TOPICS.state]
self.assertEqual(states, ["OFF", "ON"])
class ConfigTests(unittest.TestCase):
def config(self, values):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "controller.ini"
path.write_text("[mqtt]\n" + values)
return load_config(path)
def test_full_custom_topics_are_loaded_without_prefix_or_interpolation(self):
_, _, _, _, topics = self.config(
"command_topic = house%20/radio/set\n"
"state_topic = another/tree/state\n"
"availability_topic = separate/health\n"
)
self.assertEqual(topics, Topics("house%20/radio/set", "another/tree/state", "separate/health"))
def test_missing_topics_do_not_fall_back_to_another_installation(self):
with self.assertRaisesRegex(ValueError, "Add full topics"):
self.config("host = 127.0.0.1\n")
def test_invalid_topics_are_rejected_for_each_setting(self):
for index in range(3):
for invalid in ("", "radio/#", "radio/+/state", "radio\0state", "x" * 65536, "é" * 32768):
with self.subTest(index=index, invalid=invalid[:20]):
values = ["radio/set", "radio/state", "radio/availability"]
values[index] = invalid
with self.assertRaises(ValueError):
Topics(*values)
def test_duplicate_topics_are_rejected(self):
for values in (("same", "same", "other"), ("same", "other", "same"), ("other", "same", "same")):
with self.assertRaisesRegex(ValueError, "must be different"):
Topics(*values)
def test_blank_topic_in_ini_is_rejected(self):
with self.assertRaises(ValueError):
self.config("command_topic = \nstate_topic = radio/state\navailability_topic = radio/health\n")
def test_shared_examples_use_matching_generic_topics(self):
root = Path(__file__).parent
_, _, _, _, topics = load_config(root / "meshcore-phone-mode.ini.example")
yaml = (root / "meshcore_phone_app_mode.yaml").read_text()
for key, topic in (("command_topic", topics.command), ("state_topic", topics.state),
("json_attributes_topic", topics.state), ("availability_topic", topics.availability)):
self.assertIn(f'{key}: "{topic}"', yaml)
def test_main_sets_last_will_and_controller_from_same_private_config(self):
mqtt = Mock()
paho_package = ModuleType("paho")
mqtt_package = ModuleType("paho.mqtt")
paho_package.mqtt = mqtt_package
mqtt_package.client = mqtt
modules = {"paho": paho_package, "paho.mqtt": mqtt_package, "paho.mqtt.client": mqtt}
with patch.dict(sys.modules, modules), patch("controller.load_config", return_value=(
"127.0.0.1", 1883, 15, configparser.ConfigParser(), TEST_TOPICS,
)), patch("controller.DockerControl") as docker, patch("controller.Controller") as control, \
patch("controller.signal.signal"), patch("controller.logging.basicConfig"), \
patch.object(sys, "argv", ["controller.py"]):
main()
mqtt.Client.return_value.will_set.assert_called_once_with(
TEST_TOPICS.availability, "offline", qos=1, retain=True,
)
control.assert_called_once_with(mqtt.Client.return_value, docker.return_value, TEST_TOPICS, 15)
control.return_value.run.assert_called_once_with()
class DockerTests(unittest.TestCase):
def control(self):
with patch("controller.shutil.which", return_value="/usr/bin/docker"):
return DockerControl()
def test_stop_only_targets_webui_with_separate_arguments(self):
control = self.control()
control.status = Mock(side_effect=["running", "exited"])
control.run = Mock()
self.assertEqual(control.set_mode("ON"), "exited")
control.run.assert_called_once_with("stop", "--time", "10", CONTAINER, timeout=35)
def test_start_only_targets_webui(self):
control = self.control()
control.status = Mock(side_effect=["exited", "running"])
control.run = Mock()
self.assertEqual(control.set_mode("OFF"), "running")
control.run.assert_called_once_with("start", CONTAINER, timeout=35)
def test_duplicate_commands_are_idempotent(self):
control = self.control()
control.status = Mock(return_value="exited")
control.run = Mock()
self.assertEqual(control.set_mode("ON"), "exited")
control.run.assert_not_called()
def test_invalid_request_is_rejected_before_docker(self):
control = self.control()
control.status = Mock()
with self.assertRaises(ValueError):
control.set_mode("ON; shutdown")
control.status.assert_not_called()
def test_paused_or_unknown_is_not_claimed_as_free(self):
for status in ["paused", "dead", "removing", "unknown"]:
with self.assertRaises(RuntimeError):
phone_mode(status)
def test_successful_command_exit_without_state_change_is_failure(self):
control = self.control()
control.status = Mock(return_value="running")
control.run = Mock()
with self.assertRaises(RuntimeError):
control.set_mode("ON")
if __name__ == "__main__":
unittest.main()