soulsync/tests/search/test_search_cache.py
Broque Thomas fd7b56e58c Lift /api/search and /api/enhanced-search/* into core/search/
Routes moved to thin parse-args/jsonify handlers; logic now lives in
six focused modules under core/search/. 720 lines deleted from
web_server.py; 109 added back as wrappers; ~700 lines of new core code
plus ~700 lines of tests.

Module split:
- core/search/cache.py — TTL+LRU cache for enhanced-search responses,
  keyed by (query, active_server, fallback_source, hydrabase_active,
  source_tag) so config changes don't poison stale entries.
- core/search/sources.py — per-kind metadata search (artists/albums/
  tracks) and the multi-kind ThreadPoolExecutor that fans them out.
- core/search/library_check.py — library + wishlist presence check
  with Plex thumb URL resolution; profile-aware wishlist with legacy
  fallback for older DBs missing the profile_id column.
- core/search/stream.py — single-track preview search; effective stream
  mode resolution, query-variant generation, retry walk, matching
  engine integration.
- core/search/basic.py — flat Soulseek file search, quality-sorted.
- core/search/orchestrator.py — main enhanced-search dispatch
  (short-query fast path, single-source bypass, hydrabase-primary fan
  out, alternate source list builder), NDJSON streaming generator
  for /source/<src>, and the SearchDeps dataclass that bundles the
  cross-cutting deps.

Routes pass clients (spotify, hydrabase, hydrabase_worker, soulseek)
and helpers (config_manager, fix_artist_image_url,
_is_hydrabase_active, _get_metadata_fallback_*, _run_background_
comparison, run_async, dev_mode_enabled_provider) into core/search via
a SearchDeps bundle built per-request. fix_artist_image_url stays in
web_server.py because it touches 31 other call sites.

Behavior preserved 1:1:
- Same response shapes (db_artists, spotify_artists, spotify_albums,
  spotify_tracks, primary_source, metadata_source, alternate_sources,
  source_available)
- Same NDJSON line ordering (artists/albums/tracks as they finish, plus
  done marker)
- Same per-kind exception swallowing
- Same hydrabase-worker mirror on dev mode
- Same cache key shape (5-tuple) and TTL/LRU semantics
- Same stream-track effective-mode resolution including the
  Soulseek-coerce-to-YouTube edge case
- Same library-check Plex thumb URL rewriting and wishlist fallback
  for older DBs

Tests: 94 new (cache TTL/LRU/key, sources happy/partial/all-fail,
library presence with library + wishlist + thumbs, stream effective
mode + query gen + retry, orchestrator client resolution + short
query + single source + fan-out alternates + hydrabase primary +
NDJSON drain). Full suite: 788 passing (was 694).

Ruff clean.
2026-04-27 15:07:11 -07:00

121 lines
4.3 KiB
Python

"""Tests for core/search/cache.py — TTL+LRU cache for enhanced-search responses."""
from __future__ import annotations
import time
import pytest
from core.search import cache as search_cache
@pytest.fixture
def fresh_cache():
"""Each test gets a clean instance — module-level _cache is shared otherwise."""
return search_cache.EnhancedSearchCache(ttl=10, max_entries=3)
def test_set_and_get_round_trip(fresh_cache):
key = ('q', 'plex', 'spotify', False, 'auto')
fresh_cache.set(key, {'result': 'data'})
assert fresh_cache.get(key) == {'result': 'data'}
def test_get_returns_none_for_missing_key(fresh_cache):
assert fresh_cache.get(('absent', 'plex', 'spotify', False, 'auto')) is None
def test_ttl_expiration_evicts_entry():
c = search_cache.EnhancedSearchCache(ttl=0.05, max_entries=10)
key = ('q', 'plex', 'spotify', False, 'auto')
c.set(key, {'x': 1})
assert c.get(key) == {'x': 1}
time.sleep(0.1)
assert c.get(key) is None
def test_max_entries_evicts_lru(fresh_cache):
# max_entries = 3
for i in range(3):
fresh_cache.set((f"q{i}", 'plex', 'spotify', False, 'auto'), {'i': i})
fresh_cache.set(('q3', 'plex', 'spotify', False, 'auto'), {'i': 3})
# q0 should be evicted (oldest)
assert fresh_cache.get(('q0', 'plex', 'spotify', False, 'auto')) is None
assert fresh_cache.get(('q3', 'plex', 'spotify', False, 'auto')) == {'i': 3}
def test_get_promotes_lru(fresh_cache):
fresh_cache.set(('q0', 'plex', 'spotify', False, 'auto'), {'i': 0})
fresh_cache.set(('q1', 'plex', 'spotify', False, 'auto'), {'i': 1})
fresh_cache.set(('q2', 'plex', 'spotify', False, 'auto'), {'i': 2})
fresh_cache.get(('q0', 'plex', 'spotify', False, 'auto')) # touch q0
fresh_cache.set(('q3', 'plex', 'spotify', False, 'auto'), {'i': 3})
# q1 should be evicted, not q0 (which was just touched)
assert fresh_cache.get(('q1', 'plex', 'spotify', False, 'auto')) is None
assert fresh_cache.get(('q0', 'plex', 'spotify', False, 'auto')) == {'i': 0}
def test_clear_empties_cache(fresh_cache):
fresh_cache.set(('q', 'plex', 'spotify', False, 'auto'), {})
fresh_cache.clear()
assert fresh_cache.get(('q', 'plex', 'spotify', False, 'auto')) is None
# ---------------------------------------------------------------------------
# Key builder
# ---------------------------------------------------------------------------
def _providers(server='plex', source='spotify', hb=False):
return {
'active_server_provider': lambda: server,
'fallback_source_provider': lambda: source,
'hydrabase_active_provider': lambda: hb,
}
def test_key_normalizes_query():
key = search_cache.get_cache_key(" Pink FLOYD ", None, **_providers())
assert key[0] == "pink floyd"
def test_key_includes_active_server_and_fallback():
key = search_cache.get_cache_key('q', None, **_providers(server='jellyfin', source='deezer'))
assert key[1] == 'jellyfin'
assert key[2] == 'deezer'
def test_key_includes_hydrabase_flag():
k1 = search_cache.get_cache_key('q', None, **_providers(hb=False))
k2 = search_cache.get_cache_key('q', None, **_providers(hb=True))
assert k1 != k2
def test_key_includes_source_tag():
k_auto = search_cache.get_cache_key('q', None, **_providers())
k_explicit = search_cache.get_cache_key('q', 'spotify', **_providers())
assert k_auto != k_explicit
assert k_auto[4] == 'auto'
assert k_explicit[4] == 'spotify'
def test_key_provider_failure_falls_back_to_unknown():
def boom():
raise RuntimeError("config dead")
key = search_cache.get_cache_key('q', None,
active_server_provider=boom,
fallback_source_provider=lambda: 'spotify',
hydrabase_active_provider=lambda: False)
assert key[1] == 'unknown'
def test_key_hydrabase_provider_failure_falls_back_to_false():
def boom():
raise RuntimeError("hydrabase init failed")
key = search_cache.get_cache_key('q', None,
active_server_provider=lambda: 'plex',
fallback_source_provider=lambda: 'spotify',
hydrabase_active_provider=boom)
assert key[3] is False