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.
This commit is contained in:
Broque Thomas 2026-05-24 22:58:48 -07:00
parent 871feb3997
commit 687bb0ca2c
2 changed files with 217 additions and 0 deletions

View file

@ -289,6 +289,56 @@ def test_update_then_actions_clears_notify_when_empty():
assert db.automations[1]['notify_config'] == '{}'
def test_update_trigger_config_change_resets_next_run():
"""Changing the interval must blank stored next_run so the engine
recomputes from scratch otherwise dragging a playlist from the 8h
Auto-Sync column to the 1h column keeps firing at the old 8h mark."""
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule',
'trigger_config': '{"interval": 8, "unit": "hours"}',
'then_actions': '[]',
'next_run': '2026-05-25 05:00:00',
}
eng = _FakeEngine()
body, status = api.update_automation(db, eng, automation_id=1, data={
'trigger_config': {'interval': 1, 'unit': 'hours'},
})
assert status == 200
assert db.automations[1]['next_run'] is None
assert eng.scheduled == [1]
def test_update_trigger_type_change_resets_next_run():
"""Switching from interval to daily_time must also reset next_run."""
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule', 'trigger_config': '{}',
'then_actions': '[]',
'next_run': '2026-05-25 05:00:00',
}
api.update_automation(db, _FakeEngine(), automation_id=1, data={
'trigger_type': 'daily_time',
})
assert db.automations[1]['next_run'] is None
def test_update_non_trigger_field_preserves_next_run():
"""Renaming an automation must NOT disturb its scheduled clock —
the reset is scoped to schedule-shape changes only."""
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule', 'trigger_config': '{}',
'then_actions': '[]',
'next_run': '2026-05-25 05:00:00',
}
api.update_automation(db, _FakeEngine(), automation_id=1, data={'name': 'renamed'})
assert db.automations[1].get('next_run') == '2026-05-25 05:00:00'
# ---------------------------------------------------------------------------
# batch_update_group
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,167 @@
"""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',
)