M5: use DB-merged runtime settings for MQTT/discovery; add MQTT test button; move Expose panel into config accordion
This commit is contained in:
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user