"""Tests for AssetStore — focusing on self-heal behavior on startup. Regression guard for the "restored backup loses notification sounds" class of bug: when the SQLite DB is restored from elsewhere but the ``assets/`` directory hasn't moved with it, prebuilt sound files end up with stale DB rows pointing at missing files. ``heal_prebuilt_files`` is the safety net. """ import pytest from ledgrab.storage.asset_store import AssetStore from ledgrab.storage.database import Database @pytest.fixture def store_with_prebuilt(tmp_path): """AssetStore seeded with the shipped prebuilt sounds.""" db = Database(tmp_path / "test.db") assets_dir = tmp_path / "assets" prebuilt_dir = tmp_path / "prebuilt" prebuilt_dir.mkdir() # Minimal valid WAV-ish bytes — content doesn't matter for these tests, # only that the file exists and is copied around correctly. for name in ("alert.wav", "bell.wav"): (prebuilt_dir / name).write_bytes(b"RIFF\x00\x00\x00\x00WAVEfmt " + name.encode()) store = AssetStore(db, assets_dir) imported = store.import_prebuilt_sounds(prebuilt_dir) assert len(imported) == 2 yield store, prebuilt_dir, assets_dir db.close() class TestHealPrebuiltFiles: def test_heals_missing_stored_file(self, store_with_prebuilt): store, prebuilt_dir, assets_dir = store_with_prebuilt alert = next(a for a in store.get_all_assets() if a.filename == "alert.wav") # Simulate a partial restore: DB row survived, file vanished. (assets_dir / alert.stored_filename).unlink() assert store.get_file_path(alert.id) is None healed = store.heal_prebuilt_files(prebuilt_dir) assert [a.id for a in healed] == [alert.id] restored_path = assets_dir / alert.stored_filename assert restored_path.exists() # File content round-trips from the prebuilt source. assert restored_path.read_bytes() == (prebuilt_dir / "alert.wav").read_bytes() # Size metadata is updated to match the re-copied file. assert store.get_asset(alert.id).size_bytes == restored_path.stat().st_size def test_idempotent_when_files_present(self, store_with_prebuilt): store, prebuilt_dir, _ = store_with_prebuilt first = store.heal_prebuilt_files(prebuilt_dir) second = store.heal_prebuilt_files(prebuilt_dir) assert first == [] and second == [] def test_skips_soft_deleted_prebuilt(self, store_with_prebuilt): store, prebuilt_dir, assets_dir = store_with_prebuilt alert = next(a for a in store.get_all_assets() if a.filename == "alert.wav") store.delete_asset(alert.id) # prebuilt → soft-delete; file removed assert store.get_asset(alert.id).deleted is True healed = store.heal_prebuilt_files(prebuilt_dir) # Heal must not resurrect a soft-deleted asset — that is # `restore_prebuilt`'s job and is an explicit user action. assert healed == [] assert not (assets_dir / alert.stored_filename).exists() def test_returns_empty_when_source_also_missing(self, store_with_prebuilt): """If both the stored file and the prebuilt source are gone, the heal is a no-op — the asset stays broken and the method does not raise (so startup is not aborted).""" store, prebuilt_dir, assets_dir = store_with_prebuilt alert = next(a for a in store.get_all_assets() if a.filename == "alert.wav") (assets_dir / alert.stored_filename).unlink() (prebuilt_dir / "alert.wav").unlink() healed = store.heal_prebuilt_files(prebuilt_dir) assert healed == [] # Unrelated assets still heal correctly. bell = next(a for a in store.get_all_assets() if a.filename == "bell.wav") (assets_dir / bell.stored_filename).unlink() healed = store.heal_prebuilt_files(prebuilt_dir) assert [a.id for a in healed] == [bell.id] def test_import_prebuilt_sounds_runs_heal(self, tmp_path): """End-to-end: restoring a DB and re-running startup heals missing files.""" # First run: import both sounds normally. db = Database(tmp_path / "first.db") assets_dir = tmp_path / "assets" prebuilt_dir = tmp_path / "prebuilt" prebuilt_dir.mkdir() (prebuilt_dir / "alert.wav").write_bytes(b"hello-alert") store = AssetStore(db, assets_dir) imported = store.import_prebuilt_sounds(prebuilt_dir) assert len(imported) == 1 alert_stored = imported[0].stored_filename db.close() # Simulate partial restore: file gone, DB row preserved. (assets_dir / alert_stored).unlink() # Second run: reopen the same DB + assets dir. db2 = Database(tmp_path / "first.db") store2 = AssetStore(db2, assets_dir) store2.import_prebuilt_sounds(prebuilt_dir) assert (assets_dir / alert_stored).exists() db2.close() class TestWarnMissingCustomFiles: def test_reports_custom_asset_with_missing_file(self, tmp_path): db = Database(tmp_path / "test.db") assets_dir = tmp_path / "assets" store = AssetStore(db, assets_dir) asset = store.create_asset( name="User Sound", filename="user.wav", file_data=b"RIFF\x00\x00\x00\x00WAVEfmt user", ) (assets_dir / asset.stored_filename).unlink() missing = store.warn_missing_custom_files() assert [a.id for a in missing] == [asset.id] db.close() def test_quiet_when_all_custom_files_present(self, tmp_path): db = Database(tmp_path / "test.db") store = AssetStore(db, tmp_path / "assets") store.create_asset(name="User", filename="user.wav", file_data=b"data123") assert store.warn_missing_custom_files() == [] db.close() def test_ignores_prebuilt_and_soft_deleted(self, tmp_path): db = Database(tmp_path / "test.db") assets_dir = tmp_path / "assets" prebuilt_dir = tmp_path / "prebuilt" prebuilt_dir.mkdir() (prebuilt_dir / "alert.wav").write_bytes(b"prebuilt-alert") store = AssetStore(db, assets_dir) store.import_prebuilt_sounds(prebuilt_dir) prebuilt_asset = store.get_all_assets()[0] (assets_dir / prebuilt_asset.stored_filename).unlink() # Missing prebuilt files are NOT reported here (that's heal's job). assert store.warn_missing_custom_files() == [] db.close()