fix: update test fixtures for SQLite storage migration
Some checks failed
Lint & Test / test (push) Failing after 1m33s

All store tests were passing file paths instead of Database objects
after the JSON-to-SQLite migration. Updated fixtures to create temp
Database instances, rewrote backup e2e tests for binary .db format,
and fixed config tests for the simplified StorageConfig.
This commit is contained in:
2026-03-25 11:38:07 +03:00
parent 9dfd2365f4
commit 2da5c047f9
12 changed files with 135 additions and 126 deletions

View File

@@ -30,13 +30,21 @@ def _make_app():
@pytest.fixture @pytest.fixture
def device_store(tmp_path): def _route_db(tmp_path):
return DeviceStore(tmp_path / "devices.json") from wled_controller.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture @pytest.fixture
def output_target_store(tmp_path): def device_store(_route_db):
return OutputTargetStore(str(tmp_path / "output_targets.json")) return DeviceStore(_route_db)
@pytest.fixture
def output_target_store(_route_db):
return OutputTargetStore(_route_db)
@pytest.fixture @pytest.fixture

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
import pytest import pytest
from wled_controller.config import Config, StorageConfig, ServerConfig, AuthConfig from wled_controller.config import Config, StorageConfig, ServerConfig, AuthConfig
from wled_controller.storage.database import Database
from wled_controller.storage.device_store import Device, DeviceStore from wled_controller.storage.device_store import Device, DeviceStore
from wled_controller.storage.sync_clock import SyncClock from wled_controller.storage.sync_clock import SyncClock
from wled_controller.storage.sync_clock_store import SyncClockStore from wled_controller.storage.sync_clock_store import SyncClockStore
@@ -37,12 +38,25 @@ def test_config_dir(tmp_path):
@pytest.fixture @pytest.fixture
def temp_store_dir(tmp_path): def temp_store_dir(tmp_path):
"""Provide a temp directory for JSON store files, cleaned up after tests.""" """Provide a temp directory for store files, cleaned up after tests."""
d = tmp_path / "stores" d = tmp_path / "stores"
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d return d
# ---------------------------------------------------------------------------
# Database fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_db(tmp_path):
"""Provide a temporary SQLite Database instance."""
db = Database(tmp_path / "test.db")
yield db
db.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config fixtures # Config fixtures
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -55,20 +69,7 @@ def test_config(tmp_path):
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
storage = StorageConfig( storage = StorageConfig(
devices_file=str(data_dir / "devices.json"), database_file=str(data_dir / "test.db"),
templates_file=str(data_dir / "capture_templates.json"),
postprocessing_templates_file=str(data_dir / "postprocessing_templates.json"),
picture_sources_file=str(data_dir / "picture_sources.json"),
output_targets_file=str(data_dir / "output_targets.json"),
pattern_templates_file=str(data_dir / "pattern_templates.json"),
color_strip_sources_file=str(data_dir / "color_strip_sources.json"),
audio_sources_file=str(data_dir / "audio_sources.json"),
audio_templates_file=str(data_dir / "audio_templates.json"),
value_sources_file=str(data_dir / "value_sources.json"),
automations_file=str(data_dir / "automations.json"),
scene_presets_file=str(data_dir / "scene_presets.json"),
color_strip_processing_templates_file=str(data_dir / "color_strip_processing_templates.json"),
sync_clocks_file=str(data_dir / "sync_clocks.json"),
) )
return Config( return Config(
@@ -84,33 +85,33 @@ def test_config(tmp_path):
@pytest.fixture @pytest.fixture
def device_store(temp_store_dir): def device_store(tmp_db):
"""Provide a DeviceStore backed by a temp file.""" """Provide a DeviceStore backed by a temp database."""
return DeviceStore(temp_store_dir / "devices.json") return DeviceStore(tmp_db)
@pytest.fixture @pytest.fixture
def sync_clock_store(temp_store_dir): def sync_clock_store(tmp_db):
"""Provide a SyncClockStore backed by a temp file.""" """Provide a SyncClockStore backed by a temp database."""
return SyncClockStore(str(temp_store_dir / "sync_clocks.json")) return SyncClockStore(tmp_db)
@pytest.fixture @pytest.fixture
def output_target_store(temp_store_dir): def output_target_store(tmp_db):
"""Provide an OutputTargetStore backed by a temp file.""" """Provide an OutputTargetStore backed by a temp database."""
return OutputTargetStore(str(temp_store_dir / "output_targets.json")) return OutputTargetStore(tmp_db)
@pytest.fixture @pytest.fixture
def automation_store(temp_store_dir): def automation_store(tmp_db):
"""Provide an AutomationStore backed by a temp file.""" """Provide an AutomationStore backed by a temp database."""
return AutomationStore(str(temp_store_dir / "automations.json")) return AutomationStore(tmp_db)
@pytest.fixture @pytest.fixture
def value_source_store(temp_store_dir): def value_source_store(tmp_db):
"""Provide a ValueSourceStore backed by a temp file.""" """Provide a ValueSourceStore backed by a temp database."""
return ValueSourceStore(str(temp_store_dir / "value_sources.json")) return ValueSourceStore(tmp_db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -25,8 +25,8 @@ from wled_controller.storage.automation_store import AutomationStore
@pytest.fixture @pytest.fixture
def mock_store(tmp_path) -> AutomationStore: def mock_store(tmp_db) -> AutomationStore:
return AutomationStore(str(tmp_path / "auto.json")) return AutomationStore(tmp_db)
@pytest.fixture @pytest.fixture

View File

@@ -46,10 +46,12 @@ def client(_test_client):
def _clear_stores(): def _clear_stores():
"""Remove all entities from all stores for test isolation.""" """Remove all entities from all stores for test isolation."""
# Reset the saves-frozen flag that freeze_saves() sets during restore flows. # Reset frozen-writes flags that restore flows set.
# Without this, subsequent tests can't persist data because _save() is a no-op. # Without this, subsequent tests can't persist data.
from wled_controller.storage.base_store import unfreeze_saves from wled_controller.storage.base_store import unfreeze_saves
unfreeze_saves() unfreeze_saves()
import wled_controller.storage.database as _db_mod
_db_mod._writes_frozen = False
from wled_controller.api import dependencies as deps from wled_controller.api import dependencies as deps

View File

@@ -1,11 +1,9 @@
"""E2E: Backup and restore flow. """E2E: Backup and restore flow.
Tests creating entities, backing up, deleting, then restoring from backup. Tests creating entities, backing up (SQLite .db file), deleting, then restoring.
""" """
import io import io
import json
class TestBackupRestoreFlow: class TestBackupRestoreFlow:
@@ -42,20 +40,12 @@ class TestBackupRestoreFlow:
resp = client.get("/api/v1/color-strip-sources") resp = client.get("/api/v1/color-strip-sources")
assert resp.json()["count"] == 1 assert resp.json()["count"] == 1
# 2. Create a backup (GET returns a JSON file) # 2. Create a backup (GET returns a SQLite .db file)
resp = client.get("/api/v1/system/backup") resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200 assert resp.status_code == 200
backup_data = resp.json() backup_bytes = resp.content
assert backup_data["meta"]["format"] == "ledgrab-backup" # SQLite files start with this magic header
assert "stores" in backup_data assert backup_bytes[:16].startswith(b"SQLite format 3")
assert "devices" in backup_data["stores"]
assert "color_strip_sources" in backup_data["stores"]
# Verify device is in the backup.
# Store files have structure: {"version": "...", "devices": {id: {...}}}
devices_store = backup_data["stores"]["devices"]
assert "devices" in devices_store
assert len(devices_store["devices"]) == 1
# 3. Delete all created entities # 3. Delete all created entities
resp = client.delete(f"/api/v1/color-strip-sources/{css_id}") resp = client.delete(f"/api/v1/color-strip-sources/{css_id}")
@@ -69,52 +59,37 @@ class TestBackupRestoreFlow:
resp = client.get("/api/v1/color-strip-sources") resp = client.get("/api/v1/color-strip-sources")
assert resp.json()["count"] == 0 assert resp.json()["count"] == 0
# 4. Restore from backup (POST with the backup JSON as a file upload) # 4. Restore from backup (POST with the .db file upload)
backup_bytes = json.dumps(backup_data).encode("utf-8")
resp = client.post( resp = client.post(
"/api/v1/system/restore", "/api/v1/system/restore",
files={"file": ("backup.json", io.BytesIO(backup_bytes), "application/json")}, files={"file": ("backup.db", io.BytesIO(backup_bytes), "application/octet-stream")},
) )
assert resp.status_code == 200, f"Restore failed: {resp.text}" assert resp.status_code == 200, f"Restore failed: {resp.text}"
restore_result = resp.json() restore_result = resp.json()
assert restore_result["status"] == "restored" assert restore_result["status"] == "restored"
assert restore_result["stores_written"] > 0
# 5. After restore, stores are written to disk but the in-memory
# stores haven't been re-loaded (normally a server restart does that).
# Verify the backup file was written correctly by reading it back.
# The restore endpoint writes JSON files; we check the response confirms success.
assert restore_result["restart_scheduled"] is True assert restore_result["restart_scheduled"] is True
def test_backup_contains_all_store_keys(self, client): def test_backup_is_valid_sqlite(self, client):
"""Backup response includes entries for all known store types.""" """Backup response is a valid SQLite database file."""
resp = client.get("/api/v1/system/backup") resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200 assert resp.status_code == 200
stores = resp.json()["stores"] assert resp.content[:16].startswith(b"SQLite format 3")
# At minimum, these critical stores should be present # Should have Content-Disposition header for download
expected_keys = { assert "attachment" in resp.headers.get("content-disposition", "")
"devices", "output_targets", "color_strip_sources",
"capture_templates", "value_sources",
}
assert expected_keys.issubset(set(stores.keys()))
def test_restore_rejects_invalid_format(self, client): def test_restore_rejects_invalid_format(self, client):
"""Uploading a non-backup JSON file should fail validation.""" """Uploading a non-SQLite file should fail validation."""
bad_data = json.dumps({"not": "a backup"}).encode("utf-8") bad_data = b"not a database file at all, just random text content"
resp = client.post( resp = client.post(
"/api/v1/system/restore", "/api/v1/system/restore",
files={"file": ("bad.json", io.BytesIO(bad_data), "application/json")}, files={"file": ("bad.db", io.BytesIO(bad_data), "application/octet-stream")},
) )
assert resp.status_code == 400 assert resp.status_code == 400
def test_restore_rejects_empty_stores(self, client): def test_restore_rejects_empty_file(self, client):
"""A backup with no recognized stores should fail.""" """A tiny file should fail validation."""
bad_backup = {
"meta": {"format": "ledgrab-backup", "format_version": 1},
"stores": {"unknown_store": {}},
}
resp = client.post( resp = client.post(
"/api/v1/system/restore", "/api/v1/system/restore",
files={"file": ("bad.json", io.BytesIO(json.dumps(bad_backup).encode()), "application/json")}, files={"file": ("tiny.db", io.BytesIO(b"x" * 50), "application/octet-stream")},
) )
assert resp.status_code == 400 assert resp.status_code == 400

View File

@@ -18,8 +18,8 @@ from wled_controller.storage.automation_store import AutomationStore
@pytest.fixture @pytest.fixture
def store(tmp_path) -> AutomationStore: def store(tmp_db) -> AutomationStore:
return AutomationStore(str(tmp_path / "automations.json")) return AutomationStore(tmp_db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -240,16 +240,18 @@ class TestAutomationNameUniqueness:
class TestAutomationPersistence: class TestAutomationPersistence:
def test_persist_and_reload(self, tmp_path): def test_persist_and_reload(self, tmp_path):
path = str(tmp_path / "auto_persist.json") from wled_controller.storage.database import Database
s1 = AutomationStore(path) db = Database(tmp_path / "auto_persist.db")
s1 = AutomationStore(db)
a = s1.create_automation( a = s1.create_automation(
name="Persist", name="Persist",
conditions=[WebhookCondition(token="t1")], conditions=[WebhookCondition(token="t1")],
) )
aid = a.id aid = a.id
s2 = AutomationStore(path) s2 = AutomationStore(db)
loaded = s2.get_automation(aid) loaded = s2.get_automation(aid)
assert loaded.name == "Persist" assert loaded.name == "Persist"
assert len(loaded.conditions) == 1 assert len(loaded.conditions) == 1
assert isinstance(loaded.conditions[0], WebhookCondition) assert isinstance(loaded.conditions[0], WebhookCondition)
db.close()

View File

@@ -14,13 +14,16 @@ from wled_controller.storage.device_store import Device, DeviceStore
@pytest.fixture @pytest.fixture
def temp_storage(tmp_path) -> Path: def tmp_db(tmp_path):
return tmp_path / "devices.json" from wled_controller.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture @pytest.fixture
def store(temp_storage) -> DeviceStore: def store(tmp_db) -> DeviceStore:
return DeviceStore(temp_storage) return DeviceStore(tmp_db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -240,23 +243,29 @@ class TestDeviceNameUniqueness:
class TestDevicePersistence: class TestDevicePersistence:
def test_persistence_across_instances(self, temp_storage): def test_persistence_across_instances(self, tmp_path):
s1 = DeviceStore(temp_storage) from wled_controller.storage.database import Database
db = Database(tmp_path / "persist.db")
s1 = DeviceStore(db)
d = s1.create_device(name="Persist", url="http://p", led_count=77) d = s1.create_device(name="Persist", url="http://p", led_count=77)
did = d.id did = d.id
s2 = DeviceStore(temp_storage) s2 = DeviceStore(db)
loaded = s2.get_device(did) loaded = s2.get_device(did)
assert loaded.name == "Persist" assert loaded.name == "Persist"
assert loaded.led_count == 77 assert loaded.led_count == 77
db.close()
def test_update_persists(self, temp_storage): def test_update_persists(self, tmp_path):
s1 = DeviceStore(temp_storage) from wled_controller.storage.database import Database
db = Database(tmp_path / "persist2.db")
s1 = DeviceStore(db)
d = s1.create_device(name="Before", url="http://x", led_count=10) d = s1.create_device(name="Before", url="http://x", led_count=10)
s1.update_device(d.id, name="After") s1.update_device(d.id, name="After")
s2 = DeviceStore(temp_storage) s2 = DeviceStore(db)
assert s2.get_device(d.id).name == "After" assert s2.get_device(d.id).name == "After"
db.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -266,7 +275,9 @@ class TestDevicePersistence:
class TestDeviceThreadSafety: class TestDeviceThreadSafety:
def test_concurrent_creates(self, tmp_path): def test_concurrent_creates(self, tmp_path):
s = DeviceStore(tmp_path / "conc.json") from wled_controller.storage.database import Database
db = Database(tmp_path / "conc.db")
s = DeviceStore(db)
errors = [] errors = []
def _create(i): def _create(i):

View File

@@ -11,8 +11,8 @@ from wled_controller.storage.key_colors_output_target import (
@pytest.fixture @pytest.fixture
def store(tmp_path) -> OutputTargetStore: def store(tmp_db) -> OutputTargetStore:
return OutputTargetStore(str(tmp_path / "output_targets.json")) return OutputTargetStore(tmp_db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -193,8 +193,10 @@ class TestOutputTargetQueries:
class TestOutputTargetPersistence: class TestOutputTargetPersistence:
def test_persist_and_reload(self, tmp_path): def test_persist_and_reload(self, tmp_path):
path = str(tmp_path / "ot_persist.json") from wled_controller.storage.database import Database
s1 = OutputTargetStore(path) db_path = str(tmp_path / "ot_persist.db")
db = Database(db_path)
s1 = OutputTargetStore(db)
t = s1.create_target( t = s1.create_target(
"Persist", "led", "Persist", "led",
device_id="dev_1", device_id="dev_1",
@@ -203,8 +205,9 @@ class TestOutputTargetPersistence:
) )
tid = t.id tid = t.id
s2 = OutputTargetStore(path) s2 = OutputTargetStore(db)
loaded = s2.get_target(tid) loaded = s2.get_target(tid)
assert loaded.name == "Persist" assert loaded.name == "Persist"
assert isinstance(loaded, WledOutputTarget) assert isinstance(loaded, WledOutputTarget)
assert loaded.tags == ["tv"] assert loaded.tags == ["tv"]
db.close()

View File

@@ -7,8 +7,8 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
@pytest.fixture @pytest.fixture
def store(tmp_path) -> SyncClockStore: def store(tmp_db) -> SyncClockStore:
return SyncClockStore(str(tmp_path / "sync_clocks.json")) return SyncClockStore(tmp_db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -149,12 +149,14 @@ class TestSyncClockNameUniqueness:
class TestSyncClockPersistence: class TestSyncClockPersistence:
def test_persist_and_reload(self, tmp_path): def test_persist_and_reload(self, tmp_path):
path = str(tmp_path / "sc_persist.json") from wled_controller.storage.database import Database
s1 = SyncClockStore(path) db = Database(tmp_path / "sc_persist.db")
s1 = SyncClockStore(db)
c = s1.create_clock(name="Persist", speed=2.5) c = s1.create_clock(name="Persist", speed=2.5)
cid = c.id cid = c.id
s2 = SyncClockStore(path) s2 = SyncClockStore(db)
loaded = s2.get_clock(cid) loaded = s2.get_clock(cid)
assert loaded.name == "Persist" assert loaded.name == "Persist"
assert loaded.speed == 2.5 assert loaded.speed == 2.5
db.close()

View File

@@ -14,8 +14,8 @@ from wled_controller.storage.value_source_store import ValueSourceStore
@pytest.fixture @pytest.fixture
def store(tmp_path) -> ValueSourceStore: def store(tmp_db) -> ValueSourceStore:
return ValueSourceStore(str(tmp_path / "value_sources.json")) return ValueSourceStore(tmp_db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -247,13 +247,15 @@ class TestValueSourceNameUniqueness:
class TestValueSourcePersistence: class TestValueSourcePersistence:
def test_persist_and_reload(self, tmp_path): def test_persist_and_reload(self, tmp_path):
path = str(tmp_path / "vs_persist.json") from wled_controller.storage.database import Database
s1 = ValueSourceStore(path) db = Database(tmp_path / "vs_persist.db")
s1 = ValueSourceStore(db)
src = s1.create_source("Persist", "static", value=0.42) src = s1.create_source("Persist", "static", value=0.42)
sid = src.id sid = src.id
s2 = ValueSourceStore(path) s2 = ValueSourceStore(db)
loaded = s2.get_source(sid) loaded = s2.get_source(sid)
assert loaded.name == "Persist" assert loaded.name == "Persist"
assert isinstance(loaded, StaticValueSource) assert isinstance(loaded, StaticValueSource)
assert loaded.value == 0.42 assert loaded.value == 0.42
db.close()

View File

@@ -21,8 +21,7 @@ class TestDefaultConfig:
def test_default_storage_paths(self): def test_default_storage_paths(self):
config = Config() config = Config()
assert config.storage.devices_file == "data/devices.json" assert config.storage.database_file == "data/ledgrab.db"
assert config.storage.sync_clocks_file == "data/sync_clocks.json"
def test_default_mqtt_disabled(self): def test_default_mqtt_disabled(self):
config = Config() config = Config()
@@ -73,12 +72,11 @@ class TestServerConfig:
class TestDemoMode: class TestDemoMode:
def test_demo_rewrites_storage_paths(self): def test_demo_rewrites_storage_paths(self):
config = Config(demo=True) config = Config(demo=True)
assert config.storage.devices_file.startswith("data/demo/") assert config.storage.database_file.startswith("data/demo/")
assert config.storage.sync_clocks_file.startswith("data/demo/")
def test_non_demo_keeps_original_paths(self): def test_non_demo_keeps_original_paths(self):
config = Config(demo=False) config = Config(demo=False)
assert config.storage.devices_file == "data/devices.json" assert config.storage.database_file == "data/ledgrab.db"
class TestGlobalConfig: class TestGlobalConfig:

View File

@@ -2,19 +2,22 @@
import pytest import pytest
from wled_controller.storage.database import Database
from wled_controller.storage.device_store import Device, DeviceStore from wled_controller.storage.device_store import Device, DeviceStore
@pytest.fixture @pytest.fixture
def temp_storage(tmp_path): def tmp_db(tmp_path):
"""Provide temporary storage file.""" """Provide a temporary SQLite Database instance."""
return tmp_path / "devices.json" db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture @pytest.fixture
def device_store(temp_storage): def device_store(tmp_db):
"""Provide device store instance.""" """Provide device store instance."""
return DeviceStore(temp_storage) return DeviceStore(tmp_db)
def test_device_creation(): def test_device_creation():
@@ -207,10 +210,11 @@ def test_device_exists(device_store):
assert device_store.device_exists("nonexistent") is False assert device_store.device_exists("nonexistent") is False
def test_persistence(temp_storage): def test_persistence(tmp_path):
"""Test device persistence across store instances.""" """Test device persistence across store instances."""
db = Database(tmp_path / "persist.db")
# Create store and add device # Create store and add device
store1 = DeviceStore(temp_storage) store1 = DeviceStore(db)
device = store1.create_device( device = store1.create_device(
name="Test WLED", name="Test WLED",
url="http://192.168.1.100", url="http://192.168.1.100",
@@ -218,14 +222,15 @@ def test_persistence(temp_storage):
) )
device_id = device.id device_id = device.id
# Create new store instance (loads from file) # Create new store instance (loads from database)
store2 = DeviceStore(temp_storage) store2 = DeviceStore(db)
# Verify device persisted # Verify device persisted
loaded_device = store2.get_device(device_id) loaded_device = store2.get_device(device_id)
assert loaded_device is not None assert loaded_device is not None
assert loaded_device.name == "Test WLED" assert loaded_device.name == "Test WLED"
assert loaded_device.led_count == 150 assert loaded_device.led_count == 150
db.close()
def test_clear(device_store): def test_clear(device_store):