2026-06-24 11:02:32 +02:00
|
|
|
"""Tests for restart-free DSMR subscription management (apply_dsmr_subscription).
|
|
|
|
|
|
|
|
|
|
These verify that toggling DSMR ingest / changing its topic / changing its sample
|
|
|
|
|
interval via the config UI is reflected in the live MQTT subscription without an
|
|
|
|
|
app restart. A fake MQTT manager is injected so no real broker is touched.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from app.services import dsmr_ingest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeMqtt:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.subscribe_calls: list[tuple[str, object]] = []
|
|
|
|
|
self.unsubscribe_calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
def subscribe(self, topic: str, handler) -> None:
|
|
|
|
|
self.subscribe_calls.append((topic, handler))
|
|
|
|
|
|
|
|
|
|
def unsubscribe(self, topic: str) -> None:
|
|
|
|
|
self.unsubscribe_calls.append(topic)
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 15:39:21 +02:00
|
|
|
def _settings(
|
|
|
|
|
*,
|
|
|
|
|
enabled: bool = True,
|
|
|
|
|
topic: str = "dsmr/json",
|
|
|
|
|
interval: int = 10,
|
|
|
|
|
tariff_topic: str = "",
|
|
|
|
|
):
|
2026-06-24 11:02:32 +02:00
|
|
|
s = MagicMock()
|
|
|
|
|
s.dsmr_ingest_enabled = enabled
|
|
|
|
|
s.dsmr_mqtt_topic = topic
|
|
|
|
|
s.dsmr_sample_interval_s = interval
|
2026-06-24 15:39:21 +02:00
|
|
|
s.dsmr_tariff_topic = tariff_topic
|
2026-06-24 11:02:32 +02:00
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def fake_mqtt(monkeypatch):
|
|
|
|
|
fake = _FakeMqtt()
|
|
|
|
|
# apply_dsmr_subscription does `from app.integrations.mqtt import mqtt_manager`
|
|
|
|
|
# at call time, so patching the module attribute is picked up.
|
|
|
|
|
monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake)
|
2026-06-24 15:39:21 +02:00
|
|
|
# Reset (and auto-restore) the module-level "currently subscribed topics".
|
2026-06-24 11:02:32 +02:00
|
|
|
monkeypatch.setattr(dsmr_ingest, "_current_dsmr_topic", None)
|
2026-06-24 15:39:21 +02:00
|
|
|
monkeypatch.setattr(dsmr_ingest, "_current_tariff_topic", None)
|
2026-06-24 11:02:32 +02:00
|
|
|
return fake
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_enabled_subscribes_to_topic(fake_mqtt):
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=True, topic="dsmr/json"))
|
|
|
|
|
|
|
|
|
|
assert len(fake_mqtt.subscribe_calls) == 1
|
|
|
|
|
assert fake_mqtt.subscribe_calls[0][0] == "dsmr/json"
|
|
|
|
|
assert fake_mqtt.unsubscribe_calls == []
|
|
|
|
|
assert dsmr_ingest._current_dsmr_topic == "dsmr/json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disabled_after_enabled_unsubscribes(fake_mqtt):
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=True, topic="dsmr/json"))
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=False))
|
|
|
|
|
|
|
|
|
|
assert fake_mqtt.unsubscribe_calls == ["dsmr/json"]
|
|
|
|
|
assert dsmr_ingest._current_dsmr_topic is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_topic_change_unsubscribes_old_subscribes_new(fake_mqtt):
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(_settings(topic="dsmr/json"))
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(_settings(topic="meter/dsmr"))
|
|
|
|
|
|
|
|
|
|
assert fake_mqtt.unsubscribe_calls == ["dsmr/json"]
|
|
|
|
|
assert [t for t, _ in fake_mqtt.subscribe_calls] == ["dsmr/json", "meter/dsmr"]
|
|
|
|
|
assert dsmr_ingest._current_dsmr_topic == "meter/dsmr"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reapply_same_topic_resubscribes_fresh_handler(fake_mqtt):
|
|
|
|
|
# A changed sample interval must take effect — the handler is re-bound to a
|
|
|
|
|
# fresh settings snapshot, so re-applying the same topic re-subscribes.
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(_settings(topic="dsmr/json", interval=10))
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(_settings(topic="dsmr/json", interval=20))
|
|
|
|
|
|
|
|
|
|
assert len(fake_mqtt.subscribe_calls) == 2
|
|
|
|
|
assert fake_mqtt.unsubscribe_calls == [] # same topic, no churn
|
|
|
|
|
handler1 = fake_mqtt.subscribe_calls[0][1]
|
|
|
|
|
handler2 = fake_mqtt.subscribe_calls[1][1]
|
|
|
|
|
assert handler1 is not handler2 # fresh closure carrying the new settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disabled_when_never_enabled_is_noop(fake_mqtt):
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=False))
|
|
|
|
|
|
|
|
|
|
assert fake_mqtt.subscribe_calls == []
|
|
|
|
|
assert fake_mqtt.unsubscribe_calls == []
|
|
|
|
|
assert dsmr_ingest._current_dsmr_topic is None
|
2026-06-24 15:39:21 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Tariff topic subscription management
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_enabled_with_tariff_topic_subscribes_both(fake_mqtt):
|
|
|
|
|
"""When enabled and tariff_topic is non-empty, both topics must be subscribed."""
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(
|
|
|
|
|
_settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
subscribed_topics = [t for t, _ in fake_mqtt.subscribe_calls]
|
|
|
|
|
assert "dsmr/json" in subscribed_topics
|
|
|
|
|
assert "dsmr/meter-stats/electricity_tariff" in subscribed_topics
|
|
|
|
|
assert len(fake_mqtt.subscribe_calls) == 2
|
|
|
|
|
assert dsmr_ingest._current_tariff_topic == "dsmr/meter-stats/electricity_tariff"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_enabled_with_empty_tariff_topic_subscribes_only_main(fake_mqtt):
|
|
|
|
|
"""When tariff_topic is empty, only the main DSMR topic is subscribed."""
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(
|
|
|
|
|
_settings(enabled=True, topic="dsmr/json", tariff_topic="")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert len(fake_mqtt.subscribe_calls) == 1
|
|
|
|
|
assert fake_mqtt.subscribe_calls[0][0] == "dsmr/json"
|
|
|
|
|
assert dsmr_ingest._current_tariff_topic is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disabled_after_tariff_subscription_unsubscribes_both(fake_mqtt):
|
|
|
|
|
"""Disabling ingest must also unsubscribe the tariff topic."""
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(
|
|
|
|
|
_settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff")
|
|
|
|
|
)
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(_settings(enabled=False))
|
|
|
|
|
|
|
|
|
|
assert "dsmr/json" in fake_mqtt.unsubscribe_calls
|
|
|
|
|
assert "dsmr/meter-stats/electricity_tariff" in fake_mqtt.unsubscribe_calls
|
|
|
|
|
assert dsmr_ingest._current_dsmr_topic is None
|
|
|
|
|
assert dsmr_ingest._current_tariff_topic is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_tariff_topic_change_resubscribes(fake_mqtt):
|
|
|
|
|
"""Changing the tariff topic must unsubscribe the old one and subscribe the new one."""
|
|
|
|
|
old_tariff = "dsmr/meter-stats/electricity_tariff"
|
|
|
|
|
new_tariff = "meter/tariff"
|
|
|
|
|
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(
|
|
|
|
|
_settings(enabled=True, topic="dsmr/json", tariff_topic=old_tariff)
|
|
|
|
|
)
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(
|
|
|
|
|
_settings(enabled=True, topic="dsmr/json", tariff_topic=new_tariff)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert old_tariff in fake_mqtt.unsubscribe_calls
|
|
|
|
|
subscribed_topics = [t for t, _ in fake_mqtt.subscribe_calls]
|
|
|
|
|
assert new_tariff in subscribed_topics
|
|
|
|
|
assert dsmr_ingest._current_tariff_topic == new_tariff
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_tariff_topic_cleared_unsubscribes(fake_mqtt):
|
|
|
|
|
"""Setting tariff_topic to empty after it was subscribed must unsubscribe it."""
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(
|
|
|
|
|
_settings(enabled=True, topic="dsmr/json", tariff_topic="dsmr/meter-stats/electricity_tariff")
|
|
|
|
|
)
|
|
|
|
|
dsmr_ingest.apply_dsmr_subscription(
|
|
|
|
|
_settings(enabled=True, topic="dsmr/json", tariff_topic="")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "dsmr/meter-stats/electricity_tariff" in fake_mqtt.unsubscribe_calls
|
|
|
|
|
assert dsmr_ingest._current_tariff_topic is None
|