docs: add project description and server setup instructions to README
This commit is contained in:
0
server/tests/__init__.py
Normal file
0
server/tests/__init__.py
Normal file
98
server/tests/conftest.py
Normal file
98
server/tests/conftest.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Shared fixtures for Demeter server tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import DeviceConfig, DevicesConfig, ResourceConfig, Settings
|
||||
from app.device_store import DeviceStore
|
||||
from app.metrics import MetricsCollector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_devices_config() -> DevicesConfig:
|
||||
"""A minimal device config for testing."""
|
||||
return DevicesConfig(
|
||||
devices=[
|
||||
DeviceConfig(
|
||||
id="test-plant-01",
|
||||
name="Test Plant",
|
||||
ip="192.168.1.100",
|
||||
port=5683,
|
||||
enabled=True,
|
||||
resources=[
|
||||
ResourceConfig(uri="sensors/soil_moisture", name="Soil Moisture", type="periodic"),
|
||||
ResourceConfig(uri="sensors/temperature", name="Temperature", type="periodic"),
|
||||
ResourceConfig(uri="events/trigger", name="Trigger", type="event"),
|
||||
],
|
||||
),
|
||||
DeviceConfig(
|
||||
id="test-aquarium-01",
|
||||
name="Test Aquarium",
|
||||
ip="192.168.1.101",
|
||||
port=5683,
|
||||
enabled=True,
|
||||
resources=[
|
||||
ResourceConfig(uri="sensors/temperature", name="Water Temp", type="periodic"),
|
||||
],
|
||||
),
|
||||
DeviceConfig(
|
||||
id="test-disabled",
|
||||
name="Disabled Device",
|
||||
ip="192.168.1.102",
|
||||
port=5683,
|
||||
enabled=False,
|
||||
resources=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(sample_devices_config: DevicesConfig) -> DeviceStore:
|
||||
"""An initialized DeviceStore with sample devices."""
|
||||
return DeviceStore(sample_devices_config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_observer() -> MagicMock:
|
||||
"""A mock CoapObserverClient for API tests."""
|
||||
observer = MagicMock()
|
||||
observer.active_subscriptions = 3
|
||||
observer.subscription_status.return_value = {
|
||||
"test-plant-01/sensors/soil_moisture": "running",
|
||||
"test-plant-01/sensors/temperature": "running",
|
||||
"test-aquarium-01/sensors/temperature": "running",
|
||||
}
|
||||
observer.coap_get = AsyncMock(return_value={
|
||||
"device": "test-plant-01",
|
||||
"temperature": 24.5,
|
||||
"unit": "celsius",
|
||||
})
|
||||
observer.coap_put = AsyncMock(return_value={"interval": 10})
|
||||
return observer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(store: DeviceStore, mock_observer: MagicMock):
|
||||
"""FastAPI app with mocked dependencies."""
|
||||
from app.main import create_app
|
||||
|
||||
test_app = create_app()
|
||||
test_app.state.store = store
|
||||
test_app.state.observer = mock_observer
|
||||
test_app.state.settings = Settings()
|
||||
return test_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app) -> TestClient:
|
||||
"""FastAPI TestClient (no lifespan — dependencies are mocked)."""
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
108
server/tests/test_device_store.py
Normal file
108
server/tests/test_device_store.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Tests for the in-memory DeviceStore.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.device_store import DeviceStore, SensorReading
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_devices(store: DeviceStore):
|
||||
"""All registered devices are returned."""
|
||||
devices = await store.get_all_devices()
|
||||
assert len(devices) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_enabled_devices(store: DeviceStore):
|
||||
"""Only enabled devices are returned."""
|
||||
devices = await store.get_enabled_devices()
|
||||
assert len(devices) == 2
|
||||
ids = {d.config.id for d in devices}
|
||||
assert "test-disabled" not in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_device_by_id(store: DeviceStore):
|
||||
"""Lookup by device ID."""
|
||||
device = await store.get_device("test-plant-01")
|
||||
assert device is not None
|
||||
assert device.config.name == "Test Plant"
|
||||
|
||||
missing = await store.get_device("nonexistent")
|
||||
assert missing is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_device_by_ip(store: DeviceStore):
|
||||
"""Lookup by IP address."""
|
||||
device = await store.get_device_by_ip("192.168.1.100")
|
||||
assert device is not None
|
||||
assert device.config.id == "test-plant-01"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_reading(store: DeviceStore):
|
||||
"""Recording a reading updates value and marks device online."""
|
||||
await store.update_reading("test-plant-01", "sensors/soil_moisture", 42.5, "percent")
|
||||
|
||||
device = await store.get_device("test-plant-01")
|
||||
assert device.online is True
|
||||
assert "sensors/soil_moisture" in device.readings
|
||||
assert device.readings["sensors/soil_moisture"].value == 42.5
|
||||
assert device.readings["sensors/soil_moisture"].unit == "percent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_reading_unknown_device(store: DeviceStore):
|
||||
"""Readings for unknown devices are silently ignored."""
|
||||
await store.update_reading("unknown-device", "sensors/x", 99, "unit")
|
||||
device = await store.get_device("unknown-device")
|
||||
assert device is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_offline(store: DeviceStore):
|
||||
"""Marking offline sets flag and marks readings stale."""
|
||||
await store.update_reading("test-plant-01", "sensors/temperature", 24.0, "celsius")
|
||||
await store.mark_offline("test-plant-01")
|
||||
|
||||
device = await store.get_device("test-plant-01")
|
||||
assert device.online is False
|
||||
assert device.readings["sensors/temperature"].stale is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_online(store: DeviceStore):
|
||||
"""Marking online resets the flag."""
|
||||
await store.mark_offline("test-plant-01")
|
||||
await store.mark_online("test-plant-01")
|
||||
|
||||
device = await store.get_device("test-plant-01")
|
||||
assert device.online is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot(store: DeviceStore):
|
||||
"""Snapshot returns a serializable dict of all devices."""
|
||||
await store.update_reading("test-plant-01", "sensors/temperature", 23.5, "celsius")
|
||||
snap = await store.snapshot()
|
||||
|
||||
assert "test-plant-01" in snap
|
||||
assert snap["test-plant-01"]["readings"]["sensors/temperature"]["value"] == 23.5
|
||||
|
||||
|
||||
def test_sensor_reading_age():
|
||||
"""SensorReading.age_seconds returns meaningful values."""
|
||||
import time
|
||||
|
||||
reading = SensorReading(value=42, unit="percent", timestamp=time.time() - 10)
|
||||
assert 9.5 < reading.age_seconds() < 11.0
|
||||
|
||||
stale = SensorReading(value=0, timestamp=0)
|
||||
assert stale.age_seconds() == float("inf")
|
||||
133
server/tests/test_devices_api.py
Normal file
133
server/tests/test_devices_api.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Tests for the REST API endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.device_store import DeviceStore
|
||||
|
||||
|
||||
class TestDevicesApi:
|
||||
"""Tests for /api/devices endpoints."""
|
||||
|
||||
def test_list_devices(self, client: TestClient):
|
||||
"""GET /api/devices returns all devices."""
|
||||
resp = client.get("/api/devices")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["count"] == 3
|
||||
assert len(data["devices"]) == 3
|
||||
|
||||
def test_get_device(self, client: TestClient):
|
||||
"""GET /api/devices/{id} returns device detail."""
|
||||
resp = client.get("/api/devices/test-plant-01")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "test-plant-01"
|
||||
assert data["name"] == "Test Plant"
|
||||
|
||||
def test_get_device_not_found(self, client: TestClient):
|
||||
"""GET /api/devices/{id} returns 404 for missing device."""
|
||||
resp = client.get("/api/devices/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_readings_empty(self, client: TestClient):
|
||||
"""GET /api/devices/{id}/readings returns empty when no data."""
|
||||
resp = client.get("/api/devices/test-plant-01/readings")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["device_id"] == "test-plant-01"
|
||||
assert data["readings"] == {}
|
||||
|
||||
def test_get_readings_with_data(self, client: TestClient, store: DeviceStore):
|
||||
"""GET /api/devices/{id}/readings returns stored readings."""
|
||||
# Populate store
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(
|
||||
store.update_reading("test-plant-01", "sensors/temperature", 25.0, "celsius")
|
||||
)
|
||||
|
||||
resp = client.get("/api/devices/test-plant-01/readings")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "sensors/temperature" in data["readings"]
|
||||
assert data["readings"]["sensors/temperature"]["value"] == 25.0
|
||||
|
||||
def test_server_status(self, client: TestClient):
|
||||
"""GET /api/status returns server info."""
|
||||
resp = client.get("/api/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["server"] == "demeter"
|
||||
assert data["devices_total"] == 3
|
||||
assert "active_subscriptions" in data
|
||||
|
||||
|
||||
class TestCoapBridge:
|
||||
"""Tests for /api/devices/{id}/coap/ bridge endpoints."""
|
||||
|
||||
def test_coap_get(self, client: TestClient):
|
||||
"""GET bridge proxies CoAP request and returns response."""
|
||||
resp = client.get("/api/devices/test-plant-01/coap/sensors/temperature")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["device_id"] == "test-plant-01"
|
||||
assert data["method"] == "GET"
|
||||
assert "response" in data
|
||||
|
||||
def test_coap_get_not_found(self, client: TestClient):
|
||||
"""GET bridge returns 404 for missing device."""
|
||||
resp = client.get("/api/devices/missing/coap/sensors/temperature")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_coap_put(self, client: TestClient):
|
||||
"""PUT bridge proxies CoAP request."""
|
||||
resp = client.put(
|
||||
"/api/devices/test-plant-01/coap/config/interval",
|
||||
json={"interval": 10},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["method"] == "PUT"
|
||||
|
||||
|
||||
class TestMetricsEndpoint:
|
||||
"""Tests for the /metrics Prometheus endpoint."""
|
||||
|
||||
def test_metrics_returns_text(self, client: TestClient):
|
||||
"""GET /metrics returns Prometheus text format."""
|
||||
resp = client.get("/metrics")
|
||||
assert resp.status_code == 200
|
||||
assert "text/plain" in resp.headers["content-type"]
|
||||
# Should contain our metric names
|
||||
assert "demeter_server_info" in resp.text
|
||||
|
||||
|
||||
class TestDashboard:
|
||||
"""Tests for dashboard HTML routes."""
|
||||
|
||||
def test_root_redirects(self, client: TestClient):
|
||||
"""GET / redirects to /dashboard."""
|
||||
resp = client.get("/", follow_redirects=False)
|
||||
assert resp.status_code == 307
|
||||
assert "/dashboard" in resp.headers["location"]
|
||||
|
||||
def test_dashboard_renders(self, client: TestClient):
|
||||
"""GET /dashboard returns HTML."""
|
||||
resp = client.get("/dashboard")
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
assert "Demeter" in resp.text
|
||||
|
||||
def test_dashboard_api_readings(self, client: TestClient):
|
||||
"""GET /dashboard/api/readings returns JSON."""
|
||||
resp = client.get("/dashboard/api/readings")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "devices" in data
|
||||
assert "timestamp" in data
|
||||
Reference in New Issue
Block a user