M8-T04: reconcile DSMR ingest from meter sources
This commit is contained in:
+37
-109
@@ -187,15 +187,13 @@ def test_put_config_with_csrf_header_updates_app_name(
|
||||
assert app_name_field["value"] == "Updated via API"
|
||||
|
||||
|
||||
def test_put_config_reapplies_dsmr_subscription(
|
||||
def test_put_config_reapplies_dsmr_source_subscription(
|
||||
client: TestClient, test_database_urls
|
||||
) -> None:
|
||||
"""Saving config must re-apply the DSMR subscription so enabling DSMR ingest
|
||||
takes effect without an app restart (the route calls apply_dsmr_subscription
|
||||
with the refreshed settings)."""
|
||||
"""A config save triggers source subscription reconciliation."""
|
||||
_login(client)
|
||||
|
||||
payload = _full_config_payload({"DSMR_INGEST_ENABLED": "true"})
|
||||
payload = _full_config_payload()
|
||||
with patch("app.services.dsmr_ingest.apply_dsmr_subscription") as spy:
|
||||
response = client.put(
|
||||
"/api/config",
|
||||
@@ -205,8 +203,7 @@ def test_put_config_reapplies_dsmr_subscription(
|
||||
|
||||
assert response.status_code == 200
|
||||
spy.assert_called_once()
|
||||
applied_settings = spy.call_args.args[0]
|
||||
assert applied_settings.dsmr_ingest_enabled is True
|
||||
assert spy.call_args.args
|
||||
|
||||
|
||||
def test_put_config_blank_secret_keeps_existing_value(
|
||||
@@ -594,7 +591,6 @@ EXPECTED_CHECKBOX_FIELDS = {
|
||||
"MQTT_TLS_ENABLED",
|
||||
"HA_DISCOVERY_ENABLED",
|
||||
"MODBUS_POLLING_ENABLED",
|
||||
"DSMR_INGEST_ENABLED",
|
||||
}
|
||||
|
||||
|
||||
@@ -731,24 +727,15 @@ def test_put_config_mqtt_reconnect_uses_db_merged_settings(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_config_includes_dsmr_section(client: TestClient) -> None:
|
||||
"""GET /api/config must include a DSMR section with expected fields including DSMR_TARIFF_TOPIC."""
|
||||
def test_get_config_excludes_legacy_dsmr_section(client: TestClient) -> None:
|
||||
"""DSMR is configured through MeterSource, never the legacy config form."""
|
||||
_login(client)
|
||||
|
||||
response = client.get("/api/config")
|
||||
body = response.json()
|
||||
|
||||
section_names = {s["name"] for s in body["sections"]}
|
||||
assert "DSMR" in section_names, f"DSMR section missing; got {section_names}"
|
||||
|
||||
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
|
||||
env_names = {f["env_name"] for f in dsmr_section["fields"]}
|
||||
assert "DSMR_INGEST_ENABLED" in env_names
|
||||
assert "DSMR_MQTT_TOPIC" in env_names
|
||||
assert "DSMR_SAMPLE_INTERVAL_S" in env_names
|
||||
assert "DSMR_TARIFF_TOPIC" in env_names, (
|
||||
f"DSMR_TARIFF_TOPIC must be present in DSMR section; got {env_names}"
|
||||
)
|
||||
assert "DSMR" not in section_names
|
||||
|
||||
|
||||
def test_get_config_includes_tibber_section(client: TestClient) -> None:
|
||||
@@ -783,36 +770,46 @@ def test_get_config_tibber_api_token_is_secret(client: TestClient) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_get_config_dsmr_ingest_enabled_is_checkbox(client: TestClient) -> None:
|
||||
"""DSMR_INGEST_ENABLED must have input_type='checkbox' for correct frontend rendering."""
|
||||
def test_get_config_excludes_all_legacy_dsmr_fields(client: TestClient) -> None:
|
||||
"""Old DSMR KV values stay in DB but are not returned by the config API."""
|
||||
_login(client)
|
||||
|
||||
response = client.get("/api/config")
|
||||
body = response.json()
|
||||
|
||||
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
|
||||
enabled_field = next(f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_INGEST_ENABLED")
|
||||
|
||||
assert enabled_field["input_type"] == "checkbox", (
|
||||
f"DSMR_INGEST_ENABLED input_type should be 'checkbox', got {enabled_field['input_type']!r}"
|
||||
)
|
||||
fields = {field["env_name"] for section in body["sections"] for field in section["fields"]}
|
||||
assert not fields.intersection({"DSMR_INGEST_ENABLED", "DSMR_MQTT_TOPIC", "DSMR_SAMPLE_INTERVAL_S", "DSMR_TARIFF_TOPIC"})
|
||||
|
||||
|
||||
def test_get_config_dsmr_sample_interval_input_type_is_number(client: TestClient) -> None:
|
||||
"""DSMR_SAMPLE_INTERVAL_S must have input_type='number'."""
|
||||
def test_config_save_preserves_legacy_dsmr_kv_rows(client: TestClient, test_database_urls) -> None:
|
||||
"""A config-only save neither reads nor deletes retired DSMR configuration."""
|
||||
_login(client)
|
||||
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
("DSMR_MQTT_TOPIC", "legacy/topic"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
("DSMR_SAMPLE_INTERVAL_S", "37"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
response = client.get("/api/config")
|
||||
body = response.json()
|
||||
|
||||
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
|
||||
interval_field = next(
|
||||
f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_SAMPLE_INTERVAL_S"
|
||||
)
|
||||
|
||||
assert interval_field["input_type"] == "number", (
|
||||
f"DSMR_SAMPLE_INTERVAL_S input_type should be 'number', got {interval_field['input_type']!r}"
|
||||
response = client.put(
|
||||
"/api/config",
|
||||
json={"updates": _full_config_payload({"APP_NAME": "config-only save"})},
|
||||
headers={"X-CSRF-Token": "any-non-empty-value"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||
try:
|
||||
rows = dict(conn.execute("SELECT key, value FROM app_config WHERE key LIKE 'DSMR_%'"))
|
||||
finally:
|
||||
conn.close()
|
||||
assert rows == {"DSMR_MQTT_TOPIC": "legacy/topic", "DSMR_SAMPLE_INTERVAL_S": "37"}
|
||||
|
||||
|
||||
def test_put_config_blank_tibber_api_token_keeps_existing(
|
||||
@@ -882,75 +879,6 @@ def test_put_config_new_tibber_api_token_overwrites_existing(
|
||||
assert rows.get("TIBBER_API_TOKEN") == "new-tibber-token"
|
||||
|
||||
|
||||
def test_put_config_invalid_dsmr_sample_interval_returns_422_and_does_not_write(
|
||||
client: TestClient, test_database_urls
|
||||
) -> None:
|
||||
"""Non-integer DSMR_SAMPLE_INTERVAL_S must return 422 and not persist the bad value."""
|
||||
_login(client)
|
||||
|
||||
payload = _full_config_payload({"DSMR_SAMPLE_INTERVAL_S": "not-a-number"})
|
||||
response = client.put(
|
||||
"/api/config",
|
||||
json={"updates": payload},
|
||||
headers={"X-CSRF-Token": "token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||
try:
|
||||
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert rows.get("DSMR_SAMPLE_INTERVAL_S") != "not-a-number"
|
||||
|
||||
|
||||
def test_put_config_dsmr_tariff_topic_persists_and_reflects_in_get(
|
||||
client: TestClient, test_database_urls
|
||||
) -> None:
|
||||
"""DSMR_TARIFF_TOPIC must persist via PUT and be readable via GET /api/config."""
|
||||
_login(client)
|
||||
|
||||
new_topic = "meter/tariff/slot"
|
||||
payload = _full_config_payload({"DSMR_TARIFF_TOPIC": new_topic})
|
||||
with patch("app.services.dsmr_ingest.apply_dsmr_subscription"):
|
||||
response = client.put(
|
||||
"/api/config",
|
||||
json={"updates": payload},
|
||||
headers={"X-CSRF-Token": "token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# The updated value must appear in the GET response.
|
||||
get_resp = client.get("/api/config")
|
||||
body = get_resp.json()
|
||||
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
|
||||
tariff_field = next(f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_TARIFF_TOPIC")
|
||||
assert tariff_field["value"] == new_topic, (
|
||||
f"Expected DSMR_TARIFF_TOPIC to be {new_topic!r}, got {tariff_field['value']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_put_config_dsmr_tariff_topic_in_settings_payload(client: TestClient) -> None:
|
||||
"""DSMR_TARIFF_TOPIC must appear in _settings_payload (GET /api/config returns it)."""
|
||||
_login(client)
|
||||
|
||||
# The default value from Settings must appear in the DSMR section.
|
||||
response = client.get("/api/config")
|
||||
body = response.json()
|
||||
|
||||
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
|
||||
tariff_field = next(
|
||||
(f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_TARIFF_TOPIC"), None
|
||||
)
|
||||
assert tariff_field is not None, "DSMR_TARIFF_TOPIC must appear in DSMR config section"
|
||||
# Default value should be the DSMR reader meter-stats topic.
|
||||
assert tariff_field["value"] == "dsmr/meter-stats/electricity_tariff", (
|
||||
f"Unexpected default for DSMR_TARIFF_TOPIC: {tariff_field['value']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_config_tibber_api_token_value_masked_after_save(
|
||||
client: TestClient, test_database_urls
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user