feat: asset-based image/video sources, notification sounds, UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled

- Replace URL-based image_source/url fields with image_asset_id/video_asset_id
  on StaticImagePictureSource and VideoCaptureSource (clean break, no migration)
- Resolve asset IDs to file paths at runtime via AssetStore.get_file_path()
- Add EntitySelect asset pickers for image/video in stream editor modal
- Add notification sound configuration (global sound + per-app overrides)
- Unify per-app color and sound overrides into single "Per-App Overrides" section
- Persist notification history between server restarts
- Add asset management system (upload, edit, delete, soft-delete)
- Replace emoji buttons with SVG icons throughout UI
- Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
This commit is contained in:
2026-03-26 20:40:25 +03:00
parent c0853ce184
commit e2e1107df7
100 changed files with 2935 additions and 992 deletions

View File

@@ -1,18 +1,47 @@
"""Shared fixtures for end-to-end API tests.
Uses the real FastAPI app with a module-scoped TestClient to avoid
repeated lifespan startup/shutdown issues. Each test function gets
fresh, empty stores via the _clear_stores helper.
Uses the real FastAPI app with a session-scoped TestClient.
All e2e tests run against an ISOLATED temporary database and assets
directory — never the production data.
"""
import shutil
import tempfile
from pathlib import Path
import pytest
from wled_controller.config import get_config
# ---------------------------------------------------------------------------
# Isolate e2e tests from production data.
#
# We must set the config singleton BEFORE wled_controller.main is imported,
# because main.py reads get_config() at module level to create the DB and
# all stores. By forcing the singleton here we guarantee the app opens a
# throwaway SQLite file in a temp directory.
# ---------------------------------------------------------------------------
_e2e_tmp = Path(tempfile.mkdtemp(prefix="wled_e2e_"))
_test_db_path = str(_e2e_tmp / "test_ledgrab.db")
_test_assets_dir = str(_e2e_tmp / "test_assets")
# Resolve the API key from the real config (same key used in production tests)
_config = get_config()
API_KEY = next(iter(_config.auth.api_keys.values()), "")
import wled_controller.config as _config_mod # noqa: E402
# Build a Config that mirrors production settings but with isolated paths.
_original_config = _config_mod.Config.load()
_test_config = _original_config.model_copy(
update={
"storage": _config_mod.StorageConfig(database_file=_test_db_path),
"assets": _config_mod.AssetsConfig(
assets_dir=_test_assets_dir,
max_file_size_mb=_original_config.assets.max_file_size_mb,
),
},
)
# Install as the global singleton so all subsequent get_config() calls
# (including main.py module-level code) use isolated paths.
_config_mod.config = _test_config
API_KEY = next(iter(_test_config.auth.api_keys.values()), "")
AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}
@@ -22,7 +51,7 @@ def _test_client():
The app's lifespan (MQTT, automation engine, health monitoring, etc.)
starts once for the entire e2e test session and shuts down after all
tests complete.
tests complete. The app uses the isolated test database set above.
"""
from fastapi.testclient import TestClient
from wled_controller.main import app
@@ -30,6 +59,9 @@ def _test_client():
with TestClient(app, raise_server_exceptions=False) as c:
yield c
# Clean up temp directory after all e2e tests finish
shutil.rmtree(_e2e_tmp, ignore_errors=True)
@pytest.fixture
def client(_test_client):
@@ -63,6 +95,7 @@ def _clear_stores():
(deps.get_sync_clock_store, "get_all", "delete"),
(deps.get_automation_store, "get_all", "delete"),
(deps.get_scene_preset_store, "get_all", "delete"),
(deps.get_asset_store, "get_all_assets", "delete_asset"),
]
for getter, list_method, delete_method in store_clearers:
try:

View File

@@ -1,9 +1,11 @@
"""E2E: Backup and restore flow.
Tests creating entities, backing up (SQLite .db file), deleting, then restoring.
Tests creating entities, backing up (ZIP containing SQLite .db + asset files),
deleting, then restoring.
"""
import io
import zipfile
class TestBackupRestoreFlow:
@@ -40,12 +42,17 @@ class TestBackupRestoreFlow:
resp = client.get("/api/v1/color-strip-sources")
assert resp.json()["count"] == 1
# 2. Create a backup (GET returns a SQLite .db file)
# 2. Create a backup (GET returns a ZIP containing ledgrab.db + assets)
resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200
backup_bytes = resp.content
# SQLite files start with this magic header
assert backup_bytes[:16].startswith(b"SQLite format 3")
# Backup is a ZIP file (PK magic bytes)
assert backup_bytes[:4] == b"PK\x03\x04"
# ZIP should contain ledgrab.db
with zipfile.ZipFile(io.BytesIO(backup_bytes)) as zf:
assert "ledgrab.db" in zf.namelist()
db_data = zf.read("ledgrab.db")
assert db_data[:16].startswith(b"SQLite format 3")
# 3. Delete all created entities
resp = client.delete(f"/api/v1/color-strip-sources/{css_id}")
@@ -59,23 +66,27 @@ class TestBackupRestoreFlow:
resp = client.get("/api/v1/color-strip-sources")
assert resp.json()["count"] == 0
# 4. Restore from backup (POST with the .db file upload)
# 4. Restore from backup (POST with the .zip file upload)
resp = client.post(
"/api/v1/system/restore",
files={"file": ("backup.db", io.BytesIO(backup_bytes), "application/octet-stream")},
files={"file": ("backup.zip", io.BytesIO(backup_bytes), "application/zip")},
)
assert resp.status_code == 200, f"Restore failed: {resp.text}"
restore_result = resp.json()
assert restore_result["status"] == "restored"
assert restore_result["restart_scheduled"] is True
def test_backup_is_valid_sqlite(self, client):
"""Backup response is a valid SQLite database file."""
def test_backup_is_valid_zip(self, client):
"""Backup response is a valid ZIP containing a SQLite database."""
resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200
assert resp.content[:16].startswith(b"SQLite format 3")
assert resp.content[:4] == b"PK\x03\x04"
# Should have Content-Disposition header for download
assert "attachment" in resp.headers.get("content-disposition", "")
# ZIP should contain ledgrab.db with valid SQLite header
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
assert "ledgrab.db" in zf.namelist()
assert zf.read("ledgrab.db")[:16].startswith(b"SQLite format 3")
def test_restore_rejects_invalid_format(self, client):
"""Uploading a non-SQLite file should fail validation."""