"""Tests for M6-T06: MqttManager subscribe + on_message dispatch. All paho interaction is mocked — no real broker is required. Covers: - subscribe() registers topic → handler in the internal registry. - on_message dispatches the payload to the correct handler. - A handler that raises does NOT propagate the exception out of on_message. - Existing publish() behaviour is not regressed. - When already connected, subscribe() calls client.subscribe() immediately. - on_connect re-subscribes to all registered topics after (re-)connect. - subscribe() before connect: topic is subscribed when connect fires. """ from __future__ import annotations import threading from unittest.mock import MagicMock, patch from app.integrations.mqtt import MqttManager # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_settings( *, mqtt_enabled: bool = True, mqtt_broker_host: str = "broker.test", mqtt_broker_port: int = 1883, mqtt_username: str = "", mqtt_password: str = "", mqtt_tls_enabled: bool = False, ): s = MagicMock() s.mqtt_enabled = mqtt_enabled s.mqtt_broker_host = mqtt_broker_host s.mqtt_broker_port = mqtt_broker_port s.mqtt_username = mqtt_username s.mqtt_password = mqtt_password s.mqtt_tls_enabled = mqtt_tls_enabled return s def _make_mqtt_message(topic: str, payload: bytes) -> MagicMock: """Construct a minimal mock of a paho MQTTMessage.""" msg = MagicMock() msg.topic = topic msg.payload = payload return msg # --------------------------------------------------------------------------- # subscribe() — registry # --------------------------------------------------------------------------- def test_subscribe_registers_handler() -> None: """subscribe() must store the handler in the internal _subscriptions dict.""" manager = MqttManager() handler = MagicMock() manager.subscribe("test/topic", handler) assert "test/topic" in manager._subscriptions assert manager._subscriptions["test/topic"] is handler def test_subscribe_overwrites_previous_handler() -> None: """Subscribing to the same topic twice replaces the handler.""" manager = MqttManager() h1 = MagicMock() h2 = MagicMock() manager.subscribe("test/topic", h1) manager.subscribe("test/topic", h2) assert manager._subscriptions["test/topic"] is h2 def test_subscribe_when_not_connected_does_not_call_paho_subscribe() -> None: """subscribe() before connect must not attempt to call client.subscribe().""" real_manager = MqttManager() handler = MagicMock() mock_client = MagicMock() real_manager._client = None real_manager._connected = False real_manager.subscribe("dsmr/json", handler) # No client → subscribe should not have been called on paho mock_client.subscribe.assert_not_called() def test_subscribe_when_connected_calls_paho_subscribe_immediately() -> None: """subscribe() while connected must call client.subscribe(topic) right away.""" manager = MqttManager() mock_client = MagicMock() manager._client = mock_client manager._connected = True handler = MagicMock() manager.subscribe("dsmr/json", handler) mock_client.subscribe.assert_called_once_with("dsmr/json") def test_unsubscribe_removes_handler_from_registry() -> None: """unsubscribe() must drop the handler so it is not re-subscribed on reconnect.""" manager = MqttManager() manager.subscribe("dsmr/json", MagicMock()) manager.unsubscribe("dsmr/json") assert "dsmr/json" not in manager._subscriptions def test_unsubscribe_when_connected_calls_paho_unsubscribe() -> None: """unsubscribe() while connected must call client.unsubscribe(topic).""" manager = MqttManager() mock_client = MagicMock() manager._client = mock_client manager._connected = True manager.subscribe("dsmr/json", MagicMock()) manager.unsubscribe("dsmr/json") mock_client.unsubscribe.assert_called_once_with("dsmr/json") def test_unsubscribe_unknown_topic_is_noop() -> None: """unsubscribe() on a topic that was never registered must not raise.""" manager = MqttManager() manager.unsubscribe("never/registered") # must not raise assert "never/registered" not in manager._subscriptions # --------------------------------------------------------------------------- # on_message dispatch # --------------------------------------------------------------------------- def _extract_on_message(manager: MqttManager, settings) -> object: """Connect with a mocked paho client and return the on_message callback that was registered on the client mock.""" mock_client = MagicMock() mock_client.connect.return_value = None with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client): manager.connect(settings) # The callback was assigned as: client.on_message = _on_message return mock_client.on_message def test_on_message_dispatches_to_registered_handler() -> None: """on_message must invoke the handler registered for the message's topic.""" manager = MqttManager() settings = _make_settings() received_payloads: list[bytes] = [] def handler(payload: bytes) -> None: received_payloads.append(payload) manager.subscribe("dsmr/json", handler) on_message = _extract_on_message(manager, settings) msg = _make_mqtt_message("dsmr/json", b'{"id": 1}') on_message(MagicMock(), None, msg) # simulate paho calling the callback assert received_payloads == [b'{"id": 1}'] def test_on_message_ignores_unknown_topic() -> None: """on_message for an unregistered topic must not raise and must not call any handler.""" manager = MqttManager() settings = _make_settings() handler = MagicMock() manager.subscribe("other/topic", handler) on_message = _extract_on_message(manager, settings) msg = _make_mqtt_message("unknown/topic", b"data") # Must not raise on_message(MagicMock(), None, msg) handler.assert_not_called() def test_on_message_swallows_handler_exception() -> None: """If the handler raises, on_message must NOT propagate the exception.""" manager = MqttManager() settings = _make_settings() def bad_handler(payload: bytes) -> None: raise RuntimeError("handler exploded") manager.subscribe("dsmr/json", bad_handler) on_message = _extract_on_message(manager, settings) msg = _make_mqtt_message("dsmr/json", b"{}") # This must not raise on_message(MagicMock(), None, msg) def test_on_message_does_not_crash_on_handler_exception_multiple_calls() -> None: """After a handler exception the manager remains functional for subsequent messages.""" manager = MqttManager() settings = _make_settings() call_count = [0] def flaky_handler(payload: bytes) -> None: call_count[0] += 1 if call_count[0] == 1: raise ValueError("first call fails") manager.subscribe("dsmr/json", flaky_handler) on_message = _extract_on_message(manager, settings) msg1 = _make_mqtt_message("dsmr/json", b"first") msg2 = _make_mqtt_message("dsmr/json", b"second") on_message(MagicMock(), None, msg1) # should not raise despite exception in handler on_message(MagicMock(), None, msg2) # handler called again assert call_count[0] == 2 def test_replace_source_uses_isolated_client_and_source_credentials() -> None: """A DSMR source has its own client; replacing it leaves peers untouched.""" manager = MqttManager() first_client = MagicMock() second_client = MagicMock() third_client = MagicMock() received: list[tuple[str, bytes]] = [] with patch( "app.integrations.mqtt.mqtt.Client", side_effect=[first_client, second_client, third_client] ): manager.replace_source( 1, host="one.test", port=1884, username="one-user", password="one-secret", tls_enabled=True, subscriptions={"one/topic": lambda payload: received.append(("one", payload))}, base_client_id="home-automation-dev", ) manager.replace_source( 2, host="two.test", port=2884, username="two-user", password="two-secret", tls_enabled=False, subscriptions={"two/topic": lambda payload: received.append(("two", payload))}, ) manager.replace_source( 1, host="changed.test", port=1885, username="changed-user", password="changed-secret", tls_enabled=False, subscriptions={"changed/topic": lambda payload: received.append(("changed", payload))}, ) first_client.tls_set.assert_called_once_with() first_client.username_pw_set.assert_called_once_with(username="one-user", password="one-secret") first_client.connect.assert_called_once_with(host="one.test", port=1884, keepalive=60) first_client.disconnect.assert_called_once_with() second_client.disconnect.assert_not_called() second_client.connect.assert_called_once_with(host="two.test", port=2884, keepalive=60) third_client.connect.assert_called_once_with(host="changed.test", port=1885, keepalive=60) second_client.on_message(second_client, None, _make_mqtt_message("two/topic", b"two")) third_client.on_message(third_client, None, _make_mqtt_message("changed/topic", b"changed")) assert received == [("two", b"two"), ("changed", b"changed")] def test_source_client_id_is_scoped_by_deployment_and_source() -> None: manager = MqttManager() client = MagicMock() with patch("app.integrations.mqtt.mqtt.Client", return_value=client) as mock_cls: assert manager.replace_source( 42, host="broker.test", port=1883, username="", password="", tls_enabled=False, subscriptions={"topic": lambda _payload: None}, base_client_id="home-automation-dev", ) assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev-dsmr-source-42" def test_replaced_source_client_callbacks_cannot_reach_new_generation() -> None: """A retained old paho client cannot subscribe, mutate state, or dispatch new handlers.""" manager = MqttManager() old_client = MagicMock() new_client = MagicMock() received: list[tuple[str, bytes]] = [] with patch("app.integrations.mqtt.mqtt.Client", side_effect=[old_client, new_client]): assert manager.replace_source( 7, host="old.test", port=1883, username="", password="", tls_enabled=False, subscriptions={"same/topic": lambda payload: received.append(("old", payload))}, ) assert manager.replace_source( 7, host="new.test", port=1883, username="", password="", tls_enabled=False, subscriptions={"same/topic": lambda payload: received.append(("new", payload))}, ) accepted = MagicMock() accepted.is_failure = False old_client.on_connect(old_client, None, MagicMock(), accepted, None) old_client.on_message(old_client, None, _make_mqtt_message("same/topic", b"stale")) old_client.on_disconnect(old_client, None, MagicMock(), MagicMock(), None) old_client.subscribe.assert_not_called() assert received == [] assert 7 not in manager._source_connected new_client.on_message(new_client, None, _make_mqtt_message("same/topic", b"fresh")) assert received == [("new", b"fresh")] def test_removed_then_reenabled_identical_source_rejects_old_callback() -> None: manager = MqttManager() old_client = MagicMock() reenabled_client = MagicMock() received: list[bytes] = [] kwargs = { "host": "broker.test", "port": 1883, "username": "", "password": "", "tls_enabled": False, "subscriptions": {"same/topic": lambda payload: received.append(payload)}, } with patch("app.integrations.mqtt.mqtt.Client", side_effect=[old_client, reenabled_client]): assert manager.replace_source(7, **kwargs) manager.remove_source(7) assert manager.replace_source(7, **kwargs) old_client.on_message(old_client, None, _make_mqtt_message("same/topic", b"stale")) reenabled_client.on_message(reenabled_client, None, _make_mqtt_message("same/topic", b"fresh")) assert received == [b"fresh"] def test_source_connect_failure_is_not_active_and_can_be_retried() -> None: manager = MqttManager() failed_client = MagicMock() failed_client.connect.side_effect = OSError("broker down") recovered_client = MagicMock() with patch("app.integrations.mqtt.mqtt.Client", side_effect=[failed_client, recovered_client]): assert not manager.replace_source( 1, host="broker.test", port=1883, username="", password="", tls_enabled=False, subscriptions={"topic": lambda _payload: None}, ) assert not manager.source_is_active(1) assert manager.replace_source( 1, host="broker.test", port=1883, username="", password="", tls_enabled=False, subscriptions={"topic": lambda _payload: None}, ) assert manager.source_is_active(1) failed_client.loop_stop.assert_called_once_with() def test_source_tls_failure_is_not_active() -> None: manager = MqttManager() failed_client = MagicMock() failed_client.tls_set.side_effect = OSError("bad TLS") with patch("app.integrations.mqtt.mqtt.Client", return_value=failed_client): assert not manager.replace_source( 1, host="broker.test", port=1883, username="", password="", tls_enabled=True, subscriptions={"topic": lambda _payload: None}, ) assert not manager.source_is_active(1) def test_source_sync_connack_before_connect_returns_subscribes_all_topics() -> None: """Ownership is installed before a synchronous CONNACK callback can run.""" manager = MqttManager() class SyncConnackClient: def __init__(self) -> None: self.subscribed: list[str] = [] def loop_start(self) -> None: pass def connect(self, **_kwargs: object) -> None: accepted = MagicMock() accepted.is_failure = False self.on_connect(self, None, MagicMock(), accepted, None) def subscribe(self, topic: str) -> None: self.subscribed.append(topic) def disconnect(self) -> None: pass def loop_stop(self) -> None: pass client = SyncConnackClient() with patch("app.integrations.mqtt.mqtt.Client", return_value=client): assert manager.replace_source( 9, host="broker.test", port=1883, username="", password="", tls_enabled=False, subscriptions={"telegram/topic": lambda _payload: None, "tariff/topic": lambda _payload: None}, ) assert manager.source_is_active(9) assert client.subscribed == ["telegram/topic", "tariff/topic"] def test_source_health_tracks_connack_disconnect_and_ignores_stale_callbacks() -> None: """A source is connecting until CONNACK, and old generations cannot rewrite health.""" manager = MqttManager() old_client = MagicMock() new_client = MagicMock() old_states: list[str] = [] new_states: list[str] = [] kwargs = { "host": "broker.test", "port": 1883, "username": "", "password": "", "tls_enabled": False, "subscriptions": {"topic": lambda _payload: None}, } with patch("app.integrations.mqtt.mqtt.Client", side_effect=[old_client, new_client]): assert manager.replace_source(1, **kwargs, state_handler=old_states.append) assert old_states == ["connecting"] assert manager.replace_source(1, **kwargs, state_handler=new_states.append) accepted = MagicMock() accepted.is_failure = False old_client.on_connect(old_client, None, MagicMock(), accepted, None) old_client.on_disconnect(old_client, None, MagicMock(), MagicMock(), None) assert old_states == ["connecting"] new_client.on_connect(new_client, None, MagicMock(), accepted, None) new_client.on_disconnect(new_client, None, MagicMock(), MagicMock(), None) assert new_states == ["connecting", "online", "error"] def test_source_health_rejected_connack_reports_error_without_disconnect() -> None: """A failed CONNACK is distinct from a later disconnect callback.""" manager = MqttManager() client = MagicMock() states: list[str] = [] with patch("app.integrations.mqtt.mqtt.Client", return_value=client): assert manager.replace_source( 1, host="broker.test", port=1883, username="", password="", tls_enabled=False, subscriptions={"topic": lambda _payload: None}, state_handler=states.append, ) accepted = MagicMock() accepted.is_failure = False refused = MagicMock() refused.is_failure = True client.on_connect(client, None, MagicMock(), accepted, None) client.on_connect(client, None, MagicMock(), refused, None) assert states == ["connecting", "online", "error"] assert 1 not in manager._source_connected def test_source_health_disconnect_then_reconnect_connack_is_ordered_and_isolated() -> None: """One source's reconnect sequence cannot change another source's health.""" manager = MqttManager() first_client = MagicMock() second_client = MagicMock() first_states: list[str] = [] second_states: list[str] = [] kwargs = { "host": "broker.test", "port": 1883, "username": "", "password": "", "tls_enabled": False, "subscriptions": {"topic": lambda _payload: None}, } with patch("app.integrations.mqtt.mqtt.Client", side_effect=[first_client, second_client]): assert manager.replace_source(1, **kwargs, state_handler=first_states.append) assert manager.replace_source(2, **kwargs, state_handler=second_states.append) accepted = MagicMock() accepted.is_failure = False first_client.on_connect(first_client, None, MagicMock(), accepted, None) first_client.on_disconnect(first_client, None, MagicMock(), MagicMock(), None) first_client.on_connect(first_client, None, MagicMock(), accepted, None) assert first_states == ["connecting", "online", "error", "online"] assert second_states == ["connecting"] def test_source_teardown_with_joining_loop_stop_waits_for_callback_before_aba() -> None: """loop_stop may join a callback that needs the manager lock to finish.""" manager = MqttManager() class JoiningClient: def loop_start(self) -> None: pass def connect(self, **_kwargs: object) -> None: pass def disconnect(self) -> None: pass def loop_stop(self) -> None: self.callback_thread.join() old_client = JoiningClient() new_client = MagicMock() started = threading.Event() release = threading.Event() removed = threading.Event() received: list[bytes] = [] def old_handler(payload: bytes) -> None: started.set() release.wait(timeout=2) received.append(payload) kwargs = { "host": "broker.test", "port": 1883, "username": "", "password": "", "tls_enabled": False, "subscriptions": {"same/topic": old_handler}, } with patch("app.integrations.mqtt.mqtt.Client", side_effect=[old_client, new_client]): assert manager.replace_source(7, **kwargs) callback_thread = threading.Thread( target=old_client.on_message, args=(old_client, None, _make_mqtt_message("same/topic", b"old")), daemon=True, ) old_client.callback_thread = callback_thread callback_thread.start() assert started.wait(timeout=1) def remove_source() -> None: manager.remove_source(7) removed.set() teardown_thread = threading.Thread(target=remove_source, daemon=True) teardown_thread.start() assert not removed.wait(timeout=0.05) release.set() assert removed.wait(timeout=1) callback_thread.join(timeout=1) teardown_thread.join(timeout=1) assert not callback_thread.is_alive() assert not teardown_thread.is_alive() assert removed.is_set() with patch("app.integrations.mqtt.mqtt.Client", return_value=new_client): assert manager.replace_source(7, **kwargs) old_client.on_message(old_client, None, _make_mqtt_message("same/topic", b"stale")) assert received == [b"old"] # --------------------------------------------------------------------------- # on_connect re-subscribes registered topics # --------------------------------------------------------------------------- def test_on_connect_subscribes_all_registered_topics() -> None: """When connect fires successfully, _on_connect must subscribe every registered topic.""" manager = MqttManager() settings = _make_settings() manager.subscribe("topic/a", MagicMock()) manager.subscribe("topic/b", MagicMock()) mock_client = MagicMock() mock_client.connect.return_value = None # Capture the on_connect callback that was registered on the mock client with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client): manager.connect(settings) on_connect_cb = mock_client.on_connect # Simulate broker accepting the connection (reason_code.is_failure == False) reason_code = MagicMock() reason_code.is_failure = False on_connect_cb(mock_client, None, MagicMock(), reason_code, None) # client.subscribe should have been called once per topic during on_connect subscribe_calls = [c[0][0] for c in mock_client.subscribe.call_args_list] assert "topic/a" in subscribe_calls assert "topic/b" in subscribe_calls def test_on_connect_does_not_subscribe_on_failure() -> None: """When the broker rejects the connection, _on_connect must NOT subscribe.""" manager = MqttManager() settings = _make_settings() manager.subscribe("topic/a", MagicMock()) mock_client = MagicMock() mock_client.connect.return_value = None with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client): manager.connect(settings) on_connect_cb = mock_client.on_connect reason_code = MagicMock() reason_code.is_failure = True mock_client.subscribe.reset_mock() on_connect_cb(mock_client, None, MagicMock(), reason_code, None) mock_client.subscribe.assert_not_called() # --------------------------------------------------------------------------- # Existing publish() is not regressed # --------------------------------------------------------------------------- def test_publish_still_works_after_subscribe_registered() -> None: """Adding a subscription must not break publish() behaviour.""" manager = MqttManager() mock_client = MagicMock() mock_client.connect.return_value = None manager._client = mock_client manager._connected = True manager.subscribe("dsmr/json", MagicMock()) manager.publish("home/sensor", b"data", retain=True, qos=1) mock_client.publish.assert_called_once_with( "home/sensor", payload=b"data", qos=1, retain=True ) def test_publish_skipped_when_not_connected_regression() -> None: """publish() must remain a no-op when not connected, even with subscriptions.""" manager = MqttManager() manager.subscribe("dsmr/json", MagicMock()) # No client set up — publish should be silent manager.publish("topic", b"payload") # must not raise