docs: add project description and server setup instructions to README

This commit is contained in:
2026-03-21 18:25:35 +00:00
parent c81815a83d
commit 6115a065c7
36 changed files with 4003 additions and 0 deletions

0
server/tests/__init__.py Normal file
View File

98
server/tests/conftest.py Normal file
View 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)

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

View 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