a5effba553
Backend
- snapshot: GET /api/v1/snapshot aggregates targets, devices, sources,
presets and system into one payload for the HA coordinator, collapsing
the prior ~2N+M request fan-out; per-section ?include= gating.
- graph: GET /api/v1/graph{,/schema,/dependents} backed by a pure,
unit-tested graph_schema engine — one authoritative connectable-field
registry so the editor no longer hard-codes topology in two places.
- devices: thread mqtt_source_id through DeviceCreate/Update/Response and
the routes for multi-broker MQTT; shared validate_mqtt_source_exists
(_mqtt_validation.py) reused by device + output-target routes; stop
update_device masking intentional 4xx as 500.
- shutdown: bound uvicorn graceful-shutdown via GRACEFUL_SHUTDOWN_TIMEOUT
(shared by __main__, android_entry, demo) so a lingering events WebSocket
can't strand LED targets or block process exit.
- access log: structured _access_log middleware attributing each request to
its authenticated token label (never the secret); uvicorn access_log off.
Frontend
- graph editor: generic schema-driven port/edge rendering, layout and
connection handling; service-worker refresh.
- device modals: MQTT broker EntitySelect for device_type=mqtt in add-device
and settings, wired into load/save/validate/dirty-check/clone.
- i18n: en/ru/zh keys.
Tests: graph routes + schema, snapshot routes, access log, mqtt_source_id
device regressions, bounded-shutdown entrypoint. 1614 passed.
226 lines
8.6 KiB
Python
226 lines
8.6 KiB
Python
"""Tests for the aggregated /api/v1/snapshot endpoint.
|
|
|
|
The snapshot collapses the integration's per-target/per-device poll fan-out
|
|
into one response. These tests build a minimal app with the snapshot router and
|
|
override the store/manager getters, mirroring tests/api/routes/test_devices_routes.py.
|
|
Auth is left real (the conftest patches a test API key) so the rejection path is
|
|
also covered. System metrics + health run for real — they read module-level
|
|
providers and the patched config, no lifespan needed.
|
|
"""
|
|
|
|
import types
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ledgrab.api import dependencies as deps
|
|
from ledgrab.api.routes import devices as devices_mod
|
|
from ledgrab.api.routes.devices import resolve_device_brightness
|
|
from ledgrab.api.routes.snapshot import SNAPSHOT_SECTIONS, _resolve_sections, router
|
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
|
from ledgrab.core.update.update_service import UpdateService
|
|
|
|
_AUTH = {"Authorization": "Bearer test-api-key-12345"}
|
|
|
|
_TOP_LEVEL_KEYS = (
|
|
"targets",
|
|
"target_states",
|
|
"target_metrics",
|
|
"devices",
|
|
"device_brightness",
|
|
"css_sources",
|
|
"value_sources",
|
|
"scene_presets",
|
|
"sync_clocks",
|
|
"system",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(test_config, monkeypatch):
|
|
# Pin the global config (with its test API key) so auth is deterministic
|
|
# regardless of test ordering — other suites mutate the config singleton.
|
|
import ledgrab.config as config_mod
|
|
|
|
monkeypatch.setattr(config_mod, "config", test_config)
|
|
|
|
target_store = MagicMock()
|
|
target_store.get_all_targets.return_value = []
|
|
device_store = MagicMock()
|
|
device_store.get_all_devices.return_value = []
|
|
css_store = MagicMock()
|
|
css_store.get_all_sources.return_value = []
|
|
value_store = MagicMock()
|
|
value_store.get_all_sources.return_value = []
|
|
preset_store = MagicMock()
|
|
preset_store.get_all_presets.return_value = []
|
|
clock_store = MagicMock()
|
|
clock_store.get_all_clocks.return_value = []
|
|
clock_manager = MagicMock()
|
|
|
|
manager = MagicMock(spec=ProcessorManager)
|
|
manager.get_all_target_states.return_value = {}
|
|
manager.get_all_target_metrics.return_value = {}
|
|
|
|
update_service = MagicMock(spec=UpdateService)
|
|
update_service.get_status.return_value = {"has_update": False, "current_version": "test"}
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[deps.get_output_target_store] = lambda: target_store
|
|
app.dependency_overrides[deps.get_device_store] = lambda: device_store
|
|
app.dependency_overrides[deps.get_color_strip_store] = lambda: css_store
|
|
app.dependency_overrides[deps.get_value_source_store] = lambda: value_store
|
|
app.dependency_overrides[deps.get_scene_preset_store] = lambda: preset_store
|
|
app.dependency_overrides[deps.get_sync_clock_store] = lambda: clock_store
|
|
app.dependency_overrides[deps.get_sync_clock_manager] = lambda: clock_manager
|
|
app.dependency_overrides[deps.get_processor_manager] = lambda: manager
|
|
app.dependency_overrides[deps.get_update_service] = lambda: update_service
|
|
|
|
return TestClient(app, raise_server_exceptions=False)
|
|
|
|
|
|
def test_snapshot_returns_all_sections(client):
|
|
resp = client.get("/api/v1/snapshot", headers=_AUTH)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
for key in _TOP_LEVEL_KEYS:
|
|
assert key in data, f"snapshot missing top-level key: {key}"
|
|
|
|
for list_key in (
|
|
"targets",
|
|
"devices",
|
|
"css_sources",
|
|
"value_sources",
|
|
"scene_presets",
|
|
"sync_clocks",
|
|
):
|
|
assert data[list_key] == []
|
|
for dict_key in ("target_states", "target_metrics", "device_brightness"):
|
|
assert data[dict_key] == {}
|
|
|
|
|
|
def test_snapshot_system_block_has_health_version(client):
|
|
data = client.get("/api/v1/snapshot", headers=_AUTH).json()
|
|
|
|
system = data["system"]
|
|
assert {"performance", "health", "update"}.issubset(system)
|
|
# health drives the coordinator's version + boot-time derivation
|
|
assert system["health"]["version"]
|
|
assert "uptime_seconds" in system["health"]
|
|
|
|
|
|
def test_snapshot_requires_auth(client):
|
|
resp = client.get("/api/v1/snapshot")
|
|
|
|
assert resp.status_code in (401, 403)
|
|
|
|
|
|
def test_snapshot_include_filters_to_requested_sections(client):
|
|
resp = client.get("/api/v1/snapshot", params={"include": "devices,system"}, headers=_AUTH)
|
|
|
|
assert resp.status_code == 200
|
|
# Only requested sections are present — excluded ones are omitted entirely.
|
|
assert set(resp.json().keys()) == {"devices", "system"}
|
|
|
|
|
|
def test_snapshot_include_rejects_unknown_section(client):
|
|
resp = client.get("/api/v1/snapshot", params={"include": "devices,bogus"}, headers=_AUTH)
|
|
|
|
assert resp.status_code == 422
|
|
assert "bogus" in resp.json()["detail"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_sections — query-param parsing edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_sections_defaults_to_all_when_empty():
|
|
assert _resolve_sections(None) == frozenset(SNAPSHOT_SECTIONS)
|
|
assert _resolve_sections("") == frozenset(SNAPSHOT_SECTIONS)
|
|
|
|
|
|
def test_resolve_sections_strips_whitespace_and_dedupes():
|
|
assert _resolve_sections("devices, system ,devices") == frozenset({"devices", "system"})
|
|
|
|
|
|
def test_resolve_sections_ignores_empty_segments():
|
|
assert _resolve_sections("devices,,system,") == frozenset({"devices", "system"})
|
|
|
|
|
|
def test_resolve_sections_is_case_sensitive():
|
|
with pytest.raises(HTTPException) as exc:
|
|
_resolve_sections("Devices")
|
|
assert exc.value.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_device_brightness — cached / cold-fetch / graceful-degrade paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fake_device(**kw):
|
|
return types.SimpleNamespace(
|
|
id=kw.get("id", "d1"),
|
|
device_type=kw.get("device_type", "wled"),
|
|
url=kw.get("url", "http://x"),
|
|
software_brightness=kw.get("software_brightness", 42),
|
|
)
|
|
|
|
|
|
async def test_brightness_none_without_capability(monkeypatch):
|
|
monkeypatch.setattr(devices_mod, "get_device_capabilities", lambda _dt: set())
|
|
manager = MagicMock()
|
|
|
|
assert await resolve_device_brightness(_fake_device(), manager) is None
|
|
manager.find_device_state.assert_not_called()
|
|
|
|
|
|
async def test_brightness_returns_cached(monkeypatch):
|
|
monkeypatch.setattr(devices_mod, "get_device_capabilities", lambda _dt: {"brightness_control"})
|
|
manager = MagicMock()
|
|
manager.find_device_state.return_value = types.SimpleNamespace(hardware_brightness=128)
|
|
|
|
assert await resolve_device_brightness(_fake_device(), manager) == 128
|
|
|
|
|
|
async def test_brightness_active_fetch_and_caches_on_cold_cache(monkeypatch):
|
|
monkeypatch.setattr(devices_mod, "get_device_capabilities", lambda _dt: {"brightness_control"})
|
|
provider = MagicMock()
|
|
provider.get_brightness = AsyncMock(return_value=200)
|
|
monkeypatch.setattr(devices_mod, "get_provider", lambda _dt: provider)
|
|
ds = types.SimpleNamespace(hardware_brightness=None)
|
|
manager = MagicMock()
|
|
manager.find_device_state.return_value = ds
|
|
|
|
assert await resolve_device_brightness(_fake_device(), manager) == 200
|
|
assert ds.hardware_brightness == 200 # cached so the next poll is I/O-free
|
|
|
|
|
|
async def test_brightness_degrades_to_none_on_provider_error(monkeypatch):
|
|
monkeypatch.setattr(devices_mod, "get_device_capabilities", lambda _dt: {"brightness_control"})
|
|
provider = MagicMock()
|
|
provider.get_brightness = AsyncMock(side_effect=OSError("unreachable"))
|
|
monkeypatch.setattr(devices_mod, "get_provider", lambda _dt: provider)
|
|
manager = MagicMock()
|
|
manager.find_device_state.return_value = types.SimpleNamespace(hardware_brightness=None)
|
|
|
|
# A single unreachable device must not raise — it degrades to None.
|
|
assert await resolve_device_brightness(_fake_device(), manager) is None
|
|
|
|
|
|
async def test_brightness_falls_back_to_software_when_unsupported(monkeypatch):
|
|
monkeypatch.setattr(devices_mod, "get_device_capabilities", lambda _dt: {"brightness_control"})
|
|
provider = MagicMock()
|
|
provider.get_brightness = AsyncMock(side_effect=NotImplementedError)
|
|
monkeypatch.setattr(devices_mod, "get_provider", lambda _dt: provider)
|
|
manager = MagicMock()
|
|
manager.find_device_state.return_value = types.SimpleNamespace(hardware_brightness=None)
|
|
|
|
assert await resolve_device_brightness(_fake_device(software_brightness=42), manager) == 42
|