Files
home-automation/tests/test_mqtt_subscribe.py
T

674 lines
24 KiB
Python
Raw Normal View History

"""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"]
2026-08-24 01:02:23 +02:00
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