"""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