diff --git a/server/tests/api/routes/test_devices_routes.py b/server/tests/api/routes/test_devices_routes.py index b8427f0..05613bf 100644 --- a/server/tests/api/routes/test_devices_routes.py +++ b/server/tests/api/routes/test_devices_routes.py @@ -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 diff --git a/server/tests/conftest.py b/server/tests/conftest.py index e939709..5d6185a 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -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) # --------------------------------------------------------------------------- diff --git a/server/tests/core/test_automation_engine.py b/server/tests/core/test_automation_engine.py index c720213..5a2d299 100644 --- a/server/tests/core/test_automation_engine.py +++ b/server/tests/core/test_automation_engine.py @@ -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 diff --git a/server/tests/e2e/conftest.py b/server/tests/e2e/conftest.py index 79d9b41..49d4760 100644 --- a/server/tests/e2e/conftest.py +++ b/server/tests/e2e/conftest.py @@ -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 diff --git a/server/tests/e2e/test_backup_flow.py b/server/tests/e2e/test_backup_flow.py index c6c2d01..ec3ccf1 100644 --- a/server/tests/e2e/test_backup_flow.py +++ b/server/tests/e2e/test_backup_flow.py @@ -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 diff --git a/server/tests/storage/test_automation_store.py b/server/tests/storage/test_automation_store.py index 61cbf3c..5ea04de 100644 --- a/server/tests/storage/test_automation_store.py +++ b/server/tests/storage/test_automation_store.py @@ -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() diff --git a/server/tests/storage/test_device_store.py b/server/tests/storage/test_device_store.py index f22c874..ce960fe 100644 --- a/server/tests/storage/test_device_store.py +++ b/server/tests/storage/test_device_store.py @@ -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): diff --git a/server/tests/storage/test_output_target_store.py b/server/tests/storage/test_output_target_store.py index 3ee9958..bb1a254 100644 --- a/server/tests/storage/test_output_target_store.py +++ b/server/tests/storage/test_output_target_store.py @@ -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() diff --git a/server/tests/storage/test_sync_clock_store.py b/server/tests/storage/test_sync_clock_store.py index abd8647..96ebe74 100644 --- a/server/tests/storage/test_sync_clock_store.py +++ b/server/tests/storage/test_sync_clock_store.py @@ -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() diff --git a/server/tests/storage/test_value_source_store.py b/server/tests/storage/test_value_source_store.py index 2e71915..bf968c7 100644 --- a/server/tests/storage/test_value_source_store.py +++ b/server/tests/storage/test_value_source_store.py @@ -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() diff --git a/server/tests/test_config.py b/server/tests/test_config.py index 204f7df..98fb85b 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -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: diff --git a/server/tests/test_device_store.py b/server/tests/test_device_store.py index 623af66..a117537 100644 --- a/server/tests/test_device_store.py +++ b/server/tests/test_device_store.py @@ -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):