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.
329 lines
13 KiB
Python
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
|