fix(test/endpoints): add pytest coverage for all Microdot routes

This commit is contained in:
pi
2026-03-26 00:39:41 +13:00
parent 63235c7822
commit fed312a397
9 changed files with 996 additions and 123 deletions

View File

@@ -0,0 +1,594 @@
import asyncio
import builtins
import json
import os
import sys
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
import pytest
import requests
# Ensure imports resolve to the repo's `src/` + `lib/` code.
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src"
LIB_PATH = PROJECT_ROOT / "lib"
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)
from microdot import Microdot, send_file # noqa: E402
from microdot.session import Session # noqa: E402
from microdot.websocket import with_websocket # noqa: E402
class DummySender:
def __init__(self):
self.sent: list[tuple[str, Optional[str]]] = []
async def send(self, data: Any, addr: Optional[str] = None):
if isinstance(data, (bytes, bytearray)):
data = bytes(data).decode(errors="ignore")
self.sent.append((data, addr))
return True
def _json(resp: requests.Response) -> 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 _start_microdot_server(app: Microdot, host: str, port: int):
"""
Start Microdot server on a background thread.
Returns (thread, chosen_port).
"""
def runner():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.start_server(host=host, port=port))
finally:
try:
loop.close()
except Exception:
pass
thread = threading.Thread(target=runner, daemon=True)
thread.start()
# Poll until the socket is bound and app.server is available.
chosen_port = None
deadline = time.time() + 5.0
while time.time() < deadline:
server = getattr(app, "server", None)
if server and getattr(server, "sockets", None):
sockets = server.sockets or []
if sockets:
chosen_port = sockets[0].getsockname()[1]
break
time.sleep(0.05)
if chosen_port is None:
raise RuntimeError("Microdot server failed to start in time")
return thread, chosen_port
@pytest.fixture(scope="function")
def server(monkeypatch, tmp_path_factory):
"""
Start the Microdot app in-process and return a test client.
"""
tmp_root = tmp_path_factory.mktemp("endpoint-tests")
tmp_db_dir = tmp_root / "db"
tmp_settings_file = tmp_root / "settings.json"
# Be defensive: pytest runners can sometimes alter sys.path ordering.
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)
# Patch Settings so endpoint tests never touch real `settings.json`.
import settings as settings_mod # noqa: E402
settings_mod.Settings.SETTINGS_FILE = str(tmp_settings_file)
# Patch the Model db directory so endpoint CRUD is isolated.
import models.model as model_mod # noqa: E402
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(tmp_db_dir))
# Reset model singletons (controllers instantiate model classes at import time).
# Import the classes first so we can delete their `_instance` attribute if present.
import models.preset as models_preset # noqa: E402
import models.profile as models_profile # noqa: E402
import models.group as models_group # noqa: E402
import models.tab as models_tab # noqa: E402
import models.pallet as models_pallet # noqa: E402
import models.scene as models_scene # noqa: E402
import models.pattern as models_pattern # noqa: E402
import models.squence as models_sequence # noqa: E402
for cls in (
models_preset.Preset,
models_profile.Profile,
models_group.Group,
models_tab.Tab,
models_pallet.Palette,
models_scene.Scene,
models_pattern.Pattern,
models_sequence.Sequence,
):
if hasattr(cls, "_instance"):
delattr(cls, "_instance")
# Patch open() so pattern definitions work after we `chdir` into src/.
orig_open = builtins.open
def patched_open(file, *args, **kwargs):
if isinstance(file, str):
# Pattern controller loads definitions from a relative db/ path.
if file in {"db/pattern.json", "pattern.json", "/db/pattern.json"}:
file = str(PROJECT_ROOT / "db" / "pattern.json")
return orig_open(file, *args, **kwargs)
monkeypatch.setattr(builtins, "open", patched_open)
old_cwd = os.getcwd()
os.chdir(str(SRC_PATH))
dummy_sender = DummySender()
try:
# Ensure controllers are imported fresh after our patching.
for mod_name in (
"controllers.preset",
"controllers.profile",
"controllers.group",
"controllers.sequence",
"controllers.tab",
"controllers.palette",
"controllers.scene",
"controllers.pattern",
"controllers.settings",
):
sys.modules.pop(mod_name, None)
# Import controllers after patching db/settings/model singletons.
import controllers.preset as preset_ctl # noqa: E402
import controllers.profile as profile_ctl # noqa: E402
import controllers.group as group_ctl # noqa: E402
import controllers.sequence as sequence_ctl # noqa: E402
import controllers.tab as tab_ctl # noqa: E402
import controllers.palette as palette_ctl # noqa: E402
import controllers.scene as scene_ctl # noqa: E402
import controllers.pattern as pattern_ctl # noqa: E402
import controllers.settings as settings_ctl # noqa: E402
# Configure transport sender used by /presets/send.
from models.transport import set_sender # noqa: E402
set_sender(dummy_sender)
app = Microdot()
# Session secret key comes from settings (patched to tmp).
settings = settings_mod.Settings()
secret_key = settings.get(
"session_secret_key",
"led-controller-secret-key-change-in-production",
)
Session(app, secret_key=secret_key)
# Mount model controllers under their public prefixes.
app.mount(preset_ctl.controller, "/presets")
app.mount(profile_ctl.controller, "/profiles")
app.mount(group_ctl.controller, "/groups")
app.mount(sequence_ctl.controller, "/sequences")
app.mount(tab_ctl.controller, "/tabs")
app.mount(palette_ctl.controller, "/palettes")
app.mount(scene_ctl.controller, "/scenes")
app.mount(pattern_ctl.controller, "/patterns")
app.mount(settings_ctl.controller, "/settings")
@app.route("/")
def index(request):
return send_file("templates/index.html")
@app.route("/settings")
def settings_page(request):
return send_file("templates/settings.html")
@app.route("/favicon.ico")
def favicon(request):
return "", 204
@app.route("/static/<path:path>")
def static_handler(request, path):
if ".." in path:
return "Not found", 404
return send_file("static/" + path)
@app.route("/ws")
@with_websocket
async def ws(request, ws):
# Minimal websocket handler: forward raw JSON/text payloads to dummy sender.
while True:
data = await ws.receive()
if not data:
break
try:
parsed = json.loads(data)
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await dummy_sender.send(payload, addr=addr)
except Exception:
await dummy_sender.send(data)
thread, chosen_port = _start_microdot_server(app, host="127.0.0.1", port=0)
base_url = f"http://127.0.0.1:{chosen_port}"
client = requests.Session()
client.headers.update(
{
"User-Agent": "pytest/requests",
"Accept": "application/json",
}
)
yield {
"base_url": base_url,
"client": client,
"sender": dummy_sender,
"thread": thread,
"app": app,
}
finally:
# Stop server cleanly.
try:
app = locals().get("app")
if app is not None:
app.shutdown()
except Exception:
pass
# Give it a moment to close sockets.
time.sleep(0.1)
try:
thread = locals().get("thread")
if thread is not None:
thread.join(timeout=5)
except Exception:
pass
os.chdir(old_cwd)
def test_main_routes(server):
c: requests.Session = 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
resp = c.get(f"{base_url}/ws")
# WebSocket endpoints should reject non-upgraded HTTP requests.
assert resp.status_code != 200
assert resp.status_code in {400, 401, 403, 404, 405, 426}
def test_settings_controller(server):
c: requests.Session = 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/settings", json={"wifi_channel": 11})
assert resp.status_code == 200
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
assert resp.status_code == 400
def test_profiles_presets_tabs_endpoints(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
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
sender.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(sender.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
# Tabs CRUD (scoped to current profile session).
unique_tab_name = f"pytest-tab-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/tabs",
json={"name": unique_tab_name, "names": ["1", "2"]},
)
assert resp.status_code == 201
created_tabs = resp.json()
tab_id = next(iter(created_tabs.keys()))
resp = c.get(f"{base_url}/tabs/{tab_id}")
assert resp.status_code == 200
assert resp.json()["name"] == unique_tab_name
resp = c.post(f"{base_url}/tabs/{tab_id}/set-current")
assert resp.status_code == 200
resp = c.get(f"{base_url}/tabs/current")
assert resp.status_code == 200
assert resp.json()["tab_id"] == str(tab_id)
resp = c.put(
f"{base_url}/tabs/{tab_id}",
json={"name": f"{unique_tab_name}-updated", "names": ["3"]},
)
assert resp.status_code == 200
assert resp.json()["names"] == ["3"]
resp = c.post(f"{base_url}/tabs/{tab_id}/clone", json={"name": "pytest-tab-clone"})
assert resp.status_code == 201
clone_payload = resp.json()
clone_id = next(iter(clone_payload.keys()))
resp = c.get(f"{base_url}/tabs/{clone_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/tabs/{clone_id}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/tabs/{tab_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: requests.Session = server["client"]
base_url: str = server["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_group_name = f"pytest-seq-group-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/sequences",
json={"group_name": unique_seq_group_name, "presets": []},
)
assert resp.status_code == 201
sequences_list = c.get(f"{base_url}/sequences").json()
seq_id = _find_id_by_field(sequences_list, "group_name", unique_seq_group_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={"sequence_duration": 1234})
assert resp.status_code == 200
assert resp.json()["sequence_duration"] == 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
# Patterns.
resp = c.get(f"{base_url}/patterns/definitions")
assert resp.status_code == 200
definitions = resp.json()
assert isinstance(definitions, dict)
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 pattern_id in patterns_list
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