Files
led-controller/tests/test_endpoints_pytest.py
Jimmy 2382ef16a1 refactor(api): migrate server to fastapi and uvicorn
Replace the Microdot-only entrypoint with a CombinedASGI app that
handles FastAPI routes (audio API, websocket, dev live-reload) while
delegating the rest to Microdot. Suppress noisy /__dev/ access logs
during live-reload polling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 10:33:38 +12:00

682 lines
22 KiB
Python

import json
import time
import uuid
from typing import Any, Dict
import pytest
from api_server import ( # noqa: E402
DummyBridge,
bridge_sent_envelope,
device_body_from_envelope,
)
def _json(resp) -> Dict[str, Any]:
# Many endpoints already set Content-Type; but be tolerant for now.
return resp.json() # pragma: no cover
def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) -> str:
for obj_id, data in list_resp_json.items():
if isinstance(data, dict) and data.get(field) == value:
return str(obj_id)
raise AssertionError(f"Could not find id for {field}={value!r}")
def _create_and_apply_profile(c, base_url: str) -> str:
"""Sequences/scenes/presets need an active profile in session."""
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
assert resp.status_code == 201
profile_id = next(iter(resp.json().keys()))
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
assert resp.status_code == 200
return str(profile_id)
def test_main_routes(server):
c = server["client"]
base_url: str = server["base_url"]
resp = c.get(f"{base_url}/")
assert resp.status_code == 200
assert "LED Controller" in resp.text
resp = c.get(f"{base_url}/favicon.ico")
assert resp.status_code == 204
resp = c.get(f"{base_url}/static/style.css")
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings/page")
assert resp.status_code == 200
assert "LED Controller" in resp.text
with c.websocket_connect("/ws") as ws:
ws.send_text('{"v":"1","select":["off"]}')
def test_settings_controller(server):
c = server["client"]
base_url: str = server["base_url"]
resp = c.get(f"{base_url}/settings")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
assert "wifi_channel" in data
resp = c.get(f"{base_url}/settings/wifi/ap")
assert resp.status_code == 200
ap = resp.json()
assert "saved_ssid" in ap
assert "active" in ap
unique_ssid = f"pytest-ssid-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/settings/wifi/ap",
json={"ssid": unique_ssid, "password": "secret", "channel": 1},
)
assert resp.status_code == 200
msg = resp.json()
assert msg["ssid"] == unique_ssid
assert msg["channel"] == 1
resp = c.post(
f"{base_url}/settings/wifi/ap",
json={"ssid": "bad-ssid", "password": "secret", "channel": 12},
)
assert resp.status_code == 400
resp = c.put(f"{base_url}/settings", json={"wifi_channel": 11})
assert resp.status_code == 200
resp = c.put(f"{base_url}/settings", json={"wifi_channel": 12})
assert resp.status_code == 400
resp = c.put(f"{base_url}/settings", json={"global_brightness": 42})
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings")
assert resp.status_code == 200
assert resp.json().get("global_brightness") == 42
resp = c.put(
f"{base_url}/settings",
json={"sequence_switch_wait": "downbeat"},
)
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings")
assert resp.json().get("sequence_switch_wait") == "downbeat"
resp = c.put(f"{base_url}/settings", json={"global_brightness": 300})
assert resp.status_code == 400
def test_profiles_presets_zones_endpoints(server, monkeypatch):
c = server["client"]
base_url: str = server["base_url"]
bridge: DummyBridge = server["bridge"]
import controllers.device as device_ctl
monkeypatch.setattr(device_ctl, "IDENTIFY_OFF_DELAY_S", 0.05)
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
assert resp.status_code == 201
created = resp.json()
assert isinstance(created, dict)
profile_id = next(iter(created.keys()))
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
assert resp.status_code == 200
resp = c.get(f"{base_url}/profiles/current")
assert resp.status_code == 200
current = resp.json()
assert str(current["id"]) == str(profile_id)
# Presets CRUD (scoped to current profile session).
resp = c.get(f"{base_url}/presets")
assert resp.status_code == 200
presets = resp.json()
assert isinstance(presets, dict)
assert presets # seeded presets should exist
first_preset_id = next(iter(presets.keys()))
resp = c.get(f"{base_url}/presets/{first_preset_id}")
assert resp.status_code == 200
assert resp.json() # dict
unique_preset_name = f"pytest-preset-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/presets",
json={
"name": unique_preset_name,
"pattern": "on",
"colors": ["#ff0000"],
"brightness": 123,
"delay": 100,
},
)
assert resp.status_code == 201
created_preset = resp.json()
new_preset_id = next(iter(created_preset.keys()))
assert created_preset[new_preset_id]["profile_id"] == str(profile_id)
resp = c.put(
f"{base_url}/presets/{new_preset_id}",
json={"brightness": 77},
)
assert resp.status_code == 200
assert resp.json()["brightness"] == 77
bridge.sent.clear()
resp = c.post(
f"{base_url}/presets/send",
json={"preset_ids": [new_preset_id], "save": False},
)
assert resp.status_code == 200
sent_result = resp.json()
assert sent_result["presets_sent"] >= 1
assert len(bridge.sent) >= 1
resp = c.delete(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 200
resp = c.get(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 404
# Zones CRUD (scoped to current profile session).
unique_zone_name = f"pytest-zone-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/zones",
json={"name": unique_zone_name, "names": ["1", "2"]},
)
assert resp.status_code == 201
created_zones = resp.json()
zone_id = next(iter(created_zones.keys()))
resp = c.get(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200
assert resp.json()["name"] == unique_zone_name
resp = c.post(f"{base_url}/zones/{zone_id}/set-current")
assert resp.status_code == 200
resp = c.get(f"{base_url}/zones/current")
assert resp.status_code == 200
assert resp.json()["zone_id"] == str(zone_id)
resp = c.put(
f"{base_url}/zones/{zone_id}",
json={"name": f"{unique_zone_name}-updated", "names": ["3"]},
)
assert resp.status_code == 200
assert resp.json()["names"] == ["3"]
resp = c.post(f"{base_url}/zones/{zone_id}/clone", json={"name": "pytest-zone-clone"})
assert resp.status_code == 201
clone_payload = resp.json()
clone_id = next(iter(clone_payload.keys()))
resp = c.get(f"{base_url}/zones/{clone_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/zones/{clone_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200
resp = c.get(f"{base_url}/profiles/{profile_id}/export")
assert resp.status_code == 200
bundle = resp.json()
assert bundle.get("kind") == "profile"
assert isinstance(bundle.get("presets"), dict)
import_name = f"pytest-imported-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/profiles/import",
json={"bundle": bundle, "name": import_name, "apply": False},
)
assert resp.status_code == 201
imported_profile_id = resp.json().get("id") or next(
k for k in resp.json().keys() if k != "id"
)
resp = c.delete(f"{base_url}/profiles/{imported_profile_id}")
assert resp.status_code == 200
resp = c.get(f"{base_url}/presets/{first_preset_id}/export")
assert resp.status_code == 200
assert resp.json().get("kind") == "preset"
resp = c.post(
f"{base_url}/presets/import",
json={"bundle": resp.json()},
)
assert resp.status_code == 201
imported_preset_id = next(iter(resp.json().keys()))
resp = c.delete(f"{base_url}/presets/{imported_preset_id}")
assert resp.status_code == 200
# Profile clone + update endpoints.
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
assert resp.status_code == 201
cloned = resp.json()
clone_profile_id = next(iter(cloned.keys()))
resp = c.post(f"{base_url}/profiles/{clone_profile_id}/apply")
assert resp.status_code == 200
resp = c.put(
f"{base_url}/profiles/current",
json={"name": f"{clone_name}-updated"},
)
assert resp.status_code == 200
resp = c.put(
f"{base_url}/profiles/{clone_profile_id}",
json={"name": f"{clone_name}-updated-2"},
)
assert resp.status_code == 200
resp = c.delete(f"{base_url}/profiles/{clone_profile_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/profiles/{profile_id}")
assert resp.status_code == 200
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
c = server["client"]
base_url: str = server["base_url"]
bridge: DummyBridge = server["bridge"]
_create_and_apply_profile(c, base_url)
# Groups.
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
assert resp.status_code == 201
groups_list = c.get(f"{base_url}/groups").json()
group_id = _find_id_by_field(groups_list, "name", unique_group_name)
resp = c.get(f"{base_url}/groups/{group_id}")
assert resp.status_code == 200
assert resp.json()["name"] == unique_group_name
resp = c.put(f"{base_url}/groups/{group_id}", json={"brightness": 10})
assert resp.status_code == 200
assert resp.json()["brightness"] == 10
resp = c.delete(f"{base_url}/groups/{group_id}")
assert resp.status_code == 200
# Sequences.
unique_seq_name = f"pytest-seq-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/sequences",
json={
"name": unique_seq_name,
"steps": [{"preset_id": "1", "group_ids": []}],
},
)
assert resp.status_code == 201
sequences_list = c.get(f"{base_url}/sequences").json()
seq_id = _find_id_by_field(sequences_list, "name", unique_seq_name)
resp = c.get(f"{base_url}/sequences/{seq_id}")
assert resp.status_code == 200
resp = c.put(f"{base_url}/sequences/{seq_id}", json={"step_duration_ms": 1234})
assert resp.status_code == 200
assert resp.json()["step_duration_ms"] == 1234
resp = c.delete(f"{base_url}/sequences/{seq_id}")
assert resp.status_code == 200
# Scenes.
unique_scene_name = f"pytest-scene-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/scenes", json={"name": unique_scene_name})
assert resp.status_code == 201
scenes_list = c.get(f"{base_url}/scenes").json()
scene_id = _find_id_by_field(scenes_list, "name", unique_scene_name)
resp = c.get(f"{base_url}/scenes/{scene_id}")
assert resp.status_code == 200
resp = c.put(f"{base_url}/scenes/{scene_id}", json={"name": unique_scene_name + "-updated"})
assert resp.status_code == 200
assert resp.json()["name"].endswith("-updated")
resp = c.delete(f"{base_url}/scenes/{scene_id}")
assert resp.status_code == 200
# Palettes.
colors = ["#112233", "#445566"]
resp = c.post(f"{base_url}/palettes", json={"colors": colors})
assert resp.status_code == 201
palette_payload = resp.json()
palette_id = str(palette_payload["id"])
resp = c.get(f"{base_url}/palettes/{palette_id}")
assert resp.status_code == 200
assert resp.json()["id"] == palette_id
resp = c.put(f"{base_url}/palettes/{palette_id}", json={"colors": ["#000000"]})
assert resp.status_code == 200
assert resp.json()["colors"] == ["#000000"]
resp = c.delete(f"{base_url}/palettes/{palette_id}")
assert resp.status_code == 200
# Devices (LED driver registry).
resp = c.get(f"{base_url}/devices")
assert resp.status_code == 200
assert resp.json() == {}
resp = c.post(f"{base_url}/devices", json={})
assert resp.status_code == 400
resp = c.post(
f"{base_url}/devices",
json={"name": "pytest-dev", "address": "aa:bb:cc:dd:ee:ff"},
)
assert resp.status_code == 201
dev_map = resp.json()
dev_id = next(iter(dev_map.keys()))
assert dev_id == "aabbccddeeff"
assert dev_map[dev_id]["name"] == "pytest-dev"
assert dev_map[dev_id]["id"] == dev_id
assert dev_map[dev_id]["type"] == "led"
assert dev_map[dev_id]["transport"] == "espnow"
assert dev_map[dev_id]["address"] == "aabbccddeeff"
resp = c.get(f"{base_url}/devices/{dev_id}")
assert resp.status_code == 200
assert resp.json()["name"] == "pytest-dev"
assert resp.json()["type"] == "led"
assert resp.json().get("connected") is None
resp = c.get(f"{base_url}/devices")
assert resp.status_code == 200
assert resp.json()[dev_id].get("connected") is None
bridge.sent.clear()
resp = c.post(f"{base_url}/devices/{dev_id}/identify")
assert resp.status_code == 200
assert resp.json().get("message")
assert len(bridge.sent) >= 1
first = bridge_sent_envelope(bridge, 0)
assert first["v"] == "1"
first_body = device_body_from_envelope(first, dev_id)
assert first_body["p"]["__identify"]["p"] == "blink"
assert first_body["p"]["__identify"]["d"] == 50
assert first_body["s"] == ["__identify"]
deadline = time.monotonic() + 2.0
while len(bridge.sent) < 2 and time.monotonic() < deadline:
time.sleep(0.02)
assert len(bridge.sent) >= 2
second = bridge_sent_envelope(bridge, 1)
second_body = device_body_from_envelope(second, dev_id)
assert second_body["s"] == ["off"]
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi",
"type": "led",
"transport": "wifi",
"address": "192.168.50.10",
"mac": "102030405060",
},
)
assert resp.status_code == 201
wid = "102030405060"
assert wid in resp.json()
assert resp.json()[wid]["transport"] == "wifi"
assert resp.json()[wid]["address"] == "192.168.50.10"
resp = c.get(f"{base_url}/devices/{wid}")
assert resp.status_code == 200
assert resp.json().get("connected") is None
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi",
"transport": "wifi",
"address": "192.168.50.11",
"mac": "102030405061",
},
)
assert resp.status_code == 201
wid2 = "102030405061"
assert wid2 in resp.json()
assert resp.json()[wid2]["name"] == "pytest-wifi"
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi-dupmac",
"transport": "wifi",
"address": "192.168.50.99",
"mac": "102030405060",
},
)
assert resp.status_code == 409
resp = c.post(
f"{base_url}/devices",
json={"name": "no-mac-wifi", "transport": "wifi", "address": "192.168.50.12"},
)
assert resp.status_code == 400
resp = c.post(
f"{base_url}/devices",
json={"name": "bad-tr", "transport": "serial"},
)
assert resp.status_code == 400
resp = c.put(f"{base_url}/devices/{dev_id}", json={"name": " "})
assert resp.status_code == 400
resp = c.put(f"{base_url}/devices/{dev_id}", json={"name": "renamed"})
assert resp.status_code == 200
assert resp.json()["name"] == "renamed"
resp = c.put(f"{base_url}/devices/{wid}", json={"name": "renamed"})
assert resp.status_code == 200
assert resp.json()["name"] == "renamed"
resp = c.delete(f"{base_url}/devices/{wid2}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/devices/{wid}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/devices/{dev_id}")
assert resp.status_code == 200
# Patterns.
resp = c.get(f"{base_url}/patterns/definitions")
assert resp.status_code == 200
definitions = resp.json()
assert isinstance(definitions, dict)
assert "colour_cycle" in definitions
cc_mode = definitions["colour_cycle"].get("mode")
assert isinstance(cc_mode, dict)
assert "0" in cc_mode and "1" in cc_mode
assert "blink" in definitions
blink_mode = definitions["blink"].get("mode")
assert not isinstance(blink_mode, dict) or len(blink_mode) < 2
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/patterns",
json={"name": pattern_id, "data": {"foo": "bar"}},
)
assert resp.status_code == 201
assert resp.json()["foo"] == "bar"
resp = c.get(f"{base_url}/patterns")
assert resp.status_code == 200
patterns_list = resp.json()
assert isinstance(patterns_list, dict)
# Runtime list merges repo ``db/pattern.json`` + driver ``.py`` names; test DB
# entries are still exposed on GET /patterns/<id> after POST.
assert "blink" in patterns_list or len(patterns_list) >= 1
resp = c.get(f"{base_url}/patterns/{pattern_id}")
assert resp.status_code == 200
assert resp.json()["foo"] == "bar"
resp = c.put(f"{base_url}/patterns/{pattern_id}", json={"baz": 1})
assert resp.status_code == 200
assert resp.json()["baz"] == 1
resp = c.delete(f"{base_url}/patterns/{pattern_id}")
assert resp.status_code == 200
# on/off are firmware-only in presets.py — no OTA file; API rejects serve/send/upload/driver.
resp = c.get(f"{base_url}/patterns/ota/file/on.py")
assert resp.status_code == 400
assert "error" in resp.json()
resp = c.post(f"{base_url}/patterns/off/send", json={})
assert resp.status_code == 400
assert "error" in resp.json()
resp = c.post(
f"{base_url}/patterns/upload",
json={"name": "on.py", "code": "class On:\n def run(self, p):\n yield\n"},
)
assert resp.status_code == 400
resp = c.post(
f"{base_url}/patterns/driver",
json={
"name": "off",
"code": "class Off:\n def run(self, p):\n yield\n",
},
)
assert resp.status_code == 400
def test_audio_api(server):
c = server["client"]
base_url = server["base_url"]
resp = c.get(f"{base_url}/api/audio/status")
assert resp.status_code == 200
body = resp.json()
assert "status" in body
assert "audio_run" in body["status"]
resp = c.get(f"{base_url}/api/audio/devices")
assert resp.status_code == 200
assert "devices" in resp.json()
resp = c.put(
f"{base_url}/api/audio/device",
json={"device_select": "default", "device_override": ""},
)
assert resp.status_code == 200
assert resp.json().get("ok") is True
resp = c.post(f"{base_url}/api/audio/reset")
assert resp.status_code == 409
resp = c.post(f"{base_url}/api/audio/stop")
assert resp.status_code == 200
assert resp.json().get("ok") is True
def test_bridge_settings_api(server, monkeypatch):
c = server["client"]
base_url = server["base_url"]
import controllers.wifi_bridge as wifi_bridge_ctl # noqa: E402
monkeypatch.setattr(wifi_bridge_ctl, "nmcli_available", lambda: True)
monkeypatch.setattr(
wifi_bridge_ctl,
"list_wifi_interfaces",
lambda: [{"device": "wlan0", "type": "wifi", "state": "connected"}],
)
async def _fake_scan(device):
_ = device
return [{"ssid": "bridge-test", "signal": 80}]
monkeypatch.setattr(wifi_bridge_ctl, "scan_wifi", _fake_scan)
resp = c.get(f"{base_url}/settings/wifi/interfaces")
assert resp.status_code == 200
assert resp.json().get("ok") is True
assert resp.json()["interfaces"][0]["device"] == "wlan0"
resp = c.get(f"{base_url}/settings/wifi/scan", params={"device": "wlan0"})
assert resp.status_code == 200
assert resp.json()["networks"][0]["ssid"] == "bridge-test"
resp = c.get(f"{base_url}/settings/wifi/bridges")
assert resp.status_code == 200
payload = resp.json()
assert payload.get("ok") is True
assert "bridge_transport" in payload
assert "bridges" in payload
resp = c.put(
f"{base_url}/settings/wifi/bridges",
json={
"bridge_transport": "serial",
"bridge_serial_port": "/dev/ttyUSB0",
"bridges": [],
},
)
assert resp.status_code == 200
assert resp.json().get("ok") is True
resp = c.get(f"{base_url}/settings/wifi/bridges")
assert resp.json().get("bridge_transport") == "serial"
def test_group_identify(server, monkeypatch):
c = server["client"]
base_url = server["base_url"]
bridge: DummyBridge = server["bridge"]
import controllers.device as device_ctl # noqa: E402
monkeypatch.setattr(device_ctl, "IDENTIFY_OFF_DELAY_S", 0.05)
_create_and_apply_profile(c, base_url)
resp = c.post(f"{base_url}/groups", json={"name": "pytest-identify-group"})
assert resp.status_code == 201
groups_list = c.get(f"{base_url}/groups").json()
group_id = _find_id_by_field(groups_list, "name", "pytest-identify-group")
resp = c.post(
f"{base_url}/devices",
json={"name": "identify-dev", "address": "aabbccddeeff"},
)
assert resp.status_code == 201
dev_id = "aabbccddeeff"
resp = c.put(
f"{base_url}/groups/{group_id}",
json={"devices": [dev_id]},
)
assert resp.status_code == 200
bridge.sent.clear()
resp = c.post(f"{base_url}/groups/{group_id}/identify")
assert resp.status_code == 200
assert resp.json().get("sent", 0) >= 1
assert len(bridge.sent) >= 1