soulsync/tests/downloads/test_downloads_cancel.py
Broque Thomas dc2835eecc PR4b: lift cancel + clear download routes to core/downloads/cancel.py
Second sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change.

What moved:
- cancel_download (single slskd cancel) → cancel_single_download
- cancel_all_downloads (cancel + clear + sweep) → cancel_all_active
- clear_finished_downloads (slskd clear + sweep) → clear_finished_active
- clear_completed_downloads (local task tracker prune) → clear_completed_local

Slskd-touching helpers take (soulseek_client, run_async, sweep_callback)
explicitly so the route layer wires the live client + the existing
_sweep_empty_download_directories helper. The local-state helper imports
download_tasks/download_batches/batch_locks/tasks_lock straight from
core.runtime_state since those are module-level shared globals.

Prep change: `batch_locks` dict moved from web_server.py global into
core/runtime_state.py alongside the other download globals. web_server.py
re-imports from runtime_state so the ~3 existing call sites in
web_server.py keep resolving without modification. Identity preserved
(same dict across all importers).

Out of scope (deferred to PR4g batch lifecycle):
- cancel_download_task (calls _on_download_completed)
- cancel_task_v2 + _atomic_cancel_task + _find_task_by_playlist_track
  (manipulate batch active_count directly, deeply coupled to lifecycle)

Behavior parity:
- Same response shapes + status codes on each route
- Same call order (cancel_all → clear_all_completed → sweep)
- Same conditional sweep on clear_finished (skipped on failure)
- Same sweep ALWAYS runs after cancel_all even if clear_all returns False
  (matches original — clear failure was non-fatal in cancel_all path)
- Same TERMINAL_STATUSES set: completed/failed/not_found/cancelled/skipped/
  already_owned (lifted to module-level constant)
- Same empty-batch pruning + same batch_locks cleanup
- Same lock acquisition pattern (single tasks_lock)

Tests: 14 new under tests/downloads/test_downloads_cancel.py covering
single cancel, cancel-all happy + failure paths, clear-finished + sweep
gate, local task pruning across all 7 active/terminal states, batch
queue trimming, batch_locks cleanup.

Full suite: 921 passing (was 907). Ruff clean.
2026-04-27 21:41:35 -07:00

211 lines
7.1 KiB
Python

"""Tests for core/downloads/cancel.py — slskd cancel + clear + local task pruning."""
from __future__ import annotations
import pytest
from core.downloads import cancel
from core.runtime_state import (
batch_locks,
download_batches,
download_tasks,
)
@pytest.fixture(autouse=True)
def reset_state():
"""Each test gets clean download_tasks / download_batches / batch_locks."""
download_tasks.clear()
download_batches.clear()
batch_locks.clear()
yield
download_tasks.clear()
download_batches.clear()
batch_locks.clear()
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeSoulseek:
def __init__(self, cancel_result=True, cancel_all_result=True, clear_result=True):
self._cancel_result = cancel_result
self._cancel_all_result = cancel_all_result
self._clear_result = clear_result
self.cancel_calls = []
self.cancel_all_calls = 0
self.clear_calls = 0
async def cancel_download(self, download_id, username, remove=False):
self.cancel_calls.append((download_id, username, remove))
return self._cancel_result
async def cancel_all_downloads(self):
self.cancel_all_calls += 1
return self._cancel_all_result
async def clear_all_completed_downloads(self):
self.clear_calls += 1
return self._clear_result
def _sync_run_async(coro):
"""Drain a coroutine on a fresh loop."""
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
# ---------------------------------------------------------------------------
# cancel_single_download
# ---------------------------------------------------------------------------
def test_cancel_single_passes_args_with_remove_true():
sk = _FakeSoulseek()
result = cancel.cancel_single_download(sk, _sync_run_async, 'dl-123', 'user-x')
assert result is True
assert sk.cancel_calls == [('dl-123', 'user-x', True)]
def test_cancel_single_propagates_failure():
sk = _FakeSoulseek(cancel_result=False)
result = cancel.cancel_single_download(sk, _sync_run_async, 'dl', 'u')
assert result is False
# ---------------------------------------------------------------------------
# cancel_all_active
# ---------------------------------------------------------------------------
def test_cancel_all_happy_path():
sk = _FakeSoulseek()
sweeps = []
success, msg = cancel.cancel_all_active(sk, _sync_run_async, lambda: sweeps.append(1))
assert success is True
assert msg == "All downloads cancelled and cleared."
assert sk.cancel_all_calls == 1
assert sk.clear_calls == 1
assert sweeps == [1]
def test_cancel_all_returns_failure_if_cancel_step_fails():
sk = _FakeSoulseek(cancel_all_result=False)
sweeps = []
success, msg = cancel.cancel_all_active(sk, _sync_run_async, lambda: sweeps.append(1))
assert success is False
assert msg == "Failed to cancel active downloads."
# Clear/sweep should NOT run when cancel fails
assert sk.clear_calls == 0
assert sweeps == []
def test_cancel_all_runs_sweep_even_if_clear_returns_false():
"""Clear returning False is not a hard error — sweep still runs (matches original)."""
sk = _FakeSoulseek(clear_result=False)
sweeps = []
success, msg = cancel.cancel_all_active(sk, _sync_run_async, lambda: sweeps.append(1))
assert success is True
assert sweeps == [1]
# ---------------------------------------------------------------------------
# clear_finished_active
# ---------------------------------------------------------------------------
def test_clear_finished_happy_path_calls_sweep():
sk = _FakeSoulseek()
sweeps = []
success = cancel.clear_finished_active(sk, _sync_run_async, lambda: sweeps.append(1))
assert success is True
assert sk.clear_calls == 1
assert sweeps == [1]
def test_clear_finished_failure_skips_sweep():
sk = _FakeSoulseek(clear_result=False)
sweeps = []
success = cancel.clear_finished_active(sk, _sync_run_async, lambda: sweeps.append(1))
assert success is False
assert sweeps == []
# ---------------------------------------------------------------------------
# clear_completed_local
# ---------------------------------------------------------------------------
def test_clear_completed_removes_terminal_tasks():
download_tasks['t1'] = {'status': 'completed'}
download_tasks['t2'] = {'status': 'failed'}
download_tasks['t3'] = {'status': 'downloading'} # still active
download_tasks['t4'] = {'status': 'cancelled'}
download_tasks['t5'] = {'status': 'not_found'}
download_tasks['t6'] = {'status': 'skipped'}
download_tasks['t7'] = {'status': 'already_owned'}
cleared = cancel.clear_completed_local()
assert cleared == 6
assert set(download_tasks.keys()) == {'t3'}
def test_clear_completed_keeps_searching_and_queued():
"""Active states ('searching', 'queued', 'downloading', 'pending') stay."""
download_tasks['t1'] = {'status': 'searching'}
download_tasks['t2'] = {'status': 'queued'}
download_tasks['t3'] = {'status': 'downloading'}
download_tasks['t4'] = {'status': 'pending'}
cleared = cancel.clear_completed_local()
assert cleared == 0
assert set(download_tasks.keys()) == {'t1', 't2', 't3', 't4'}
def test_clear_completed_drops_empty_batches():
download_tasks['t1'] = {'status': 'completed'}
download_batches['b1'] = {'queue': ['t1']} # all tasks will be cleared
download_batches['b2'] = {'queue': ['t2']} # t2 doesn't exist either
download_tasks['t3'] = {'status': 'downloading'}
download_batches['b3'] = {'queue': ['t3']} # t3 stays
cancel.clear_completed_local()
assert 'b1' not in download_batches
assert 'b2' not in download_batches
assert 'b3' in download_batches
assert download_batches['b3']['queue'] == ['t3']
def test_clear_completed_prunes_terminal_task_ids_from_batch_queues():
"""Batch with mix of terminal + active tasks gets queue trimmed, not deleted."""
download_tasks['t1'] = {'status': 'completed'}
download_tasks['t2'] = {'status': 'downloading'}
download_batches['b1'] = {'queue': ['t1', 't2']}
cancel.clear_completed_local()
assert 'b1' in download_batches
assert download_batches['b1']['queue'] == ['t2']
def test_clear_completed_drops_batch_locks_for_deleted_batches():
import threading
download_tasks['t1'] = {'status': 'completed'}
download_batches['b1'] = {'queue': ['t1']}
batch_locks['b1'] = threading.Lock()
cancel.clear_completed_local()
assert 'b1' not in batch_locks
def test_clear_completed_keeps_batch_locks_for_surviving_batches():
import threading
download_tasks['t1'] = {'status': 'downloading'}
download_batches['b1'] = {'queue': ['t1']}
batch_locks['b1'] = threading.Lock()
cancel.clear_completed_local()
assert 'b1' in batch_locks
def test_clear_completed_returns_zero_on_empty_state():
assert cancel.clear_completed_local() == 0