mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
330 lines
13 KiB
330 lines
13 KiB
"""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
|