Files
ledgrab/server/tests/api/routes/test_graph_routes.py
T
alexei.dolgolyov a5effba553 feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers
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.
2026-05-28 22:51:04 +03:00

144 lines
4.4 KiB
Python

"""Endpoint tests for the wiring-graph router (/api/v1/graph*).
The graph router resolves stores via the ``dependencies`` getters *directly*
(not FastAPI ``Depends``), so these tests populate the ``deps._deps`` registry
rather than using ``app.dependency_overrides``. Auth stays real (conftest pins a
test API key) so the rejection path is covered too.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from ledgrab.api import dependencies as deps
from ledgrab.api.routes.graph import router
_AUTH = {"Authorization": "Bearer test-api-key-12345"}
class _Ent:
"""Minimal stand-in for a storage model exposing ``to_dict``."""
def __init__(self, data: dict):
self._data = data
def to_dict(self) -> dict:
return self._data
def _store(*entities: dict):
class _S:
def get_all(self):
return [_Ent(e) for e in entities]
return _S()
@pytest.fixture
def client(test_config, monkeypatch):
import ledgrab.config as config_mod
monkeypatch.setattr(config_mod, "config", test_config)
# Populate only the kinds under test; the rest resolve to RuntimeError and
# are gracefully treated as empty by the route's _gather_entities.
monkeypatch.setattr(
deps,
"_deps",
{
"device_store": _store({"id": "dev_1", "name": "Strip", "device_type": "wled"}),
"picture_source_store": _store({"id": "ps_1", "name": "Cap", "stream_type": "raw"}),
"color_strip_store": _store(
{
"id": "css_1",
"name": "CSS",
"source_type": "picture",
"picture_source_id": "ps_1",
}
),
"output_target_store": _store(
{
"id": "ot_1",
"name": "TV",
"target_type": "led",
"device_id": "dev_1",
"color_strip_source_id": "css_1",
}
),
},
)
app = FastAPI()
app.include_router(router)
return TestClient(app, raise_server_exceptions=False)
def test_schema_endpoint_returns_registry(client):
resp = client.get("/api/v1/graph/schema", headers=_AUTH)
assert resp.status_code == 200
data = resp.json()
assert "device" in data["kinds"]
assert any(
c["target_kind"] == "output_target" and c["field"] == "device_id"
for c in data["connections"]
)
# Bindable + nested flags must be carried through.
assert any(c["bindable"] and c["nested"] for c in data["connections"])
def test_schema_endpoint_requires_auth(client):
assert client.get("/api/v1/graph/schema").status_code in (401, 403)
def test_graph_endpoint_builds_topology(client):
data = client.get("/api/v1/graph", headers=_AUTH).json()
assert {n["id"] for n in data["nodes"]} == {"dev_1", "ps_1", "css_1", "ot_1"}
edges = {(e["from"], e["to"]) for e in data["edges"]}
assert ("dev_1", "ot_1") in edges
assert ("css_1", "ot_1") in edges
assert ("ps_1", "css_1") in edges
assert data["issues"]["broken_refs"] == []
def test_dependents_endpoint(client):
data = client.get("/api/v1/graph/dependents/color_strip_source/css_1", headers=_AUTH).json()
assert data["dependents"] == [
{"id": "ot_1", "kind": "output_target", "name": "TV", "field": "color_strip_source_id"}
]
def test_dependents_endpoint_rejects_unknown_kind(client):
resp = client.get("/api/v1/graph/dependents/bogus/x", headers=_AUTH)
assert resp.status_code == 404
def test_validate_connection_endpoint_accepts_valid(client):
resp = client.post(
"/api/v1/graph/validate-connection",
json={
"target_kind": "output_target",
"target_id": "ot_1",
"field": "color_strip_source_id",
"source_id": "css_1",
},
headers=_AUTH,
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "error": None}
def test_validate_connection_endpoint_rejects_missing_source(client):
resp = client.post(
"/api/v1/graph/validate-connection",
json={
"target_kind": "output_target",
"target_id": "ot_1",
"field": "color_strip_source_id",
"source_id": "ghost",
},
headers=_AUTH,
)
data = resp.json()
assert data["ok"] is False
assert "not found" in data["error"]