mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
182 lines
5.7 KiB
182 lines
5.7 KiB
"""Tests for the metadata-cache hard capacity cap (LRU eviction).
|
|
|
|
TTL-only eviction had no upper bound, so heavy in-window caching let
|
|
metadata_cache_entities reach ~1.8M rows / 7.6 GB. evict_over_capacity adds an
|
|
LRU row ceiling. We test the pure decision function directly and the SQL
|
|
behavior against a real temp DB (proves it drops the LEAST-recently-accessed
|
|
rows, not arbitrary ones).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
# Minimal stubs so importing core.metadata.cache doesn't drag in spotipy/config.
|
|
if "spotipy" not in sys.modules:
|
|
spotipy = types.ModuleType("spotipy")
|
|
spotipy.Spotify = object
|
|
oauth2 = types.ModuleType("spotipy.oauth2")
|
|
oauth2.SpotifyOAuth = object
|
|
oauth2.SpotifyClientCredentials = object
|
|
spotipy.oauth2 = oauth2
|
|
sys.modules["spotipy"] = spotipy
|
|
sys.modules["spotipy.oauth2"] = oauth2
|
|
if "config.settings" not in sys.modules:
|
|
config_pkg = types.ModuleType("config")
|
|
settings_mod = types.ModuleType("config.settings")
|
|
|
|
class _DummyCM:
|
|
def get(self, key, default=None):
|
|
return default
|
|
|
|
def get_active_media_server(self):
|
|
return "plex"
|
|
|
|
settings_mod.config_manager = _DummyCM()
|
|
config_pkg.settings = settings_mod
|
|
sys.modules["config"] = config_pkg
|
|
sys.modules["config.settings"] = settings_mod
|
|
|
|
from core.metadata.cache import ( # noqa: E402
|
|
MetadataCache,
|
|
entities_to_evict_for_capacity,
|
|
)
|
|
|
|
|
|
# ── pure decision function ─────────────────────────────────────────────────
|
|
|
|
def test_evict_count_over_cap():
|
|
assert entities_to_evict_for_capacity(1000, 250) == 750
|
|
|
|
|
|
def test_evict_count_at_or_under_cap_is_zero():
|
|
assert entities_to_evict_for_capacity(250, 250) == 0
|
|
assert entities_to_evict_for_capacity(10, 250) == 0
|
|
|
|
|
|
def test_cap_zero_or_negative_means_no_eviction():
|
|
assert entities_to_evict_for_capacity(10_000, 0) == 0
|
|
assert entities_to_evict_for_capacity(10_000, -1) == 0
|
|
|
|
|
|
def test_never_negative():
|
|
assert entities_to_evict_for_capacity(0, 100) == 0
|
|
|
|
|
|
# ── evict_over_capacity SQL behavior (real temp DB) ────────────────────────
|
|
|
|
class _NonClosingConn:
|
|
def __init__(self, real):
|
|
self._real = real
|
|
|
|
def cursor(self):
|
|
return self._real.cursor()
|
|
|
|
def commit(self):
|
|
return self._real.commit()
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
pass
|
|
|
|
|
|
class _TempCache(MetadataCache):
|
|
"""MetadataCache whose _get_db returns a shim over a shared in-memory DB
|
|
holding just the entities table — enough to exercise evict_over_capacity."""
|
|
|
|
def __init__(self):
|
|
self._conn = sqlite3.connect(":memory:")
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._conn.execute("""
|
|
CREATE TABLE metadata_cache_entities (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source TEXT, entity_type TEXT, entity_id TEXT,
|
|
name TEXT, raw_json TEXT,
|
|
last_accessed_at TIMESTAMP,
|
|
ttl_days INTEGER DEFAULT 30
|
|
)
|
|
""")
|
|
self._conn.commit()
|
|
|
|
def _get_db(self):
|
|
outer = self
|
|
|
|
class _DB:
|
|
def _get_connection(self_inner):
|
|
return _NonClosingConn(outer._conn)
|
|
return _DB()
|
|
|
|
# NOTE: we deliberately do NOT override _run_maintenance_write — the test
|
|
# exercises the REAL method (retry + connection handling) so we're testing
|
|
# production code, not a stub. _get_db is the only injected seam.
|
|
|
|
|
|
def _add_rows(cache, specs):
|
|
"""specs: list of (entity_id, last_accessed_at). Inserts rows."""
|
|
cur = cache._conn.cursor()
|
|
for eid, ts in specs:
|
|
cur.execute(
|
|
"INSERT INTO metadata_cache_entities (source, entity_type, entity_id, name, raw_json, last_accessed_at) "
|
|
"VALUES ('spotify','artist',?,?, '{}', ?)",
|
|
(eid, eid, ts),
|
|
)
|
|
cache._conn.commit()
|
|
|
|
|
|
def _ids(cache):
|
|
return [r["entity_id"] for r in cache._conn.execute(
|
|
"SELECT entity_id FROM metadata_cache_entities ORDER BY entity_id"
|
|
).fetchall()]
|
|
|
|
|
|
def test_evict_over_capacity_drops_least_recently_accessed():
|
|
cache = _TempCache()
|
|
# 5 rows, distinct access times. Cap at 3 -> evict the 2 oldest-accessed.
|
|
_add_rows(cache, [
|
|
("a", "2026-05-01T00:00:00"), # oldest -> evicted
|
|
("b", "2026-05-02T00:00:00"), # oldest -> evicted
|
|
("c", "2026-05-03T00:00:00"),
|
|
("d", "2026-05-04T00:00:00"),
|
|
("e", "2026-05-05T00:00:00"), # newest -> kept
|
|
])
|
|
evicted = cache.evict_over_capacity(max_rows=3)
|
|
assert evicted == 2
|
|
assert _ids(cache) == ["c", "d", "e"] # a, b gone (LRU)
|
|
|
|
|
|
def test_evict_over_capacity_noop_under_cap():
|
|
cache = _TempCache()
|
|
_add_rows(cache, [("a", "2026-05-01T00:00:00"), ("b", "2026-05-02T00:00:00")])
|
|
assert cache.evict_over_capacity(max_rows=10) == 0
|
|
assert _ids(cache) == ["a", "b"]
|
|
|
|
|
|
def test_evict_over_capacity_disabled_with_zero_cap():
|
|
cache = _TempCache()
|
|
_add_rows(cache, [(str(i), f"2026-05-0{i}T00:00:00") for i in range(1, 6)])
|
|
assert cache.evict_over_capacity(max_rows=0) == 0
|
|
assert len(_ids(cache)) == 5
|
|
|
|
|
|
def test_null_access_times_evicted_first():
|
|
"""Rows never accessed since insert (NULL last_accessed_at) are the
|
|
coldest — they should go before any touched row."""
|
|
cache = _TempCache()
|
|
_add_rows(cache, [
|
|
("never1", None),
|
|
("never2", None),
|
|
("touched", "2026-05-01T00:00:00"),
|
|
])
|
|
evicted = cache.evict_over_capacity(max_rows=1)
|
|
assert evicted == 2
|
|
assert _ids(cache) == ["touched"]
|