2026-06-23 21:52:57 +02:00
|
|
|
"""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
|
|
|
|
|
|
2026-08-23 00:25:11 +02:00
|
|
|
import threading
|
2026-06-23 21:52:57 +02:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 11:02:32 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 21:52:57 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 00:25:11 +02:00
|
|
|
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))},
|
|
|
|
|
)
|
|
|
|
|
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_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_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"]
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 21:52:57 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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
|