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.
144 lines
4.4 KiB
Python
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"]
|