soulsync/tests/automation/test_handlers_simple.py
Broque Thomas cc44254bf9 Personalized playlist pipeline: auto-sync discover-page playlists
Follow-up to the personalized-playlists standardization PR. New
`personalized_pipeline` automation action syncs selected discover-
page playlists (Hidden Gems / Discovery Shuffle / Time Machine /
Genre / Daily Mix / Fresh Tape / The Archives / Seasonal Mix) to
the active media server + queues missing tracks for download.

Same pattern as the existing mirrored `playlist_pipeline` but two
phases instead of four — no REFRESH (no external source to re-pull)
and no DISCOVER (manager-backed snapshots are already metadata-
matched). Pipeline shape:

    SNAPSHOT → SYNC → WISHLIST

Where SNAPSHOT either reads the persisted track list from
`PersonalizedPlaylistManager` (default) or refreshes it first when
`refresh_first=true` (cron use case: regenerate Hidden Gems nightly
and sync the fresh set).

Shared helper extraction:

PHASE 3 (SYNC loop) + PHASE 4 (WISHLIST tail) lifted out of mirrored
`playlist_pipeline` into `core/automation/handlers/_pipeline_shared.py`
as `run_sync_and_wishlist(deps, automation_id, playlists, sync_one_fn,
sync_id_for_fn, ...)`. Both pipelines call it. Mirrored injects
`auto_sync_playlist` as the per-playlist sync function; personalized
injects a thin wrapper that launches `_run_sync_task` directly with
a pre-built tracks_json. Same sync-state polling / progress emission
/ status counting / wishlist trigger logic — 0 duplication.

Files added:
- core/automation/handlers/_pipeline_shared.py
- core/automation/handlers/personalized_pipeline.py
- tests/automation/test_handlers_personalized_pipeline.py

Files changed:
- core/automation/handlers/playlist_pipeline.py: PHASE 3+4 replaced
  with shared helper call (~100 lines deleted, 1 helper invocation
  added; behavior identical).
- core/automation/deps.py: new `build_personalized_manager` field
  (lazy builder so the pipeline gets a fresh PersonalizedPlaylistManager
  per run).
- core/automation/handlers/__init__.py + registration.py: register
  `personalized_pipeline` action with the shared `pipeline_running`
  guard so it can't overlap mirrored.
- core/automation/blocks.py: new `personalized_pipeline` block
  declaration with config_fields (kinds multi-select, refresh_first,
  skip_wishlist).
- web_server.py: thread `_build_personalized_manager` into
  AutomationDeps construction.
- All 5 automation test fixtures: `_build_deps` adds
  `build_personalized_manager=lambda: None` stub.
- tests/automation/test_handler_registration.py:
  EXPECTED_ACTION_NAMES + EXPECTED_GUARDED_ACTIONS gain
  `personalized_pipeline`.

Trigger schema:

    {
      "_automation_id": "...",
      "kinds": [
        {"kind": "hidden_gems"},
        {"kind": "time_machine", "variant": "1980s"},
        {"kind": "seasonal_mix", "variant": "halloween"}
      ],
      "refresh_first": false,
      "skip_wishlist": false
    }

Tests (14 new, 178 automation total):
- _track_to_sync_shape: basic shape, source ID fallback chain,
  no-id returns empty string
- empty config / non-list kinds / empty kinds list all return
  error + clear pipeline_running flag
- _build_payloads_for_kinds: skips invalid entries, skips kinds
  with no tracks, refresh_first vs ensure dispatch, payload shape
  + sync_id format, manager exception swallowed continues
- _sync_personalized_playlist: launches background thread + returns
  status='started'
- happy path: stubbed sync_states drives helper to completion, flag
  cleaned up

Full suite: 3383 passed.

Note: the trigger UI block declares config_fields but the frontend
doesn't yet render the `personalized_playlist_select` multi-select
type — usable today via API; polished UI ships in a follow-up
frontend PR.
2026-05-15 18:41:08 -07:00

329 lines
13 KiB
Python

"""Boundary tests for the simple extracted automation handlers
(``process_wishlist``, ``scan_watchlist``, ``scan_library``).
Each handler is tested as a pure function: real ``AutomationDeps``
constructed with stub callables, no Flask, no DB, no media-server
clients. The tests exercise the success path, the guard paths
(handler short-circuits when another instance is running), the
exception-swallowing contract (handlers must NEVER raise into the
engine), and the mutable-state machinery for handlers that own a
flag in ``AutomationState``.
Pre-extraction these closures lived inside
``web_server._register_automation_handlers`` and were essentially
un-testable — every test would have needed to spin up the whole
Flask app and stub a dozen module-level globals."""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, List, Optional
import pytest
from core.automation.deps import AutomationDeps, AutomationState
from core.automation.handlers.process_wishlist import auto_process_wishlist
from core.automation.handlers.scan_watchlist import auto_scan_watchlist
from core.automation.handlers.scan_library import auto_scan_library
# ─── shared test scaffolding ──────────────────────────────────────────
def _build_deps(**overrides: Any) -> AutomationDeps:
"""Return a default `AutomationDeps` with no-op callables. Tests
pass ``overrides`` to install behaviour on the specific deps they
care about."""
class _StubLogger:
def debug(self, *_a, **_k): pass
def info(self, *_a, **_k): pass
def warning(self, *_a, **_k): pass
def error(self, *_a, **_k): pass
defaults = dict(
engine=object(),
state=AutomationState(),
config_manager=object(),
update_progress=lambda *a, **k: None,
logger=_StubLogger(),
get_database=lambda: object(),
spotify_client=None,
tidal_client=None,
web_scan_manager=None,
process_wishlist_automatically=lambda **k: None,
process_watchlist_scan_automatically=lambda **k: None,
is_wishlist_actually_processing=lambda: False,
is_watchlist_actually_scanning=lambda: False,
get_watchlist_scan_state=lambda: {},
run_playlist_discovery_worker=lambda *a, **k: None,
run_sync_task=lambda *a, **k: None,
load_sync_status_file=lambda: {},
get_deezer_client=lambda: None,
parse_youtube_playlist=lambda url: None,
get_sync_states=lambda: {},
set_db_update_automation_id=lambda v: None,
get_db_update_state=lambda: {},
db_update_lock=threading.Lock(),
db_update_executor=None,
run_db_update_task=lambda *a, **k: None,
run_deep_scan_task=lambda *a, **k: None,
get_duplicate_cleaner_state=lambda: {},
duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {},
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None,
run_async=lambda coro: None,
tasks_lock=threading.Lock(),
get_download_batches=lambda: {},
get_download_tasks=lambda: {},
sweep_empty_download_directories=lambda: 0,
get_staging_path=lambda: '/staging',
docker_resolve_path=lambda p: p,
get_current_profile_id=lambda: 1,
get_watchlist_scanner=lambda spc: None,
get_app=lambda: None,
get_beatport_data_cache=lambda: {'cache_lock': threading.Lock(), 'homepage': {}},
init_automation_progress=lambda *a, **k: None,
record_progress_history=lambda *a, **k: None,
build_personalized_manager=lambda: None,
)
defaults.update(overrides)
return AutomationDeps(**defaults) # type: ignore[arg-type]
# ─── process_wishlist ─────────────────────────────────────────────────
class TestProcessWishlist:
def test_success_returns_completed_status(self):
called: List[Any] = []
def stub(automation_id=None):
called.append(automation_id)
deps = _build_deps(process_wishlist_automatically=stub)
result = auto_process_wishlist({'_automation_id': 'auto-1'}, deps)
assert result == {'status': 'completed'}
assert called == ['auto-1']
def test_passes_none_when_no_automation_id(self):
called: List[Any] = []
def stub(automation_id=None):
called.append(automation_id)
deps = _build_deps(process_wishlist_automatically=stub)
result = auto_process_wishlist({}, deps)
assert result == {'status': 'completed'}
assert called == [None]
def test_handler_swallows_exceptions(self):
def stub(**_kwargs):
raise RuntimeError('boom')
deps = _build_deps(process_wishlist_automatically=stub)
result = auto_process_wishlist({'_automation_id': 'a'}, deps)
assert result == {'status': 'error', 'error': 'boom'}
# ─── scan_watchlist ──────────────────────────────────────────────────
class TestScanWatchlist:
def test_fresh_scan_reports_summary_stats(self):
# Worker reassigns the state dict mid-run — handler detects
# via id() change and reports stats.
states = [
{'summary': {}},
{'summary': {
'total_artists': 5,
'successful_scans': 4,
'new_tracks_found': 12,
'tracks_added_to_wishlist': 8,
}},
]
idx = {'i': 0}
def get_state():
return states[idx['i']]
def stub(**_kwargs):
idx['i'] = 1 # simulate the worker swapping the dict
deps = _build_deps(
process_watchlist_scan_automatically=stub,
get_watchlist_scan_state=get_state,
)
result = auto_scan_watchlist({}, deps)
assert result == {
'status': 'completed',
'artists_scanned': 5,
'successful_scans': 4,
'new_tracks_found': 12,
'tracks_added_to_wishlist': 8,
}
def test_no_fresh_scan_returns_bare_completed(self):
# Same dict identity before and after = no fresh scan ran.
same_dict = {'summary': {'total_artists': 999}}
deps = _build_deps(
process_watchlist_scan_automatically=lambda **_k: None,
get_watchlist_scan_state=lambda: same_dict,
)
result = auto_scan_watchlist({}, deps)
assert result == {'status': 'completed'}
def test_handler_swallows_exceptions(self):
def stub(**_kwargs):
raise ValueError('no scanner')
deps = _build_deps(process_watchlist_scan_automatically=stub)
result = auto_scan_watchlist({}, deps)
assert result == {'status': 'error', 'error': 'no scanner'}
# ─── scan_library ────────────────────────────────────────────────────
@dataclass
class _StubScanManager:
"""Minimal fake of ``web_scan_manager`` — records calls + lets
tests script its responses."""
request_responses: List[dict] = field(default_factory=list)
status_responses: List[dict] = field(default_factory=list)
request_calls: List[str] = field(default_factory=list)
def request_scan(self, label: str) -> dict:
self.request_calls.append(label)
return self.request_responses.pop(0) if self.request_responses else {'status': 'queued'}
def get_scan_status(self) -> dict:
return self.status_responses.pop(0) if self.status_responses else {'status': 'idle'}
class TestScanLibrary:
def test_no_scan_manager_returns_error(self):
deps = _build_deps(web_scan_manager=None)
result = auto_scan_library({'_automation_id': 'a'}, deps)
assert result == {'status': 'error', 'reason': 'Scan manager not available'}
def test_already_tracked_returns_skipped(self):
# Pre-set the state flag — handler should short-circuit.
state = AutomationState()
state.scan_library_automation_id = 'someone-else'
scanner = _StubScanManager(request_responses=[{'status': 'queued'}])
deps = _build_deps(state=state, web_scan_manager=scanner)
result = auto_scan_library({'_automation_id': 'a'}, deps)
assert result == {'status': 'skipped', 'reason': 'Scan already being tracked'}
assert scanner.request_calls == ['Automation trigger (additional batch)']
def test_scan_completes_normally(self):
# request_scan returns scheduled; first poll = scheduled;
# second poll = scanning; third poll = idle.
scanner = _StubScanManager(
request_responses=[{'status': 'scheduled', 'delay_seconds': 5}],
status_responses=[
{'status': 'scheduled'},
{'status': 'scanning', 'elapsed_seconds': 10, 'max_time_seconds': 100},
{'status': 'idle'},
],
)
progress: List[dict] = []
def stub_progress(automation_id, **kwargs):
progress.append({'aid': automation_id, **kwargs})
deps = _build_deps(
web_scan_manager=scanner,
update_progress=stub_progress,
)
# Patch time.sleep so the test runs instantly.
import core.automation.handlers.scan_library as module
original = module.time.sleep
module.time.sleep = lambda _: None
try:
result = auto_scan_library({'_automation_id': 'auto-1'}, deps)
finally:
module.time.sleep = original
assert result['status'] == 'completed'
assert result.get('_manages_own_progress') is True
# State flag cleaned up after run
assert deps.state.scan_library_automation_id is None
# Progress phases emitted: scheduled, scan-start, scanning, completed
statuses = [p.get('status') for p in progress]
assert 'finished' in statuses
def test_state_cleanup_on_exception(self):
class ExplodingScanner:
def request_scan(self, _):
raise RuntimeError('boom')
def get_scan_status(self):
return {'status': 'idle'}
progress: List[dict] = []
deps = _build_deps(
web_scan_manager=ExplodingScanner(),
update_progress=lambda aid, **kw: progress.append({'aid': aid, **kw}),
)
result = auto_scan_library({'_automation_id': 'auto-x'}, deps)
assert result['status'] == 'error'
assert result['_manages_own_progress'] is True
# State flag still cleaned up
assert deps.state.scan_library_automation_id is None
# Error progress emitted
assert any(p.get('status') == 'error' for p in progress)
# ─── AutomationState ──────────────────────────────────────────────────
class TestAutomationState:
def test_default_state(self):
s = AutomationState()
assert s.scan_library_automation_id is None
assert s.db_update_automation_id is None
assert s.pipeline_running is False
assert s.is_scan_library_active() is False
assert s.is_pipeline_running() is False
def test_set_scan_library_id(self):
s = AutomationState()
s.set_scan_library_id('auto-1')
assert s.scan_library_automation_id == 'auto-1'
assert s.is_scan_library_active() is True
s.set_scan_library_id(None)
assert s.is_scan_library_active() is False
def test_set_pipeline_running(self):
s = AutomationState()
s.set_pipeline_running(True)
assert s.is_pipeline_running() is True
s.set_pipeline_running(False)
assert s.is_pipeline_running() is False
def test_concurrent_set_safe_via_lock(self):
# Smoke test: two threads flipping the same field don't crash.
# Lock ensures the final value is consistent.
s = AutomationState()
def worker():
for _ in range(100):
s.set_pipeline_running(True)
s.set_pipeline_running(False)
threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
assert s.pipeline_running is False