soulsync/tests/search/test_search_sources.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

174 lines
5.8 KiB
Python

"""Tests for core/search/sources.py — per-source-kind + multi-kind executor."""
from __future__ import annotations
from core.search import sources
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _Artist:
def __init__(self, id_, name, image_url=None, external_urls=None):
self.id = id_
self.name = name
self.image_url = image_url
self.external_urls = external_urls
class _Album:
def __init__(self, id_, name, artists=None, image_url=None, release_date=None,
total_tracks=10, album_type='album', external_urls=None):
self.id = id_
self.name = name
self.artists = artists or []
self.image_url = image_url
self.release_date = release_date
self.total_tracks = total_tracks
self.album_type = album_type
self.external_urls = external_urls
class _Track:
def __init__(self, id_, name, artists=None, album=None, duration_ms=180000,
image_url=None, release_date=None, external_urls=None):
self.id = id_
self.name = name
self.artists = artists or []
self.album = album
self.duration_ms = duration_ms
self.image_url = image_url
self.release_date = release_date
self.external_urls = external_urls
class _Client:
def __init__(self, artists=None, albums=None, tracks=None, fail=None):
self._artists = artists or []
self._albums = albums or []
self._tracks = tracks or []
self._fail = fail or set()
def search_artists(self, q, limit=10):
if 'artists' in self._fail:
raise RuntimeError("artists boom")
return self._artists
def search_albums(self, q, limit=10):
if 'albums' in self._fail:
raise RuntimeError("albums boom")
return self._albums
def search_tracks(self, q, limit=10):
if 'tracks' in self._fail:
raise RuntimeError("tracks boom")
return self._tracks
# ---------------------------------------------------------------------------
# search_kind
# ---------------------------------------------------------------------------
def test_search_kind_artists_returns_normalized_dicts():
client = _Client(artists=[_Artist('id1', 'Pink Floyd', 'thumb.jpg', {'spotify': 'url'})])
result = sources.search_kind(client, 'pink', 'artists', 'spotify')
assert result == [{
'id': 'id1',
'name': 'Pink Floyd',
'image_url': 'thumb.jpg',
'external_urls': {'spotify': 'url'},
}]
def test_search_kind_artists_handles_none_external_urls():
client = _Client(artists=[_Artist('id1', 'X', None, None)])
result = sources.search_kind(client, 'x', 'artists')
assert result[0]['external_urls'] == {}
def test_search_kind_albums_joins_multiple_artists():
client = _Client(albums=[_Album('a1', 'DSOTM', artists=['Pink Floyd', 'Roger'])])
result = sources.search_kind(client, 'd', 'albums')
assert result[0]['artist'] == 'Pink Floyd, Roger'
def test_search_kind_albums_handles_no_artists():
client = _Client(albums=[_Album('a1', 'Mystery', artists=[])])
result = sources.search_kind(client, 'm', 'albums')
assert result[0]['artist'] == 'Unknown Artist'
def test_search_kind_tracks_returns_full_shape():
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
duration_ms=383000, image_url='m.jpg',
release_date='1973-03-01', external_urls={'a': 'b'})])
result = sources.search_kind(client, 'money', 'tracks')
assert result == [{
'id': 't1',
'name': 'Money',
'artist': 'Pink Floyd',
'album': 'DSOTM',
'duration_ms': 383000,
'image_url': 'm.jpg',
'release_date': '1973-03-01',
'external_urls': {'a': 'b'},
}]
def test_search_kind_swallows_artist_errors():
client = _Client(fail={'artists'})
assert sources.search_kind(client, 'q', 'artists') == []
def test_search_kind_swallows_album_errors():
client = _Client(fail={'albums'})
assert sources.search_kind(client, 'q', 'albums') == []
def test_search_kind_swallows_track_errors():
client = _Client(fail={'tracks'})
assert sources.search_kind(client, 'q', 'tracks') == []
def test_search_kind_unknown_kind_raises():
import pytest
with pytest.raises(ValueError):
sources.search_kind(_Client(), 'q', 'movies')
# ---------------------------------------------------------------------------
# search_source — multi-kind executor
# ---------------------------------------------------------------------------
def test_search_source_returns_all_three_kinds():
client = _Client(
artists=[_Artist('a', 'A')],
albums=[_Album('b', 'B', artists=['A'])],
tracks=[_Track('c', 'C', artists=['A'], album='B')],
)
result = sources.search_source('q', client, 'spotify')
assert result['available'] is True
assert len(result['artists']) == 1
assert len(result['albums']) == 1
assert len(result['tracks']) == 1
def test_search_source_partial_failure_does_not_break_others():
client = _Client(
artists=[_Artist('a', 'A')],
albums=[_Album('b', 'B')],
tracks=[_Track('c', 'C')],
fail={'albums'},
)
result = sources.search_source('q', client, 'spotify')
assert result['available'] is True
assert result['artists'] != []
assert result['albums'] == []
assert result['tracks'] != []
def test_search_source_all_fail_returns_empty_lists():
client = _Client(fail={'artists', 'albums', 'tracks'})
result = sources.search_source('q', client, 'spotify')
assert result == {'artists': [], 'albums': [], 'tracks': [], 'available': True}