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
def device_store(tmp_path):
return DeviceStore(tmp_path / "devices.json")
def _route_db(tmp_path):
from wled_controller.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def output_target_store(tmp_path):
return OutputTargetStore(str(tmp_path / "output_targets.json"))
def device_store(_route_db):
return DeviceStore(_route_db)
@pytest.fixture
def output_target_store(_route_db):
return OutputTargetStore(_route_db)
@pytest.fixture

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
import pytest
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.sync_clock import SyncClock
from wled_controller.storage.sync_clock_store import SyncClockStore
@@ -37,12 +38,25 @@ def test_config_dir(tmp_path):
@pytest.fixture
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.mkdir(parents=True, exist_ok=True)
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
# ---------------------------------------------------------------------------
@@ -55,20 +69,7 @@ def test_config(tmp_path):
data_dir.mkdir(parents=True, exist_ok=True)
storage = StorageConfig(
devices_file=str(data_dir / "devices.json"),
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"),
database_file=str(data_dir / "test.db"),
)
return Config(
@@ -84,33 +85,33 @@ def test_config(tmp_path):
@pytest.fixture
def device_store(temp_store_dir):
"""Provide a DeviceStore backed by a temp file."""
return DeviceStore(temp_store_dir / "devices.json")
def device_store(tmp_db):
"""Provide a DeviceStore backed by a temp database."""
return DeviceStore(tmp_db)
@pytest.fixture
def sync_clock_store(temp_store_dir):
"""Provide a SyncClockStore backed by a temp file."""
return SyncClockStore(str(temp_store_dir / "sync_clocks.json"))
def sync_clock_store(tmp_db):
"""Provide a SyncClockStore backed by a temp database."""
return SyncClockStore(tmp_db)
@pytest.fixture
def output_target_store(temp_store_dir):
"""Provide an OutputTargetStore backed by a temp file."""
return OutputTargetStore(str(temp_store_dir / "output_targets.json"))
def output_target_store(tmp_db):
"""Provide an OutputTargetStore backed by a temp database."""
return OutputTargetStore(tmp_db)
@pytest.fixture
def automation_store(temp_store_dir):
"""Provide an AutomationStore backed by a temp file."""
return AutomationStore(str(temp_store_dir / "automations.json"))
def automation_store(tmp_db):
"""Provide an AutomationStore backed by a temp database."""
return AutomationStore(tmp_db)
@pytest.fixture
def value_source_store(temp_store_dir):
"""Provide a ValueSourceStore backed by a temp file."""
return ValueSourceStore(str(temp_store_dir / "value_sources.json"))
def value_source_store(tmp_db):
"""Provide a ValueSourceStore backed by a temp database."""
return ValueSourceStore(tmp_db)
# ---------------------------------------------------------------------------

View File

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

View File

@@ -46,10 +46,12 @@ def client(_test_client):
def _clear_stores():
"""Remove all entities from all stores for test isolation."""
# Reset the saves-frozen flag that freeze_saves() sets during restore flows.
# Without this, subsequent tests can't persist data because _save() is a no-op.
# Reset frozen-writes flags that restore flows set.
# Without this, subsequent tests can't persist data.
from wled_controller.storage.base_store import 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

View File

@@ -1,11 +1,9 @@
"""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 json
class TestBackupRestoreFlow:
@@ -42,20 +40,12 @@ class TestBackupRestoreFlow:
resp = client.get("/api/v1/color-strip-sources")
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")
assert resp.status_code == 200
backup_data = resp.json()
assert backup_data["meta"]["format"] == "ledgrab-backup"
assert "stores" in backup_data
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
backup_bytes = resp.content
# SQLite files start with this magic header
assert backup_bytes[:16].startswith(b"SQLite format 3")
# 3. Delete all created entities
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")
assert resp.json()["count"] == 0
# 4. Restore from backup (POST with the backup JSON as a file upload)
backup_bytes = json.dumps(backup_data).encode("utf-8")
# 4. Restore from backup (POST with the .db file upload)
resp = client.post(
"/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}"
restore_result = resp.json()
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
def test_backup_contains_all_store_keys(self, client):
"""Backup response includes entries for all known store types."""
def test_backup_is_valid_sqlite(self, client):
"""Backup response is a valid SQLite database file."""
resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200
stores = resp.json()["stores"]
# At minimum, these critical stores should be present
expected_keys = {
"devices", "output_targets", "color_strip_sources",
"capture_templates", "value_sources",
}
assert expected_keys.issubset(set(stores.keys()))
assert resp.content[:16].startswith(b"SQLite format 3")
# Should have Content-Disposition header for download
assert "attachment" in resp.headers.get("content-disposition", "")
def test_restore_rejects_invalid_format(self, client):
"""Uploading a non-backup JSON file should fail validation."""
bad_data = json.dumps({"not": "a backup"}).encode("utf-8")
"""Uploading a non-SQLite file should fail validation."""
bad_data = b"not a database file at all, just random text content"
resp = client.post(
"/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
def test_restore_rejects_empty_stores(self, client):
"""A backup with no recognized stores should fail."""
bad_backup = {
"meta": {"format": "ledgrab-backup", "format_version": 1},
"stores": {"unknown_store": {}},
}
def test_restore_rejects_empty_file(self, client):
"""A tiny file should fail validation."""
resp = client.post(
"/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

View File

@@ -18,8 +18,8 @@ from wled_controller.storage.automation_store import AutomationStore
@pytest.fixture
def store(tmp_path) -> AutomationStore:
return AutomationStore(str(tmp_path / "automations.json"))
def store(tmp_db) -> AutomationStore:
return AutomationStore(tmp_db)
# ---------------------------------------------------------------------------
@@ -240,16 +240,18 @@ class TestAutomationNameUniqueness:
class TestAutomationPersistence:
def test_persist_and_reload(self, tmp_path):
path = str(tmp_path / "auto_persist.json")
s1 = AutomationStore(path)
from wled_controller.storage.database import Database
db = Database(tmp_path / "auto_persist.db")
s1 = AutomationStore(db)
a = s1.create_automation(
name="Persist",
conditions=[WebhookCondition(token="t1")],
)
aid = a.id
s2 = AutomationStore(path)
s2 = AutomationStore(db)
loaded = s2.get_automation(aid)
assert loaded.name == "Persist"
assert len(loaded.conditions) == 1
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
def temp_storage(tmp_path) -> Path:
return tmp_path / "devices.json"
def tmp_db(tmp_path):
from wled_controller.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def store(temp_storage) -> DeviceStore:
return DeviceStore(temp_storage)
def store(tmp_db) -> DeviceStore:
return DeviceStore(tmp_db)
# ---------------------------------------------------------------------------
@@ -240,23 +243,29 @@ class TestDeviceNameUniqueness:
class TestDevicePersistence:
def test_persistence_across_instances(self, temp_storage):
s1 = DeviceStore(temp_storage)
def test_persistence_across_instances(self, tmp_path):
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)
did = d.id
s2 = DeviceStore(temp_storage)
s2 = DeviceStore(db)
loaded = s2.get_device(did)
assert loaded.name == "Persist"
assert loaded.led_count == 77
db.close()
def test_update_persists(self, temp_storage):
s1 = DeviceStore(temp_storage)
def test_update_persists(self, tmp_path):
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)
s1.update_device(d.id, name="After")
s2 = DeviceStore(temp_storage)
s2 = DeviceStore(db)
assert s2.get_device(d.id).name == "After"
db.close()
# ---------------------------------------------------------------------------
@@ -266,7 +275,9 @@ class TestDevicePersistence:
class TestDeviceThreadSafety:
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 = []
def _create(i):

View File

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

View File

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

View File

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

View File

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

View File

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