"""Real-broker smoke test for Audio Router self-echo correlation. The fake MQTT client used by ``tests/test_mqtt_adapter.py`` delivers a publish's own echo synchronously, inside the ``publish()`` call itself, so it cannot exercise the actual ordering risk: a real broker may legally deliver a Controller-originated Router mode-set message back through the Controller's own subscription either before or after the PUBACK that already acknowledged the same publish. This test runs the real ``MqttAdapter`` against a real Mosquitto broker in Docker to prove that ordering no longer matters. Skipped automatically when Docker (or a Mosquitto broker container) is not available, so it never blocks the primary regression suite. """ from __future__ import annotations import queue import shutil import socket import subprocess import tempfile import time import unittest import uuid from pathlib import Path import paho.mqtt.client as mqtt from controller.config import ControllerConfig, MqttConfig from controller.mqtt_adapter import MqttAdapter def _docker_available() -> bool: return shutil.which("docker") is not None def _free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] @unittest.skipUnless( _docker_available(), "docker is not available for a real Mosquitto broker" ) class RouterSelfEchoMosquittoIntegrationTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.port = _free_port() cls.container_name = f"controller-echo-test-{uuid.uuid4().hex[:10]}" cls.tempdir = tempfile.TemporaryDirectory() conf_path = Path(cls.tempdir.name) / "mosquitto.conf" conf_path.write_text("listener 1883\nallow_anonymous true\n", encoding="utf-8") try: subprocess.run( [ "docker", "run", "-d", "--rm", "--name", cls.container_name, "-p", f"127.0.0.1:{cls.port}:1883", "-v", f"{conf_path}:/mosquitto/config/mosquitto.conf:ro", "eclipse-mosquitto", ], check=True, capture_output=True, text=True, timeout=30, ) except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: cls.tempdir.cleanup() raise unittest.SkipTest( f"could not start a real Mosquitto broker container: {exc}" ) try: cls._wait_for_broker(cls.port) except AssertionError as exc: subprocess.run( ["docker", "rm", "-f", cls.container_name], capture_output=True, timeout=15, ) cls.tempdir.cleanup() raise unittest.SkipTest(str(exc)) @classmethod def tearDownClass(cls) -> None: subprocess.run( ["docker", "rm", "-f", cls.container_name], capture_output=True, timeout=15, ) cls.tempdir.cleanup() @staticmethod def _wait_for_broker(port: int, timeout: float = 15.0) -> None: deadline = time.monotonic() + timeout last_exc: OSError | None = None while time.monotonic() < deadline: try: with socket.create_connection(("127.0.0.1", port), timeout=0.5): return except OSError as exc: last_exc = exc time.sleep(0.2) raise AssertionError(f"Mosquitto broker never became reachable: {last_exc}") def _config(self) -> ControllerConfig: return ControllerConfig( mqtt=MqttConfig( host="127.0.0.1", port=self.port, client_id=f"controller-echo-{uuid.uuid4().hex[:10]}", reconnect_min_seconds=1, reconnect_max_seconds=1, command_queue_size=32, publish_ack_timeout_seconds=5.0, router_echo_grace_seconds=2.0, ) ) def test_controller_recognizes_its_own_router_echo_over_a_real_broker(self) -> None: config = self._config() adapter = MqttAdapter(config) adapter.start() try: self._wait_until( lambda: adapter.connected, "Controller MQTT adapter never connected to the real broker", ) receipt = adapter.publish_router_mode("video") self.assertTrue(receipt.accepted, receipt.reason) event = self._next_message_event(adapter, timeout=5.0) self.assertEqual(event.topic, config.topics.router_mode_set) self.assertEqual(event.payload, b"video") self.assertTrue( event.controller_echo, "Controller did not recognize its own Router mode-set echo " "delivered by a real broker; the PUBACK-vs-subscription-echo " "race was not closed", ) finally: adapter.stop() def test_genuine_external_router_command_is_not_treated_as_self_echo(self) -> None: config = self._config() adapter = MqttAdapter(config) adapter.start() operator = mqtt.Client( mqtt.CallbackAPIVersion.VERSION2, client_id=f"operator-{uuid.uuid4().hex[:10]}", ) try: self._wait_until( lambda: adapter.connected, "Controller MQTT adapter never connected to the real broker", ) operator.connect(config.mqtt.host, config.mqtt.port, keepalive=30) operator.loop_start() info = operator.publish( config.topics.router_mode_set, "service", qos=1, retain=False ) info.wait_for_publish(timeout=5.0) event = self._next_message_event(adapter, timeout=5.0) self.assertEqual(event.payload, b"service") self.assertFalse( event.controller_echo, "A genuine external Router command was mistaken for a " "Controller echo; automatic restoration ownership would be " "incorrectly preserved instead of relinquished", ) finally: operator.loop_stop() operator.disconnect() adapter.stop() @staticmethod def _wait_until(predicate, message: str, timeout: float = 10.0) -> None: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if predicate(): return time.sleep(0.05) raise AssertionError(message) @staticmethod def _next_message_event(adapter: MqttAdapter, *, timeout: float): deadline = time.monotonic() + timeout while time.monotonic() < deadline: try: event = adapter.events.get(timeout=0.2) except queue.Empty: continue if event.kind == "message": return event raise AssertionError("no MQTT message event was observed within the timeout") if __name__ == "__main__": unittest.main()