M8-R01: add configurable MQTT client identity
This commit is contained in:
@@ -454,6 +454,7 @@ def test_get_config_includes_mqtt_section(client: TestClient) -> None:
|
||||
assert "MQTT_USERNAME" in env_names
|
||||
assert "MQTT_PASSWORD" in env_names
|
||||
assert "MQTT_TLS_ENABLED" in env_names
|
||||
assert "MQTT_CLIENT_ID" in env_names
|
||||
|
||||
|
||||
def test_get_config_includes_ha_discovery_section(client: TestClient) -> None:
|
||||
@@ -578,6 +579,18 @@ def test_put_config_invalid_mqtt_port_returns_422_and_does_not_write(
|
||||
assert rows.get("MQTT_BROKER_PORT") != "not-a-number"
|
||||
|
||||
|
||||
def test_put_config_invalid_mqtt_client_id_returns_422(client: TestClient) -> None:
|
||||
_login(client)
|
||||
|
||||
response = client.put(
|
||||
"/api/config",
|
||||
json={"updates": _full_config_payload({"MQTT_CLIENT_ID": "invalid id"})},
|
||||
headers={"X-CSRF-Token": "token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M5-polish2 Area B: bool fields have input_type="checkbox"
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -722,6 +735,32 @@ def test_put_config_mqtt_reconnect_uses_db_merged_settings(
|
||||
)
|
||||
|
||||
|
||||
def test_put_config_mqtt_client_id_reconnects_and_trims_value(
|
||||
client: TestClient, test_database_urls
|
||||
) -> None:
|
||||
_login(client)
|
||||
mock_mgr = MagicMock()
|
||||
|
||||
with patch("app.api.routes.api.config.mqtt_manager", mock_mgr):
|
||||
response = client.put(
|
||||
"/api/config",
|
||||
json={"updates": _full_config_payload({"MQTT_CLIENT_ID": " home-automation-dev "})},
|
||||
headers={"X-CSRF-Token": "token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
reconnect_settings = mock_mgr.reconnect.call_args.args[0]
|
||||
assert reconnect_settings.mqtt_client_id == "home-automation-dev"
|
||||
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||
try:
|
||||
stored_value = conn.execute(
|
||||
"SELECT value FROM app_config WHERE key = 'MQTT_CLIENT_ID'"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert stored_value == ("home-automation-dev",)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M6-T02: DSMR + Tibber CONFIG_FIELDS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -112,6 +112,7 @@ def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_va
|
||||
assert rows["APP_NAME"] == "Database Owned Name"
|
||||
assert rows["HOME_ASSISTANT_BASE_URL"] == "http://bootstrap-ha.local:8123"
|
||||
assert rows["AUTH_SESSION_COOKIE_NAME"] == "home_automation_session"
|
||||
assert rows["MQTT_CLIENT_ID"] == "home-automation"
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -44,6 +45,7 @@ def fake_mqtt(monkeypatch):
|
||||
fake = _FakeMqtt()
|
||||
monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake)
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscriptions", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_subscription_client_ids", {})
|
||||
monkeypatch.setattr(dsmr_ingest, "_tariffs", {})
|
||||
return fake
|
||||
|
||||
@@ -81,6 +83,18 @@ def test_same_snapshot_has_no_subscription_churn(fake_mqtt, monkeypatch):
|
||||
assert fake_mqtt.remove_calls == []
|
||||
|
||||
|
||||
def test_changed_base_client_id_replaces_all_enabled_sources(fake_mqtt, monkeypatch):
|
||||
snapshot = _source(1, "topic", broker_host="broker.test")
|
||||
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [snapshot])
|
||||
|
||||
dsmr_ingest.apply_dsmr_subscription(SimpleNamespace(mqtt_client_id="home-automation"))
|
||||
dsmr_ingest.apply_dsmr_subscription(SimpleNamespace(mqtt_client_id="home-automation-dev"))
|
||||
|
||||
assert fake_mqtt.remove_calls == [1]
|
||||
assert [source_id for source_id, _ in fake_mqtt.replace_calls] == [1, 1]
|
||||
assert fake_mqtt.replace_calls[-1][1]["base_client_id"] == "home-automation-dev"
|
||||
|
||||
|
||||
def test_same_topic_on_different_brokers_is_allowed(fake_mqtt, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
dsmr_ingest,
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.integrations.mqtt import MqttManager
|
||||
from app.integrations.mqtt import MqttManager, mqtt_source_client_id, mqtt_test_client_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -24,6 +24,7 @@ def _make_settings(
|
||||
mqtt_username: str = "",
|
||||
mqtt_password: str = "",
|
||||
mqtt_tls_enabled: bool = False,
|
||||
mqtt_client_id: str = "home-automation",
|
||||
ha_discovery_prefix: str = "homeassistant",
|
||||
):
|
||||
"""Return a simple namespace that acts like a Settings object for MqttManager tests."""
|
||||
@@ -34,6 +35,7 @@ def _make_settings(
|
||||
s.mqtt_username = mqtt_username
|
||||
s.mqtt_password = mqtt_password
|
||||
s.mqtt_tls_enabled = mqtt_tls_enabled
|
||||
s.mqtt_client_id = mqtt_client_id
|
||||
s.ha_discovery_prefix = ha_discovery_prefix
|
||||
return s
|
||||
|
||||
@@ -120,6 +122,34 @@ def test_connect_creates_paho_client_with_version2() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_connect_uses_configured_client_id() -> None:
|
||||
manager = MqttManager()
|
||||
settings = _make_settings(mqtt_client_id="home-automation-dev")
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client) as mock_cls:
|
||||
manager.connect(settings)
|
||||
|
||||
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev"
|
||||
|
||||
|
||||
def test_deployment_client_id_variants_are_distinct() -> None:
|
||||
production_ids = {
|
||||
"home-automation",
|
||||
mqtt_source_client_id("home-automation", 7),
|
||||
mqtt_test_client_id("home-automation"),
|
||||
}
|
||||
development_ids = {
|
||||
"home-automation-dev",
|
||||
mqtt_source_client_id("home-automation-dev", 7),
|
||||
mqtt_test_client_id("home-automation-dev"),
|
||||
}
|
||||
|
||||
assert len(production_ids) == 3
|
||||
assert len(development_ids) == 3
|
||||
assert production_ids.isdisjoint(development_ids)
|
||||
|
||||
|
||||
def test_connect_sets_credentials_when_username_provided() -> None:
|
||||
manager = MqttManager()
|
||||
settings = _make_settings(mqtt_username="user", mqtt_password="s3cr3t")
|
||||
@@ -431,3 +461,17 @@ def test_run_mqtt_test_raises_connection_error_on_timeout() -> None:
|
||||
with patch("threading.Event", side_effect=_make_event):
|
||||
with pytest.raises(_MqttConnectionError, match="timed out"):
|
||||
_run_mqtt_test(settings)
|
||||
|
||||
|
||||
def test_run_mqtt_test_uses_deployment_scoped_client_id() -> None:
|
||||
from app.api.routes.api.config import _run_mqtt_test, _MqttConnectionError
|
||||
|
||||
settings = _make_settings(mqtt_client_id="home-automation-dev")
|
||||
mock_client = MagicMock()
|
||||
mock_client.connect.side_effect = OSError("Connection refused")
|
||||
|
||||
with patch("paho.mqtt.client.Client", return_value=mock_client) as mock_cls:
|
||||
with pytest.raises(_MqttConnectionError):
|
||||
_run_mqtt_test(settings)
|
||||
|
||||
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev-test"
|
||||
|
||||
@@ -251,6 +251,7 @@ def test_replace_source_uses_isolated_client_and_source_credentials() -> None:
|
||||
password="one-secret",
|
||||
tls_enabled=True,
|
||||
subscriptions={"one/topic": lambda payload: received.append(("one", payload))},
|
||||
base_client_id="home-automation-dev",
|
||||
)
|
||||
manager.replace_source(
|
||||
2,
|
||||
@@ -284,6 +285,25 @@ def test_replace_source_uses_isolated_client_and_source_credentials() -> None:
|
||||
assert received == [("two", b"two"), ("changed", b"changed")]
|
||||
|
||||
|
||||
def test_source_client_id_is_scoped_by_deployment_and_source() -> None:
|
||||
manager = MqttManager()
|
||||
client = MagicMock()
|
||||
|
||||
with patch("app.integrations.mqtt.mqtt.Client", return_value=client) as mock_cls:
|
||||
assert manager.replace_source(
|
||||
42,
|
||||
host="broker.test",
|
||||
port=1883,
|
||||
username="",
|
||||
password="",
|
||||
tls_enabled=False,
|
||||
subscriptions={"topic": lambda _payload: None},
|
||||
base_client_id="home-automation-dev",
|
||||
)
|
||||
|
||||
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev-dsmr-source-42"
|
||||
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user