soulsync/tests/sync/test_sync_candidate_pool.py
Broque Thomas 687bb0ca2c Add tests for next_run reset and lazy candidate pool
`tests/automation/test_automation_api.py` gains three update_automation
tests covering the schedule-shape reset:
- trigger_config change blanks next_run
- trigger_type change blanks next_run
- non-trigger field (name) leaves next_run alone

`tests/sync/test_sync_candidate_pool.py` is new — nine tests for the
lazy artist track pool in PlaylistSyncService:
- candidate_pool=None disables pooling and skips the DB call
- first lookup for an artist fetches and caches
- second lookup for the same artist reuses the cache (zero DB calls)
- empty result still cached so the next call short-circuits without SQL
- defensive None return coerced to []
- search_tracks exception returns None and does NOT poison the cache
- pool key is normalized so casing variants share a single fetch
- different artists get separate pool entries
- server_source plumbing survives the trip to search_tracks

All assertions go through fakes / MagicMock — no real DB, no
web_server.py import, no AST-parsing.
2026-05-24 22:58:48 -07:00

167 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the lazy per-artist candidate pool in PlaylistSyncService.
The pool replaces a per-track SQL storm: instead of running ~30
title-variation × artist-variation queries for every playlist track,
sync now fetches each unique artist's library tracks once and feeds the
matcher via the in-memory `candidate_tracks` path. The fetch is *lazy*
— it only fires when a track actually misses the sync_match_cache,
so warm-cache playlists pay zero pool cost.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from services.sync_service import PlaylistSyncService
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_service() -> PlaylistSyncService:
"""Bare PlaylistSyncService — pool helper doesn't touch service state."""
return PlaylistSyncService(
spotify_client=MagicMock(),
download_orchestrator=MagicMock(),
media_server_engine=MagicMock(),
)
def _make_db_stub(search_returns=None, raise_on_search=None) -> MagicMock:
"""MusicDatabase stub mirroring the contract the helper relies on:
- _normalize_for_comparison returns a lower-cased key
- search_tracks returns a list (or raises)
"""
db = MagicMock()
db._normalize_for_comparison.side_effect = lambda s: s.lower().strip()
if raise_on_search is not None:
db.search_tracks.side_effect = raise_on_search
else:
db.search_tracks.return_value = search_returns if search_returns is not None else []
return db
# ---------------------------------------------------------------------------
# Pooling disabled — legacy fallback
# ---------------------------------------------------------------------------
def test_returns_none_when_pool_disabled():
"""candidate_pool=None signals callers to fall through to the legacy
per-track SQL loop. Helper must not touch the DB."""
svc = _make_service()
db = _make_db_stub()
result = svc._get_or_fetch_artist_candidates(None, db, 'Drake', 'plex')
assert result is None
db.search_tracks.assert_not_called()
# ---------------------------------------------------------------------------
# Lazy population
# ---------------------------------------------------------------------------
def test_first_call_for_artist_runs_search_and_caches():
svc = _make_service()
pool: dict = {}
db = _make_db_stub(search_returns=['t1', 't2'])
result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex')
assert result == ['t1', 't2']
assert pool == {'drake': ['t1', 't2']}
db.search_tracks.assert_called_once_with(
artist='Drake', limit=10000, server_source='plex',
)
def test_second_call_for_same_artist_reuses_cache():
"""Once an artist's pool is populated, subsequent lookups must not
re-fetch — that's the whole perf point of the pool."""
svc = _make_service()
pool = {'drake': ['cached']}
db = _make_db_stub(search_returns=['fresh'])
result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex')
assert result == ['cached']
db.search_tracks.assert_not_called()
def test_empty_result_is_still_cached():
"""Artist not in library → empty list cached. Next call short-circuits
via check_track_exists' batched path without firing SQL."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(search_returns=[])
result = svc._get_or_fetch_artist_candidates(pool, db, 'Obscure', 'plex')
assert result == []
assert pool == {'obscure': []}
def test_none_return_normalized_to_empty_list():
"""Defensive — if search_tracks ever returns None, helper must coerce
to [] so the cached value is still a valid iterable for the matcher."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub()
db.search_tracks.return_value = None
result = svc._get_or_fetch_artist_candidates(pool, db, 'Anyone', 'plex')
assert result == []
assert pool == {'anyone': []}
# ---------------------------------------------------------------------------
# Failure modes
# ---------------------------------------------------------------------------
def test_fetch_failure_returns_none_and_does_not_cache():
"""A pool fetch exception must not poison the dict — the per-track
legacy path still has a chance to run for this track, and a later
track for the same artist can retry the fetch."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(raise_on_search=RuntimeError('DB exploded'))
result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex')
assert result is None
assert pool == {}
# ---------------------------------------------------------------------------
# Normalization
# ---------------------------------------------------------------------------
def test_pool_key_is_normalized_so_casing_variants_share_one_fetch():
"""'Drake' and 'DRAKE' must hash to the same pool entry — otherwise
a playlist that mixes casing would re-fetch the same artist twice."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(search_returns=['t'])
svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex')
db.search_tracks.reset_mock()
result = svc._get_or_fetch_artist_candidates(pool, db, 'DRAKE', 'plex')
assert result == ['t']
db.search_tracks.assert_not_called()
def test_different_artists_get_separate_pool_entries():
svc = _make_service()
pool: dict = {}
db = _make_db_stub()
db.search_tracks.side_effect = [['drake-track'], ['sza-track']]
svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex')
svc._get_or_fetch_artist_candidates(pool, db, 'SZA', 'plex')
assert pool == {'drake': ['drake-track'], 'sza': ['sza-track']}
assert db.search_tracks.call_count == 2
# ---------------------------------------------------------------------------
# Server source plumbing
# ---------------------------------------------------------------------------
def test_active_server_is_passed_through_to_search_tracks():
"""Misrouting server_source would make the pool include tracks from
the wrong server (e.g. Plex tracks in a Jellyfin sync) — verify it
survives the trip."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(search_returns=['t'])
svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'jellyfin')
db.search_tracks.assert_called_once_with(
artist='Drake', limit=10000, server_source='jellyfin',
)