M8-T04: reconcile DSMR ingest from meter sources
This commit is contained in:
@@ -14,6 +14,7 @@ Covers:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.integrations.mqtt import MqttManager
|
||||
@@ -231,6 +232,260 @@ def test_on_message_does_not_crash_on_handler_exception_multiple_calls() -> None
|
||||
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))},
|
||||
)
|
||||
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"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# on_connect re-subscribes registered topics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user