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.
SoulSync/tests/test_reorganize_runner.py

236 lines
8.5 KiB

"""Tests for `core.reorganize_runner.build_runner`.
Contract this test file pins:
1. **Runner is a closure** — calling `build_runner` returns a callable
that takes a queue item and returns a summary dict matching
`reorganize_album`'s shape.
2. **Config is read per-run, not at factory time** — changing the
download/transfer path between runs is honoured. Web server config
should never need a restart for this to take effect.
3. **Setup failure surfaces a clean summary** — if the staging dir
cannot be created, the runner returns `status='setup_failed'`
instead of raising (so the queue marks the item failed cleanly).
4. **Progress callbacks fan out into the queue** — the runner wires
`reorganize_album`'s `on_progress` to `update_active_progress` on
the live singleton queue, so the status panel sees per-track state.
5. **Dependencies are injected, not imported** — the factory takes
every external dependency as a callable so tests can run without
spinning up Flask, the DB, or the post-process pipeline.
"""
import sys
import types
from unittest.mock import MagicMock
import pytest
# Stub config.settings so importing core.reorganize_runner -> core.library_reorganize doesn't blow up
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "primary"
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
spotipy.Spotify = object
oauth2 = types.ModuleType("spotipy.oauth2")
oauth2.SpotifyOAuth = object
oauth2.SpotifyClientCredentials = object
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
from core.reorganize_runner import build_runner # noqa: E402
@pytest.fixture(autouse=True)
def reset_queue_singleton():
"""Each test gets a fresh queue singleton so update_active_progress
in one test doesn't leak into another."""
from core.reorganize_queue import reset_queue_for_tests
reset_queue_for_tests()
yield
reset_queue_for_tests()
def _make_item(*, queue_id='qid-1', album_id='alb-1', source=None):
"""Mock queue item — only needs the fields the runner reads."""
item = MagicMock()
item.queue_id = queue_id
item.album_id = album_id
item.source = source
return item
def _build(monkeypatch, *, download_path_fn, transfer_path_fn,
reorganize_album_fn, get_database=lambda: object()):
"""Helper: stub out the heavy reorganize_album call so we can test
the wiring without a real DB / post-process pipeline."""
# Patch the import inside reorganize_runner.build_runner.
import core.reorganize_runner as mod
monkeypatch.setattr(
'core.library_reorganize.reorganize_album',
reorganize_album_fn,
raising=True,
)
return build_runner(
get_database=get_database,
resolve_file_path_fn=lambda p: p,
post_process_fn=lambda *a, **k: None,
cleanup_empty_directories_fn=lambda *a, **k: None,
is_shutting_down_fn=lambda: False,
get_download_path=download_path_fn,
get_transfer_path=transfer_path_fn,
)
def test_runner_invokes_reorganize_album_with_injected_deps(monkeypatch, tmp_path):
captured = {}
def fake_reorganize_album(**kwargs):
captured.update(kwargs)
return {
'status': 'completed', 'source': 'spotify',
'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
runner = _build(
monkeypatch,
download_path_fn=lambda: str(tmp_path),
transfer_path_fn=lambda: str(tmp_path / 'transfer'),
reorganize_album_fn=fake_reorganize_album,
)
item = _make_item(album_id='alb-X', source='deezer')
summary = runner(item)
assert summary['status'] == 'completed'
assert captured['album_id'] == 'alb-X'
assert captured['primary_source'] == 'deezer'
assert captured['strict_source'] is True
# staging_root is download_path / ssync_staging
assert captured['staging_root'].endswith('ssync_staging')
assert callable(captured['on_progress'])
assert callable(captured['stop_check'])
def test_runner_reads_config_per_call(monkeypatch, tmp_path):
"""Path that the runner sees should reflect the value returned by
the path-resolver lambda AT call time — not at build_runner time.
This is the explicit fix for kettui-style "config change requires
server restart" feedback."""
seen_staging_roots = []
def fake_reorganize_album(**kwargs):
seen_staging_roots.append(kwargs['staging_root'])
return {
'status': 'completed', 'source': None,
'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0, 'errors': [],
}
current_path = {'value': str(tmp_path / 'first')}
runner = _build(
monkeypatch,
download_path_fn=lambda: current_path['value'],
transfer_path_fn=lambda: '/tmp/transfer',
reorganize_album_fn=fake_reorganize_album,
)
runner(_make_item())
current_path['value'] = str(tmp_path / 'second')
runner(_make_item())
assert len(seen_staging_roots) == 2
assert 'first' in seen_staging_roots[0]
assert 'second' in seen_staging_roots[1]
def test_runner_returns_setup_failed_on_unwritable_path(monkeypatch, tmp_path):
"""If the staging dir can't be created (permission denied, etc.),
the runner returns a clean ``setup_failed`` summary so the queue
marks the item failed without an unhandled exception."""
def fake_reorganize_album(**kwargs):
pytest.fail("reorganize_album should not run when setup fails")
# Point at a child of an existing FILE — makedirs will raise OSError.
blocking_file = tmp_path / 'blocker'
blocking_file.write_text('x')
runner = _build(
monkeypatch,
download_path_fn=lambda: str(blocking_file), # makedirs fails here
transfer_path_fn=lambda: '/tmp/transfer',
reorganize_album_fn=fake_reorganize_album,
)
summary = runner(_make_item())
assert summary['status'] == 'setup_failed'
assert summary['errors']
def test_runner_progress_callback_forwards_to_queue(monkeypatch, tmp_path):
"""When reorganize_album fires its on_progress callback, the runner
must forward into the live queue's update_active_progress so the
status panel sees per-track updates."""
from core.reorganize_queue import get_queue, ReorganizeQueue
import threading
# Use a real queue that's blocked on a runner — gives us a known
# 'running' item to propagate progress into.
block = threading.Event()
def fake_reorganize_album(*, on_progress, **kwargs):
# Simulate per-track progress emissions like the real
# orchestrator does.
on_progress({'current_track': 'Backseat Freestyle', 'total': 12, 'processed': 1})
on_progress({'moved': 1, 'processed': 1})
return {
'status': 'completed', 'source': 'spotify',
'total': 12, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
runner = _build(
monkeypatch,
download_path_fn=lambda: str(tmp_path),
transfer_path_fn=lambda: str(tmp_path / 'transfer'),
reorganize_album_fn=fake_reorganize_album,
)
# Wire our runner into the singleton queue and enqueue an item, so
# update_active_progress has a 'running' item to write into.
q = get_queue()
q.set_runner(runner)
enq = q.enqueue(album_id='alb-1', album_title='good kid',
artist_id='ar-1', artist_name='Kendrick Lamar', source=None)
# Wait for the worker to finish (fake_reorganize_album is fast).
deadline_passes = 0
import time
while deadline_passes < 50:
snap = q.snapshot()
if any(r['queue_id'] == enq['queue_id'] for r in snap['recent']):
break
time.sleep(0.02)
deadline_passes += 1
snap = q.snapshot()
finished = next(r for r in snap['recent'] if r['queue_id'] == enq['queue_id'])
assert finished['status'] == 'done'
assert finished['moved'] == 1
# The progress fan-out happened *while* the item was running. The
# final snapshot shows the worker-set values — what we're really
# asserting is that progress callbacks didn't raise.