M5: use DB-merged runtime settings for MQTT/discovery; add MQTT test button; move Expose panel into config accordion

This commit is contained in:
2026-06-22 20:11:54 +02:00
parent 75d685f04d
commit 35cdc7f22a
11 changed files with 502 additions and 59 deletions
+88 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import sqlite3
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
@@ -656,3 +656,90 @@ def test_put_config_bool_field_roundtrip_true_false(
finally:
conn.close()
assert rows.get("MQTT_ENABLED") == "false"
# ---------------------------------------------------------------------------
# M5-fix2: DB-config → runtime settings (Issue 2 regression assertions)
# ---------------------------------------------------------------------------
def test_put_config_mqtt_reconnect_uses_db_merged_settings(
client: TestClient, test_database_urls
) -> None:
"""PUT /api/config with MQTT keys must reconnect with DB-merged settings,
not bootstrap-only settings. Asserts that mqtt_manager.reconnect receives
settings where mqtt_enabled=True and mqtt_broker_host matches the submitted value.
"""
_login(client)
target_host = "mqtt.example.com"
payload = _full_config_payload({
"MQTT_ENABLED": "true",
"MQTT_BROKER_HOST": target_host,
})
mock_mgr = MagicMock()
mock_mgr.is_configured.return_value = True
with patch("app.api.routes.api.config.mqtt_manager", mock_mgr):
resp = client.put(
"/api/config",
json={"updates": payload},
headers={"X-CSRF-Token": "token"},
)
assert resp.status_code == 200
# reconnect must have been called once
assert mock_mgr.reconnect.call_count == 1, (
f"Expected mqtt_manager.reconnect called once, got {mock_mgr.reconnect.call_count}"
)
reconnect_settings = mock_mgr.reconnect.call_args[0][0]
assert reconnect_settings.mqtt_enabled is True, (
f"reconnect settings.mqtt_enabled must be True, got {reconnect_settings.mqtt_enabled!r}"
)
assert reconnect_settings.mqtt_broker_host == target_host, (
f"reconnect settings.mqtt_broker_host must be {target_host!r}, "
f"got {reconnect_settings.mqtt_broker_host!r}"
)
def test_post_mqtt_test_uses_db_broker_host(
client: TestClient, test_database_urls
) -> None:
"""POST /api/config/mqtt/test must use the DB-stored broker host, not env/bootstrap."""
_login(client)
db_host = "broker.db-configured.local"
# First write the MQTT config to DB via PUT /api/config
payload_save = _full_config_payload({
"MQTT_ENABLED": "true",
"MQTT_BROKER_HOST": db_host,
})
with patch("app.api.routes.api.config.mqtt_manager"):
save_resp = client.put(
"/api/config",
json={"updates": payload_save},
headers={"X-CSRF-Token": "token"},
)
assert save_resp.status_code == 200
# Now POST /api/config/mqtt/test — capture the host that _run_mqtt_test uses.
captured_host: list[str] = []
def _fake_run_mqtt_test(settings):
captured_host.append(settings.mqtt_broker_host)
# simulate success by just returning
return
with patch("app.api.routes.api.config._run_mqtt_test", side_effect=_fake_run_mqtt_test):
resp = client.post(
"/api/config/mqtt/test",
headers={"X-CSRF-Token": "token"},
)
assert resp.status_code == 200
assert len(captured_host) == 1, "Expected _run_mqtt_test to be called once"
assert captured_host[0] == db_host, (
f"Expected mqtt test to use DB host {db_host!r}, got {captured_host[0]!r}"
)
+47 -2
View File
@@ -367,7 +367,7 @@ class TestPostRepublish:
mock_settings.ha_discovery_enabled = True
mock_settings.ha_discovery_prefix = "homeassistant"
with patch("app.api.routes.api.expose.get_settings", return_value=mock_settings):
with patch("app.api.routes.api.expose.build_runtime_settings", return_value=mock_settings):
with patch("app.api.routes.api.expose.mqtt_manager") as mock_mqtt:
mock_mqtt.is_configured.return_value = True
mock_mqtt.is_connected = True
@@ -406,7 +406,7 @@ class TestPostRepublish:
mock_settings.mqtt_enabled = True
mock_settings.ha_discovery_enabled = True
with patch("app.api.routes.api.expose.get_settings", return_value=mock_settings):
with patch("app.api.routes.api.expose.build_runtime_settings", return_value=mock_settings):
with patch("app.api.routes.api.expose.mqtt_manager") as mock_mqtt:
mock_mqtt.is_connected = False
resp = client.post(
@@ -475,3 +475,48 @@ class TestCatalogGrouping:
components = {e["entity"]["component"] for e in catalog}
assert "binary_sensor" in components
assert "sensor" in components
# ---------------------------------------------------------------------------
# M5-fix2: DB-config → mqtt_status runtime assertion (Issue 2)
# ---------------------------------------------------------------------------
class TestMqttStatusReadsFromDB:
def test_get_expose_mqtt_configured_reflects_db_value(self, expose_client):
"""GET /api/expose must return mqtt_configured=True when MQTT is enabled+configured
in the DB (app_config), even if the bootstrap env says disabled.
This verifies that _get_mqtt_status uses build_runtime_settings (DB-merged)
rather than bare get_settings() (bootstrap-only).
"""
client, engine = expose_client
_login(client)
# Write MQTT_ENABLED=true and MQTT_BROKER_HOST into app_config directly.
# The lifespan seeds all config keys on startup, so we must UPDATE existing rows.
from datetime import UTC, datetime
from app.models.config import AppConfigEntry
now = datetime.now(UTC)
with Session(engine) as session:
for key, value in [("MQTT_ENABLED", "true"), ("MQTT_BROKER_HOST", "broker.test.local")]:
row = session.query(AppConfigEntry).filter(AppConfigEntry.key == key).first()
if row is None:
session.add(AppConfigEntry(key=key, value=value, updated_at=now))
else:
row.value = value
row.updated_at = now
session.commit()
# The bootstrap env does NOT have MQTT enabled (default False in Settings).
# But GET /api/expose should still report mqtt_configured=True because it reads DB.
with patch("app.integrations.expose._REGISTRY", []):
resp = client.get("/api/expose")
assert resp.status_code == 200
mqtt_status = resp.json()["mqtt_status"]
assert mqtt_status["mqtt_configured"] is True, (
f"Expected mqtt_configured=True from DB settings, "
f"got {mqtt_status['mqtt_configured']!r}. Full status: {mqtt_status}"
)
+61 -17
View File
@@ -340,7 +340,7 @@ def test_publish_discovery_sends_retained_for_enabled_entity(disco_db) -> None:
mock_mgr.publish.side_effect = _capture
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
):
from app.services.ha_discovery import publish_discovery
@@ -383,7 +383,7 @@ def test_publish_discovery_sends_empty_payload_for_disabled_entity(disco_db) ->
mock_mgr.publish.side_effect = _capture
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
):
from app.services.ha_discovery import publish_discovery
@@ -425,7 +425,7 @@ def test_publish_discovery_enabled_vs_disabled_payload(disco_db) -> None:
mock_mgr.publish.side_effect = _capture
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
):
from app.services.ha_discovery import publish_discovery
@@ -501,7 +501,7 @@ def test_publish_states_calls_value_getter_for_enabled_sensor() -> None:
mock_catalog = [CatalogEntry(entity=mock_entity, enabled=True)]
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
patch("app.services.ha_discovery.build_catalog", return_value=mock_catalog),
):
@@ -549,7 +549,7 @@ def test_publish_states_skips_disabled_entities() -> None:
mock_catalog = [CatalogEntry(entity=mock_entity, enabled=False)]
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
patch("app.services.ha_discovery.build_catalog", return_value=mock_catalog),
):
@@ -614,7 +614,7 @@ def test_publish_device_state_pushes_availability_and_state() -> None:
mock_device.last_poll_ok = True
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
patch("app.services.ha_discovery.build_catalog", return_value=mock_catalog),
):
@@ -658,7 +658,7 @@ def test_publish_device_state_publishes_offline_on_failed_poll() -> None:
mock_device.last_poll_ok = False
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
patch("app.services.ha_discovery.build_catalog", return_value=[]),
):
@@ -695,12 +695,13 @@ def test_publish_device_offline_publishes_offline_topic() -> None:
mock_mgr.publish.side_effect = _capture
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
):
from app.services.ha_discovery import publish_device_offline
from unittest.mock import MagicMock as _MagicMock
publish_device_offline(uuid_val)
publish_device_offline(_MagicMock(), uuid_val)
assert len(published) == 1
topic, payload = published[0]
@@ -713,13 +714,55 @@ def test_publish_device_offline_publishes_offline_topic() -> None:
# ---------------------------------------------------------------------------
def test_should_publish_true_when_db_enabled_bootstrap_disabled(disco_db) -> None:
"""_should_publish must use DB-merged settings: if DB has mqtt_enabled=True and
ha_discovery_enabled=True (bootstrap has both False), publish should proceed.
This is the core regression test for Issue 2: bare get_settings() returns bootstrap
(False); build_runtime_settings returns DB-merged (True).
"""
from app.services.config_page import build_runtime_settings
from app.services.ha_discovery import _should_publish
from app.models.config import AppConfigEntry
from datetime import UTC, datetime
now = datetime.now(UTC)
# Write MQTT + discovery enabled into app_config (DB level)
with Session(disco_db) as session:
session.add(AppConfigEntry(key="MQTT_ENABLED", value="true", updated_at=now))
session.add(AppConfigEntry(key="MQTT_BROKER_HOST", value="broker.test", updated_at=now))
session.add(AppConfigEntry(key="HA_DISCOVERY_ENABLED", value="true", updated_at=now))
session.commit()
# Bootstrap has mqtt_enabled=False and ha_discovery_enabled=False (env defaults)
from app.config import Settings
bootstrap = Settings(_env_file=None, mqtt_enabled=False, ha_discovery_enabled=False,
app_database_url=str(disco_db.url))
with Session(disco_db) as session:
runtime = build_runtime_settings(session, bootstrap)
# Simulate a connected broker for the _should_publish check
mock_mgr_connected = MagicMock()
mock_mgr_connected.is_connected = True
with patch("app.services.ha_discovery.mqtt_manager", mock_mgr_connected):
result = _should_publish(runtime)
assert result is True, (
"_should_publish must return True when DB has mqtt_enabled=True + ha_discovery_enabled=True, "
"even if bootstrap Settings has both False"
)
def test_publish_discovery_noop_when_mqtt_disabled() -> None:
"""publish_discovery must be a no-op when mqtt_enabled=False."""
settings = _make_settings(mqtt_enabled=False, ha_discovery_enabled=True)
mock_mgr = _make_mock_manager(is_connected=False)
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
):
from app.services.ha_discovery import publish_discovery
@@ -739,7 +782,7 @@ def test_publish_states_noop_when_not_connected() -> None:
mock_mgr = _make_mock_manager(is_connected=False)
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
):
from app.services.ha_discovery import publish_states
@@ -758,7 +801,7 @@ def test_publish_discovery_noop_when_ha_discovery_disabled() -> None:
mock_mgr = _make_mock_manager(is_connected=True)
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
):
from app.services.ha_discovery import publish_discovery
@@ -781,7 +824,7 @@ def test_publish_device_state_noop_when_mqtt_disabled() -> None:
mock_device.last_poll_ok = True
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
):
from app.services.ha_discovery import publish_device_state
@@ -800,12 +843,13 @@ def test_publish_device_offline_noop_when_mqtt_disabled() -> None:
mock_mgr = _make_mock_manager(is_connected=False)
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
):
from app.services.ha_discovery import publish_device_offline
from unittest.mock import MagicMock as _MagicMock
publish_device_offline("some-uuid") # must not raise
publish_device_offline(_MagicMock(), "some-uuid") # must not raise
mock_mgr.publish.assert_not_called()
@@ -984,7 +1028,7 @@ def test_publish_discovery_uses_retain_true_for_enabled_entity() -> None:
catalog = [CatalogEntry(entity=entity, enabled=True)]
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
patch("app.services.ha_discovery.build_catalog", return_value=catalog),
):
@@ -1085,7 +1129,7 @@ def test_real_provider_value_getter_returns_latest_reading_value(disco_db) -> No
mock_device.last_poll_ok = True
with (
patch("app.services.ha_discovery._get_settings", return_value=settings),
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
# NOTE: build_catalog is NOT mocked — we use the real modbus provider.
):