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