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