Files
ledgrab/server/tests/api/routes/test_snapshot_routes.py
T
alexei.dolgolyov abc204c04e feat(snapshot): include scene playlists + cycling state in snapshot
The aggregated /api/v1/snapshot poll now emits a `scene_playlists` section
(each playlist with its `is_running` flag) plus a companion `playlist_state`
key carrying the single global cycling state (running playlist, current index/
preset, dwell) — so the HA-coordinator and other low-overhead pollers get
playlist state in the same round trip as scenes/targets, matching the other
entity sections. Gated by the `scene_playlists` include-section like the rest.

Reuses the existing list_scene_playlists handler; snapshot route tests updated.
2026-06-08 16:22:47 +03:00

249 lines
9.4 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",
"scene_playlists",
"playlist_state",
"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 = []
playlist_store = MagicMock()
playlist_store.get_all_playlists.return_value = []
playlist_engine = MagicMock()
playlist_engine.get_running_playlist_id.return_value = None
playlist_engine.get_state.return_value = {
"is_running": False,
"playlist_id": None,
"playlist_name": None,
"current_index": 0,
"item_count": 0,
"current_preset_id": None,
"started_at": None,
"step_started_at": None,
"step_duration": 0.0,
}
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_scene_playlist_store] = lambda: playlist_store
app.dependency_overrides[deps.get_playlist_engine] = lambda: playlist_engine
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",
"scene_playlists",
"sync_clocks",
):
assert data[list_key] == []
for dict_key in ("target_states", "target_metrics", "device_brightness"):
assert data[dict_key] == {}
# The single global cycling state rides along with the playlist list.
assert data["playlist_state"]["is_running"] is False
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