Files
home-automation/tests/test_api_modbus.py
T
tliu93 682e06d256
frontend / frontend (push) Successful in 2m4s
pytest / test (push) Successful in 7m32s
modbus: add explicit cascade delete (device + readings + toggles + HA cleanup)
A disabled device with historical readings still cannot be deleted by default
(409 guard stays, to prevent accidental data loss). A new explicit opt-in lets
the user remove it for real:

- DELETE /api/modbus/devices/{uuid}?cascade=true removes the device's readings
  and exposed_entity_toggle rows, then the device, in one transaction (FK order:
  readings before device). Best-effort clears the HA discovery configs first
  (MQTT not connected -> no-op, never blocks the delete). Default (no cascade)
  unchanged. Returns 200 with deletion counts.
- Frontend: a 'Force Delete (all data)' button appears only after the 409, with
  a red irreversible warning; cascade is sent only on that explicit action.
- Also corrected a stale comment: FK RESTRICT IS enforced at runtime now.
2026-06-24 17:38:52 +02:00

975 lines
32 KiB
Python

"""Tests for M5-T05: Modbus JSON API (app/api/routes/api/modbus.py).
Coverage:
- GET /api/modbus/profiles — list profiles
- GET /api/modbus/devices — list devices
- POST /api/modbus/devices — create device (profile validation, auth/CSRF)
- GET /api/modbus/devices/{uuid} — get single device
- PATCH /api/modbus/devices/{uuid} — update device (enable/disable, profile validation)
- DELETE /api/modbus/devices/{uuid} — delete with 409 guard (app-layer, not FK)
- GET /api/modbus/devices/{uuid}/latest — latest reading (found / not-found)
- GET /api/modbus/devices/{uuid}/readings — time-range + limit cap
- GET /api/modbus/devices/{uuid}/metrics — profile metric catalogue
- POST /api/modbus/devices/{uuid}/test — test-read (mocked driver, not persisted)
Auth/CSRF matrix:
- Unauthenticated read → 401
- Authenticated write without CSRF → 403
- Authenticated read → 200/201/204
- Authenticated write + CSRF → 200/201/204
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models.modbus import ModbusDevice, ModbusReading
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
_CSRF = "test-csrf-token"
def _login(client: TestClient) -> None:
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
)
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
def _create_device_payload(**overrides) -> dict[str, Any]:
base: dict[str, Any] = {
"friendly_name": "Test Meter",
"host": "10.0.0.1",
"port": 502,
"unit_id": 1,
"profile": "sdm120",
"poll_interval_s": 5,
"enabled": True,
}
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def modbus_client(auth_database):
"""TestClient + engine for Modbus API tests."""
from app.main import create_app
app_url = auth_database["app_url"]
engine = create_engine(app_url, connect_args={"check_same_thread": False})
fastapi_app = create_app()
with TestClient(fastapi_app) as test_client:
yield test_client, engine
engine.dispose()
def _make_device(engine, **kwargs) -> ModbusDevice:
"""Insert a ModbusDevice row directly and return it."""
now = datetime.now(UTC)
with Session(engine) as session:
device = ModbusDevice(
friendly_name=kwargs.get("friendly_name", "Test Meter"),
host=kwargs.get("host", "10.0.0.1"),
port=kwargs.get("port", 502),
unit_id=kwargs.get("unit_id", 1),
profile=kwargs.get("profile", "sdm120"),
poll_interval_s=kwargs.get("poll_interval_s", 5),
enabled=kwargs.get("enabled", True),
created_at=now,
updated_at=now,
)
session.add(device)
session.commit()
session.refresh(device)
# Detach from this session so caller can inspect plain attributes.
device_id = device.id
device_uuid = device.uuid
device_friendly_name = device.friendly_name
device_host = device.host
device_profile = device.profile
# Return a simple namespace-like object (uuid is what we mostly need).
class _DeviceStub:
id = device_id
uuid = device_uuid
friendly_name = device_friendly_name
host = device_host
profile = device_profile
return _DeviceStub()
def _make_reading(engine, device_id: int, recorded_at: datetime, payload: dict) -> None:
"""Insert a ModbusReading row directly."""
with Session(engine) as session:
reading = ModbusReading(
device_id=device_id,
recorded_at=recorded_at,
payload=payload,
)
session.add(reading)
session.commit()
# ---------------------------------------------------------------------------
# GET /api/modbus/profiles
# ---------------------------------------------------------------------------
def test_profiles_unauthenticated_returns_401(modbus_client):
client, _ = modbus_client
resp = client.get("/api/modbus/profiles")
assert resp.status_code == 401
def test_profiles_returns_list(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.get("/api/modbus/profiles")
assert resp.status_code == 200
body = resp.json()
assert "profiles" in body
assert isinstance(body["profiles"], list)
# sdm120 profile must be present
names = [p["name"] for p in body["profiles"]]
assert "sdm120" in names
# Each profile has name + description
for p in body["profiles"]:
assert "name" in p
assert "description" in p
# ---------------------------------------------------------------------------
# GET /api/modbus/devices
# ---------------------------------------------------------------------------
def test_list_devices_unauthenticated_returns_401(modbus_client):
client, _ = modbus_client
resp = client.get("/api/modbus/devices")
assert resp.status_code == 401
def test_list_devices_empty(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.get("/api/modbus/devices")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["total"] == 0
def test_list_devices_returns_created_device(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.get("/api/modbus/devices")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["uuid"] == device.uuid
# ---------------------------------------------------------------------------
# POST /api/modbus/devices
# ---------------------------------------------------------------------------
def test_create_device_unauthenticated_returns_401(modbus_client):
client, _ = modbus_client
resp = client.post(
"/api/modbus/devices",
json=_create_device_payload(),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_create_device_missing_csrf_returns_403(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.post("/api/modbus/devices", json=_create_device_payload())
assert resp.status_code == 403
def test_create_device_success(modbus_client):
client, engine = modbus_client
_login(client)
resp = client.post(
"/api/modbus/devices",
json=_create_device_payload(),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
body = resp.json()
assert body["friendly_name"] == "Test Meter"
assert "uuid" in body
assert len(body["uuid"]) == 36 # uuid4 format
# Verify row exists in DB
with Session(engine) as session:
devices = session.query(ModbusDevice).all()
assert len(devices) == 1
def test_create_device_invalid_profile_returns_422(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.post(
"/api/modbus/devices",
json=_create_device_payload(profile="nonexistent_profile_xyz"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
assert "nonexistent_profile_xyz" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}
# ---------------------------------------------------------------------------
def test_get_device_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}")
assert resp.status_code == 401
def test_get_device_not_found_returns_404(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.get("/api/modbus/devices/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 404
def test_get_device_returns_correct_fields(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine, friendly_name="My Meter")
resp = client.get(f"/api/modbus/devices/{device.uuid}")
assert resp.status_code == 200
body = resp.json()
assert body["uuid"] == device.uuid
assert body["friendly_name"] == "My Meter"
assert body["profile"] == "sdm120"
assert "last_poll_at" in body
assert "last_poll_ok" in body
assert "created_at" in body
assert "updated_at" in body
# ---------------------------------------------------------------------------
# PATCH /api/modbus/devices/{uuid}
# ---------------------------------------------------------------------------
def test_patch_device_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"enabled": False},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_patch_device_missing_csrf_returns_403(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"enabled": False},
)
assert resp.status_code == 403
def test_patch_device_enable_disable(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine, enabled=True)
# Disable
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"enabled": False},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert resp.json()["enabled"] is False
# Re-enable
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"enabled": True},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert resp.json()["enabled"] is True
def test_patch_device_invalid_profile_returns_422(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"profile": "nonexistent_xyz"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
def test_patch_device_updates_correct_fields(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.patch(
f"/api/modbus/devices/{device.uuid}",
json={"friendly_name": "Renamed Meter", "port": 503},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["friendly_name"] == "Renamed Meter"
assert body["port"] == 503
# Other fields unchanged
assert body["uuid"] == device.uuid
# ---------------------------------------------------------------------------
# DELETE /api/modbus/devices/{uuid}
# ---------------------------------------------------------------------------
def test_delete_device_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_delete_device_missing_csrf_returns_403(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.delete(f"/api/modbus/devices/{device.uuid}")
assert resp.status_code == 403
def test_delete_device_no_readings_succeeds(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 204
# Confirm device is gone
with Session(engine) as session:
devices = session.query(ModbusDevice).all()
assert len(devices) == 0
def test_delete_device_with_readings_returns_409(modbus_client):
"""Delete with existing readings must return 409 (application-layer check, not FK)."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
# Add a reading
_make_reading(
engine,
device_id=device.id,
recorded_at=datetime.now(UTC),
payload={"voltage": 230.0},
)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 409
assert "reading" in resp.json()["detail"].lower()
# Device still exists
with Session(engine) as session:
devices = session.query(ModbusDevice).all()
assert len(devices) == 1
def test_delete_device_not_found_returns_404(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.delete(
"/api/modbus/devices/00000000-0000-0000-0000-000000000000",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# DELETE /api/modbus/devices/{uuid}?cascade=true
# ---------------------------------------------------------------------------
def test_cascade_false_with_readings_still_returns_409(modbus_client):
"""cascade=false (default) + readings → 409, device and readings remain."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
_make_reading(engine, device.id, datetime.now(UTC), {"voltage": 230.0})
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "false"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 409
# Device and reading still in DB
with Session(engine) as session:
assert session.query(ModbusDevice).count() == 1
assert session.query(ModbusReading).count() == 1
def test_cascade_true_with_readings_deletes_device_readings_and_toggles(modbus_client):
"""cascade=true + readings → device, readings, and toggle rows all deleted; 200 response."""
from datetime import UTC
from app.models.expose import ExposedEntityToggle
client, engine = modbus_client
_login(client)
device = _make_device(engine)
# Add two readings
now = datetime.now(UTC)
_make_reading(engine, device.id, now, {"voltage": 230.0})
_make_reading(engine, device.id, now - timedelta(minutes=1), {"voltage": 229.0})
# Add an ExposedEntityToggle row for this device
with Session(engine) as session:
toggle = ExposedEntityToggle(
key=f"modbus.{device.uuid}.voltage",
enabled=True,
updated_at=now,
)
session.add(toggle)
# Add one toggle for a different device to verify isolation
other_toggle = ExposedEntityToggle(
key="modbus.other-uuid.voltage",
enabled=True,
updated_at=now,
)
session.add(other_toggle)
session.commit()
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "true"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] is True
assert body["readings_deleted"] == 2
assert body["toggles_deleted"] == 1
# All rows for this device must be gone
with Session(engine) as session:
assert session.query(ModbusDevice).count() == 0
assert session.query(ModbusReading).count() == 0
# Only the other device's toggle remains
remaining_toggles = session.query(ExposedEntityToggle).all()
assert len(remaining_toggles) == 1
assert remaining_toggles[0].key == "modbus.other-uuid.voltage"
def test_cascade_true_no_readings_deletes_device(modbus_client):
"""cascade=true + no readings → device deleted, response shows 0 counts."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "true"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] is True
assert body["readings_deleted"] == 0
assert body["toggles_deleted"] == 0
with Session(engine) as session:
assert session.query(ModbusDevice).count() == 0
def test_cascade_unauthenticated_returns_401(modbus_client):
"""Unauthenticated cascade delete request → 401."""
client, engine = modbus_client
device = _make_device(engine)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "true"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_cascade_missing_csrf_returns_403(modbus_client):
"""cascade=true without CSRF → 403."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "true"},
)
assert resp.status_code == 403
def test_cascade_mqtt_unavailable_still_deletes(modbus_client):
"""When MQTT is not connected, cascade delete must still succeed (HA cleanup is best-effort)."""
from unittest.mock import patch
client, engine = modbus_client
_login(client)
device = _make_device(engine)
_make_reading(engine, device.id, datetime.now(UTC), {"voltage": 230.0})
# Simulate MQTT not connected so clear_device_discovery is a no-op
with patch("app.services.ha_discovery.mqtt_manager") as mock_mqtt:
mock_mqtt.is_connected = False
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "true"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] is True
assert body["readings_deleted"] == 1
# DB rows gone
with Session(engine) as session:
assert session.query(ModbusDevice).count() == 0
assert session.query(ModbusReading).count() == 0
# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}/latest
# ---------------------------------------------------------------------------
def test_latest_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
assert resp.status_code == 401
def test_latest_no_readings_returns_found_false(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
assert resp.status_code == 200
body = resp.json()
assert body["found"] is False
assert body["recorded_at"] is None
assert body["payload"] is None
def test_latest_returns_most_recent_reading(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
earlier = now - timedelta(minutes=10)
later = now - timedelta(minutes=1)
_make_reading(engine, device.id, earlier, {"voltage": 228.0})
_make_reading(engine, device.id, later, {"voltage": 232.0})
resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
assert resp.status_code == 200
body = resp.json()
assert body["found"] is True
assert body["payload"] == {"voltage": 232.0}
assert body["recorded_at"] is not None
# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}/readings
# ---------------------------------------------------------------------------
def test_readings_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
assert resp.status_code == 401
def test_readings_empty_returns_empty_list(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
assert resp.status_code == 200
assert resp.json()["items"] == []
def test_readings_returns_all_by_default_ordered_ascending(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
t1 = now - timedelta(minutes=30)
t2 = now - timedelta(minutes=20)
t3 = now - timedelta(minutes=10)
_make_reading(engine, device.id, t3, {"voltage": 233.0})
_make_reading(engine, device.id, t1, {"voltage": 231.0})
_make_reading(engine, device.id, t2, {"voltage": 232.0})
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 3
# Ascending order
voltages = [item["payload"]["voltage"] for item in items]
assert voltages == [231.0, 232.0, 233.0]
def test_readings_time_window_filters_correctly(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
t_old = now - timedelta(hours=2)
t_mid = now - timedelta(hours=1)
t_new = now - timedelta(minutes=5)
_make_reading(engine, device.id, t_old, {"voltage": 228.0})
_make_reading(engine, device.id, t_mid, {"voltage": 230.0})
_make_reading(engine, device.id, t_new, {"voltage": 232.0})
# Only the last 90 minutes
start = (now - timedelta(minutes=90)).isoformat()
resp = client.get(
f"/api/modbus/devices/{device.uuid}/readings",
params={"start": start},
)
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 2
voltages = {item["payload"]["voltage"] for item in items}
assert 228.0 not in voltages
def test_readings_limit_applied(modbus_client):
"""When limit < total rows, exactly *limit* items are returned."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
for i in range(10):
_make_reading(engine, device.id, now - timedelta(minutes=i), {"voltage": float(i)})
resp = client.get(
f"/api/modbus/devices/{device.uuid}/readings",
params={"limit": 3},
)
assert resp.status_code == 200
assert len(resp.json()["items"]) == 3
def test_readings_limit_returns_most_recent_rows_ascending(modbus_client):
"""When window rows > limit, the MOST RECENT limit rows are returned, ascending.
This is the regression test for REWORK-1: previously the endpoint returned
the oldest N rows (ASC + LIMIT), causing 6h/24h charts to show stale data.
Now it returns the most recent N rows (DESC + LIMIT, then reversed to ASC).
"""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
# Insert 5 readings spread over the last 5 minutes (newest first, but order shouldn't matter).
timestamps = [now - timedelta(minutes=i) for i in range(5)] # t0=now, t1=now-1m, ..., t4=now-4m
for idx, ts in enumerate(timestamps):
_make_reading(engine, device.id, ts, {"voltage": float(200 + idx)})
# Chronologically: t4 < t3 < t2 < t1 < t0
# Voltages mapped: 204 203 202 201 200
# Request only the most recent 3 rows (limit=3).
resp = client.get(
f"/api/modbus/devices/{device.uuid}/readings",
params={"limit": 3},
)
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 3
# Response must be in ascending recorded_at order.
recorded_ats = [item["recorded_at"] for item in items]
assert recorded_ats == sorted(recorded_ats), "Items must be ascending by recorded_at"
# The 3 most recent are timestamps[0], [1], [2] → voltages 200, 201, 202.
voltages = [item["payload"]["voltage"] for item in items]
assert voltages == [202.0, 201.0, 200.0], (
f"Expected most recent 3 rows (voltages 200/201/202 ascending), got {voltages}"
)
# The oldest row (voltage 204.0, at t4=now-4m) must NOT be in the response.
assert 204.0 not in voltages, "Oldest row must not appear when limit truncates the window"
assert 203.0 not in voltages, "Second-oldest row must not appear when limit truncates"
def test_readings_limit_exceeds_max_returns_422(modbus_client):
"""A limit > 5000 must be rejected by FastAPI query validation (422)."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.get(
f"/api/modbus/devices/{device.uuid}/readings",
params={"limit": 9999},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}/metrics
# ---------------------------------------------------------------------------
def test_metrics_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.get(f"/api/modbus/devices/{device.uuid}/metrics")
assert resp.status_code == 401
def test_metrics_returns_profile_catalogue(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine, profile="sdm120")
resp = client.get(f"/api/modbus/devices/{device.uuid}/metrics")
assert resp.status_code == 200
body = resp.json()
assert body["profile"] == "sdm120"
assert isinstance(body["metrics"], list)
assert len(body["metrics"]) > 0
# Each metric has required fields
for m in body["metrics"]:
assert "key" in m
assert "label" in m
assert "unit" in m
assert "device_class" in m
# SDM120 must include voltage
keys = [m["key"] for m in body["metrics"]]
assert "voltage" in keys
# Label is human-readable (not raw snake_case for "active_power")
active_power = next((m for m in body["metrics"] if m["key"] == "active_power"), None)
assert active_power is not None
assert "Active Power" == active_power["label"]
def test_metrics_label_derived_from_key(modbus_client):
"""Verify the label derivation helper converts snake_case to Title Case."""
from app.api.routes.api.modbus import _label_from_key
assert _label_from_key("voltage") == "Voltage"
assert _label_from_key("active_power") == "Active Power"
assert _label_from_key("import_energy") == "Import Energy"
assert _label_from_key("power_factor") == "Power Factor"
# ---------------------------------------------------------------------------
# POST /api/modbus/devices/{uuid}/test
# ---------------------------------------------------------------------------
def test_test_read_unauthenticated_returns_401(modbus_client):
client, engine = modbus_client
device = _make_device(engine)
resp = client.post(
f"/api/modbus/devices/{device.uuid}/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_test_read_missing_csrf_returns_403(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.post(f"/api/modbus/devices/{device.uuid}/test")
assert resp.status_code == 403
def test_test_read_success_returns_payload_and_does_not_persist(modbus_client):
"""Successful test-read returns payload and stores nothing in modbus_reading."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
known_payload = {"voltage": 230.2, "current": 1.3}
with (
patch("app.api.routes.api.modbus.modbus_driver.read_blocks") as mock_read,
patch("app.api.routes.api.modbus.decode_profile") as mock_decode,
):
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
mock_decode.return_value = known_payload
resp = client.post(
f"/api/modbus/devices/{device.uuid}/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["payload"] == known_payload
assert body["error"] is None
# Verify nothing was persisted
with Session(engine) as session:
readings = session.query(ModbusReading).all()
assert len(readings) == 0
def test_test_read_driver_failure_returns_ok_false(modbus_client):
"""When the driver raises, test-read returns ok=False with an error message."""
from app.integrations.modbus.driver import ModbusConnectionError
client, engine = modbus_client
_login(client)
device = _make_device(engine)
with patch("app.api.routes.api.modbus.modbus_driver.read_blocks") as mock_read:
mock_read.side_effect = ModbusConnectionError("Cannot reach gateway")
resp = client.post(
f"/api/modbus/devices/{device.uuid}/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is False
assert body["error"] is not None
assert "Cannot reach gateway" in body["error"]
# Still no readings in DB
with Session(engine) as session:
readings = session.query(ModbusReading).all()
assert len(readings) == 0
def test_test_read_not_found_returns_404(modbus_client):
client, _ = modbus_client
_login(client)
resp = client.post(
"/api/modbus/devices/00000000-0000-0000-0000-000000000000/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Row count integrity: CRUD operations produce the right row count
# ---------------------------------------------------------------------------
def test_crud_row_counts(modbus_client):
"""Create 2 devices, verify row count; delete one (no readings), verify count drops."""
client, engine = modbus_client
_login(client)
resp1 = client.post(
"/api/modbus/devices",
json=_create_device_payload(friendly_name="Meter A"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp1.status_code == 201
uuid_a = resp1.json()["uuid"]
resp2 = client.post(
"/api/modbus/devices",
json=_create_device_payload(friendly_name="Meter B"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp2.status_code == 201
# 2 devices exist
with Session(engine) as session:
count = session.query(ModbusDevice).count()
assert count == 2
# Delete Meter A (no readings)
resp_del = client.delete(
f"/api/modbus/devices/{uuid_a}",
headers={"X-CSRF-Token": _CSRF},
)
assert resp_del.status_code == 204
# 1 device remains
with Session(engine) as session:
count = session.query(ModbusDevice).count()
assert count == 1
def test_readings_each_item_has_recorded_at_and_payload(modbus_client):
client, engine = modbus_client
_login(client)
device = _make_device(engine)
now = datetime.now(UTC)
_make_reading(engine, device.id, now, {"voltage": 230.0, "current": 1.5})
resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 1
item = items[0]
assert "recorded_at" in item
assert "payload" in item
assert item["payload"]["voltage"] == 230.0