chore: harden test isolation, gitignore stale src/data, mark shutdown action done

- ``tests/test_preferences_api.py`` no longer captures the auth API
  key at module-import time. The new ``client`` fixture resolves it
  inside its body and bakes the Bearer header into ``TestClient.headers``,
  so the e2e conftest swapping the global config singleton during
  collection cannot leave the test holding a stale 401-bound header.
  Same proven pattern as ``test_audio_processing_templates_api.py``.
- ``.gitignore`` now anchors ``/server/src/data/`` defensively. If the
  server is launched from ``server/src/`` (uncommon but possible during
  ad-hoc debugging), its relative ``data/`` resolves there. Templates
  now live in SQLite (``capture_templates`` / ``pattern_templates`` /
  ``postprocessing_templates`` tables); any stale ``*.json`` that
  lands in that directory is a runtime export and must not be
  committed.
- Three such stale exports were untracked at the start of the
  pre-merge audit and have been deleted from the working tree.
- ``TODO.md`` flips the shutdown-action checklist to done and notes
  that real-hardware verification (WLED + serial after Ctrl+C) is
  still pending.
This commit is contained in:
2026-04-25 15:11:39 +03:00
parent b1ee3c3942
commit 80f01d4813
3 changed files with 40 additions and 30 deletions
+5
View File
@@ -68,6 +68,11 @@ logs/
# shipped sound assets out of the CI tag checkout.
/data/
/server/data/
# Defensive: if the server is launched from server/src/ (uncommon path),
# its relative `data/` dir resolves to server/src/data/. Templates now
# live in SQLite, so any *.json that lands here is stale runtime export
# and must not be committed.
/server/src/data/
*.db
*.sqlite
*.json.bak
+14
View File
@@ -1,5 +1,19 @@
# LedGrab TODO
## Server shutdown action
Let user choose what happens to LED targets on server shutdown.
- [x] Backend storage: `shutdown_action` in `db.settings` (`"stop_targets"` default | `"nothing"`)
- [x] Backend route: `GET/PUT /api/v1/system/shutdown-action` in `system_settings.py`
- [x] Backend schema: `ShutdownActionResponse/Request` in `schemas/system.py`
- [x] Backend wiring: lifespan shutdown in `main.py` reads action, passes `restore_devices` flag to `processor_manager.stop_all()`
- [x] `processor_manager.stop_all(restore_devices: bool = True)` — when False, calls public `proc.cancel_task()` (defined on `TargetProcessor`) which awaits cancellation without restoring device state; skips `_restore_device_idle_state` loop. No reach into private `_task` attribute.
- [x] Frontend: hidden `<select>` + IconSelect in `settings.html` General tab (icons via `ICON_SQUARE` / `ICON_CIRCLE` from `core/icons.ts`)
- [x] Frontend: load/save handlers in `features/settings.ts`, wired into `openSettingsModal()`
- [x] i18n: en / ru / zh keys for label, hint, item descriptions
- [ ] Real-hardware test pending — verify that "nothing" actually leaves a WLED + a serial device on the last frame after `Ctrl+C`/SIGTERM.
## WebUI Redesign — "Lumenworks" Studio-Console Aesthetic
Full-app UI/UX refresh. Design direction committed to by user 2026-04-24.
+21 -30
View File
@@ -4,18 +4,25 @@ import pytest
from ledgrab.config import get_config
_config = get_config()
_api_key = next(iter(_config.auth.api_keys.values()), "")
AUTH_HEADERS = {"Authorization": f"Bearer {_api_key}"} if _api_key else {}
@pytest.fixture(scope="module")
def client():
"""TestClient with auth header read at fixture-build time.
The auth API key is resolved here (not at module import) so any
config-singleton mutation that happens during pytest collection —
notably ``server/tests/e2e/conftest.py`` reassigning the global
config to a different test key during collection of e2e tests —
cannot leave us holding a stale Bearer header that yields 401.
"""
from fastapi.testclient import TestClient
from ledgrab.main import app
api_key = next(iter(get_config().auth.api_keys.values()), "")
with TestClient(app, raise_server_exceptions=False) as c:
if api_key:
c.headers["Authorization"] = f"Bearer {api_key}"
yield c
@@ -61,8 +68,8 @@ def _minimal_layout() -> dict:
def test_get_dashboard_layout_default_empty(client):
"""When no layout has been saved, GET returns an empty object."""
# Clear first so this test is order-independent.
client.delete("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS)
resp = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS)
client.delete("/api/v1/preferences/dashboard-layout")
resp = client.get("/api/v1/preferences/dashboard-layout")
assert resp.status_code == 200
assert resp.json() == {}
@@ -70,15 +77,11 @@ def test_get_dashboard_layout_default_empty(client):
def test_put_then_get_dashboard_layout(client):
"""PUT a layout, GET it back unchanged."""
layout = _minimal_layout()
put = client.put(
"/api/v1/preferences/dashboard-layout",
json=layout,
headers=AUTH_HEADERS,
)
put = client.put("/api/v1/preferences/dashboard-layout", json=layout)
assert put.status_code == 200
assert put.json() == {"ok": True}
got = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS)
got = client.get("/api/v1/preferences/dashboard-layout")
assert got.status_code == 200
body = got.json()
assert body["version"] == 1
@@ -90,11 +93,7 @@ def test_put_then_get_dashboard_layout(client):
def test_put_rejects_missing_version(client):
"""Body without numeric version field is rejected with 422."""
bad = {"sections": []}
resp = client.put(
"/api/v1/preferences/dashboard-layout",
json=bad,
headers=AUTH_HEADERS,
)
resp = client.put("/api/v1/preferences/dashboard-layout", json=bad)
assert resp.status_code == 422
@@ -103,24 +102,16 @@ def test_put_rejects_non_object(client):
resp = client.put(
"/api/v1/preferences/dashboard-layout",
json=["not", "an", "object"],
headers=AUTH_HEADERS,
)
assert resp.status_code in (400, 422)
def test_delete_clears_layout(client):
"""DELETE wipes the saved layout so subsequent GET returns empty."""
client.put(
"/api/v1/preferences/dashboard-layout",
json=_minimal_layout(),
headers=AUTH_HEADERS,
)
deleted = client.delete(
"/api/v1/preferences/dashboard-layout",
headers=AUTH_HEADERS,
)
client.put("/api/v1/preferences/dashboard-layout", json=_minimal_layout())
deleted = client.delete("/api/v1/preferences/dashboard-layout")
assert deleted.status_code == 200
after = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS)
after = client.get("/api/v1/preferences/dashboard-layout")
assert after.status_code == 200
assert after.json() == {}
@@ -139,7 +130,7 @@ def test_layout_round_trip_preserves_unknown_fields(client):
"options": {"sensitivity": 0.7},
}
)
client.put("/api/v1/preferences/dashboard-layout", json=layout, headers=AUTH_HEADERS)
got = client.get("/api/v1/preferences/dashboard-layout", headers=AUTH_HEADERS).json()
client.put("/api/v1/preferences/dashboard-layout", json=layout)
got = client.get("/api/v1/preferences/dashboard-layout").json()
assert got["futureField"] == {"foo": "bar"}
assert any(s["key"] == "audio-meters" for s in got["sections"])