"""Phase 6 — Tests for MsgpackCache ABC (muse/core/cache_base.py). A minimal concrete subclass (_TestCache) is defined here to exercise every shared behaviour: load / save / dirty tracking / prune / size / empty. Existing per-cache test files (symbol, callgraph, implicit_edge, invariants) are the regression gate — they must continue to pass unmodified after the four production caches inherit from MsgpackCache. """ from __future__ import annotations import pathlib import pytest from muse.core.cache_base import MsgpackCache, _RawCacheMap from muse.core.paths import muse_dir # --------------------------------------------------------------------------- # Minimal concrete subclass used by all tests # --------------------------------------------------------------------------- class _TestCache(MsgpackCache): """Trivial string→string cache for testing the shared base-class logic.""" _CACHE_FILENAME = "test.json" _CACHE_VERSION = 1 _TEMP_PREFIX = ".test_cache_" @classmethod def _deserialize_entries(cls, raw: _RawCacheMap) -> _RawCacheMap: return {k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)} def _serialize_entries(self) -> _RawCacheMap: return dict(self._entries) # --------------------------------------------------------------------------- # Fixture helper # --------------------------------------------------------------------------- def _make_muse_dir(tmp_path: pathlib.Path) -> pathlib.Path: """Create a .muse/cache/ tree and return the .muse path.""" dot_muse = muse_dir(tmp_path) (dot_muse / "cache").mkdir(parents=True) return dot_muse # --------------------------------------------------------------------------- # Tier 1 — Unit (no filesystem I/O) # --------------------------------------------------------------------------- class TestUnit: def test_get_miss_returns_none(self) -> None: cache = _TestCache(None, {}) assert cache.get("missing") is None def test_put_sets_dirty(self) -> None: cache = _TestCache(None, {}) cache.put("k", "v") assert cache._dirty def test_get_hit_after_put(self) -> None: cache = _TestCache(None, {}) cache.put("k", "v") assert cache.get("k") == "v" def test_prune_removes_stale_and_sets_dirty(self) -> None: cache = _TestCache(None, {"a": "1", "b": "2", "c": "3"}) cache.prune({"a"}) assert cache.get("a") == "1" assert cache.get("b") is None assert cache.get("c") is None assert cache._dirty def test_prune_noop_when_all_live(self) -> None: cache = _TestCache(None, {"a": "1"}) cache.prune({"a", "b"}) assert not cache._dirty def test_size_property(self) -> None: cache = _TestCache(None, {"a": "1", "b": "2"}) assert cache.size == 2 def test_empty_cache_dir_is_none(self) -> None: cache = _TestCache.empty() assert cache._cache_dir is None assert cache.size == 0 def test_save_on_empty_is_noop_no_file(self, tmp_path: pathlib.Path) -> None: cache = _TestCache.empty() cache.put("k", "v") # dirty=True but _cache_dir is None cache.save() assert not any(tmp_path.rglob("*.json")) # --------------------------------------------------------------------------- # Tier 2 — Integration (real filesystem, no subprocess) # --------------------------------------------------------------------------- class TestIntegration: def test_load_missing_file_returns_empty(self, tmp_path: pathlib.Path) -> None: muse = _make_muse_dir(tmp_path) cache = _TestCache.load(muse) assert cache.size == 0 assert cache._cache_dir == muse / "cache" def test_save_creates_file_at_correct_path(self, tmp_path: pathlib.Path) -> None: muse = _make_muse_dir(tmp_path) cache = _TestCache.load(muse) cache.put("k", "v") cache.save() assert (muse / "cache" / "test.json").is_file() def test_round_trip_data_intact(self, tmp_path: pathlib.Path) -> None: muse = _make_muse_dir(tmp_path) cache = _TestCache.load(muse) cache.put("key1", "val1") cache.put("key2", "val2") cache.save() loaded = _TestCache.load(muse) assert loaded.get("key1") == "val1" assert loaded.get("key2") == "val2" assert loaded.size == 2 def test_save_noop_when_not_dirty_mtime_unchanged(self, tmp_path: pathlib.Path) -> None: muse = _make_muse_dir(tmp_path) cache = _TestCache.load(muse) cache.put("k", "v") cache.save() cache_file = muse / "cache" / "test.json" mtime1 = cache_file.stat().st_mtime_ns cache2 = _TestCache.load(muse) cache2.save() mtime2 = cache_file.stat().st_mtime_ns assert mtime1 == mtime2 def test_dirty_false_after_successful_save(self, tmp_path: pathlib.Path) -> None: muse = _make_muse_dir(tmp_path) cache = _TestCache.load(muse) cache.put("k", "v") cache.save() assert not cache._dirty # --------------------------------------------------------------------------- # Tier 5 — Data integrity # --------------------------------------------------------------------------- class TestDataIntegrity: def test_corrupt_bytes_returns_empty(self, tmp_path: pathlib.Path) -> None: muse = _make_muse_dir(tmp_path) (muse / "cache" / "test.json").write_bytes(b"not valid JSON !!!") cache = _TestCache.load(muse) assert cache.size == 0 def test_wrong_version_returns_empty(self, tmp_path: pathlib.Path) -> None: import json as _json muse = _make_muse_dir(tmp_path) payload = _json.dumps({"version": 99, "entries": {"k": "v"}}).encode() (muse / "cache" / "test.json").write_bytes(payload) cache = _TestCache.load(muse) assert cache.size == 0 def test_missing_entries_key_returns_empty(self, tmp_path: pathlib.Path) -> None: import json as _json muse = _make_muse_dir(tmp_path) payload = _json.dumps({"version": 1}).encode() (muse / "cache" / "test.json").write_bytes(payload) cache = _TestCache.load(muse) assert cache.size == 0 def test_invalid_entry_skipped_valid_survives(self, tmp_path: pathlib.Path) -> None: import json as _json muse = _make_muse_dir(tmp_path) # "bad" has int value → _deserialize_entries skips it; "good" survives payload = _json.dumps( {"version": 1, "entries": {"bad": 123, "good": "value"}} ).encode() (muse / "cache" / "test.json").write_bytes(payload) cache = _TestCache.load(muse) assert cache.get("good") == "value" assert cache.get("bad") is None def test_no_tmp_leftover_after_save(self, tmp_path: pathlib.Path) -> None: muse = _make_muse_dir(tmp_path) cache = _TestCache.load(muse) cache.put("k", "v") cache.save() assert not any((muse / "cache").glob("*.tmp")) # --------------------------------------------------------------------------- # from_root — integration tests # --------------------------------------------------------------------------- class TestFromRoot: """from_root(repo_root) — the canonical entry point for CLI callers.""" def test_from_root_with_muse_dir_sets_cache_dir(self, tmp_path: pathlib.Path) -> None: muse = _make_muse_dir(tmp_path) # creates tmp_path/.muse/cache/ cache = _TestCache.from_root(tmp_path) assert cache._cache_dir == muse / "cache" def test_from_root_without_muse_dir_returns_empty(self, tmp_path: pathlib.Path) -> None: # No .muse/ directory — should get a no-op empty cache cache = _TestCache.from_root(tmp_path) assert cache._cache_dir is None assert cache.size == 0 def test_from_root_round_trip(self, tmp_path: pathlib.Path) -> None: _make_muse_dir(tmp_path) cache = _TestCache.from_root(tmp_path) cache.put("k", "v") cache.save() reloaded = _TestCache.from_root(tmp_path) assert reloaded.get("k") == "v" def test_from_root_empty_save_is_noop(self, tmp_path: pathlib.Path) -> None: # No .muse/ → empty cache; save must not create any files cache = _TestCache.from_root(tmp_path) cache.put("k", "v") cache.save() assert not any(tmp_path.rglob("*.json")) # --------------------------------------------------------------------------- # Tier 6 — Performance # --------------------------------------------------------------------------- class TestPerformance: def test_not_dirty_save_under_1ms(self, tmp_path: pathlib.Path) -> None: import time muse = _make_muse_dir(tmp_path) cache = _TestCache.load(muse) cache.put("k", "v") cache.save() cache2 = _TestCache.load(muse) t0 = time.monotonic() cache2.save() elapsed_ms = (time.monotonic() - t0) * 1000 assert elapsed_ms < 1.0 # --------------------------------------------------------------------------- # Tier 7 — Security # --------------------------------------------------------------------------- class TestSecurity: def test_mode_000_file_returns_empty(self, tmp_path: pathlib.Path) -> None: muse = _make_muse_dir(tmp_path) cache = _TestCache.load(muse) cache.put("k", "v") cache.save() cache_file = muse / "cache" / "test.json" cache_file.chmod(0o000) try: loaded = _TestCache.load(muse) assert loaded.size == 0 finally: cache_file.chmod(0o644) def test_save_overwrites_symlink_not_target(self, tmp_path: pathlib.Path) -> None: muse = _make_muse_dir(tmp_path) target = tmp_path / "other_file" target.write_bytes(b"original") symlink = muse / "cache" / "test.json" symlink.symlink_to(target) cache = _TestCache(muse / "cache", {"k": "v"}) cache._dirty = True cache.save() # symlink is replaced by a real file (os.replace removes the symlink) assert not symlink.is_symlink() # original target is untouched assert target.read_bytes() == b"original"