"""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) def _settings( *, enabled: bool = True, topic: str = "dsmr/json", interval: int = 10, tariff_topic: str = "", ): s = MagicMock() s.dsmr_ingest_enabled = enabled s.dsmr_mqtt_topic = topic s.dsmr_sample_interval_s = interval s.dsmr_tariff_topic = tariff_topic 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) # Reset (and auto-restore) the module-level "currently subscribed topics". monkeypatch.setattr(dsmr_ingest, "_current_dsmr_topic", None) monkeypatch.setattr(dsmr_ingest, "_current_tariff_topic", None) 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 # --------------------------------------------------------------------------- # 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