Expose playlist-native run and status endpoints that reuse the shared mirrored playlist pipeline engine while routing progress into playlist UI state. Add a Run Pipeline action to mirrored playlist cards and modals with live status polling, and make the shared pipeline lock atomic for manual and scheduled callers.
337 lines
13 KiB
Python
337 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_try_start_pipeline_is_atomic(self):
|
|
s = AutomationState()
|
|
assert s.try_start_pipeline() is True
|
|
assert s.is_pipeline_running() is True
|
|
assert s.try_start_pipeline() is False
|
|
s.set_pipeline_running(False)
|
|
assert s.try_start_pipeline() is True
|
|
|
|
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
|