feat: asset-based image/video sources, notification sounds, UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled
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:
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user