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.
975 lines
32 KiB
Python
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
|