soulsync/tests/downloads/test_background_download_worker.py
Broque Thomas 4aa6b0fcf5 Add 5 test additions JohnBaumb suggested
Pinning gaps he flagged after his second pass:

- register_plugin when set_engine() raises: registration must succeed
  + plugin stays in the registry (download() raises later, surfacing
  the error to the user via download_with_fallback). Pin so a future
  refactor can't accidentally propagate the set_engine exception and
  crash boot.
- engine.get_all_downloads exclude actually doesn't invoke the plugin:
  ID-only check would pass even if soulseek's get_all was called and
  returned []; sentinel proves the plugin isn't touched at all.
- Cancel mid-flight observable from inside _download_sync: existing
  tests pin Cancelled-preserve AFTER impl returns, this pins the
  contract plugins rely on (engine.get_record reflecting Cancelled
  state during the impl thread's polling loop).
- configured_clients() with broken is_configured(): the try/except
  guard exists but had no test — broken plugin is silently skipped,
  healthy ones still surface.
- Per-source delay independence: YouTube's 3s rate-limit delay must
  not block a Tidal download starting in parallel. Companion to the
  per-source-locks test.
2026-05-05 13:04:18 -07:00

542 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for `BackgroundDownloadWorker` (Phase C1).
These tests pin the worker's state-machine semantics, semaphore
serialization, rate-limit-delay behavior, and exception handling.
Future phases (C2C7) migrate each per-source client onto this
worker — these tests stay green as the regression net.
"""
from __future__ import annotations
import threading
import time
from core.download_engine import DownloadEngine
# ---------------------------------------------------------------------------
# Dispatch — initial state + thread spawn
# ---------------------------------------------------------------------------
def test_dispatch_returns_uuid_download_id():
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
return '/tmp/file.flac'
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='abc123',
display_name='Some Song',
original_filename='abc123||Some Song',
impl_callable=impl,
)
assert len(download_id) == 36 # UUID4
assert download_id.count('-') == 4
def test_dispatch_inserts_initial_record_with_canonical_state():
"""Pinning: initial record matches the legacy per-client shape so
consumers reading the state dict via API or context-key lookup
keep working unchanged after migration."""
engine = DownloadEngine()
captured = threading.Event()
def impl(download_id, target_id, display_name):
captured.wait(timeout=1.0) # block so we can read 'Initializing' / 'InProgress' state
return '/tmp/file.flac'
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='abc',
display_name='X',
original_filename='abc||X',
impl_callable=impl,
)
record = engine.get_record('youtube', download_id)
assert record is not None
assert record['id'] == download_id
assert record['filename'] == 'abc||X'
assert record['username'] == 'youtube'
assert record['state'] in ('Initializing', 'InProgress, Downloading')
assert record['progress'] == 0.0
assert record['file_path'] is None
captured.set() # release impl
def test_dispatch_merges_extra_record_fields():
"""Pinning: source-specific slots (video_id, track_id, etc.)
merge into the initial record so frontend + status APIs that
read those keys keep working."""
engine = DownloadEngine()
started = threading.Event()
release = threading.Event()
def impl(download_id, target_id, display_name):
started.set()
release.wait(timeout=1.0)
return '/tmp/x.flac'
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid123',
display_name='Title',
original_filename='vid123||Title',
impl_callable=impl,
extra_record_fields={
'video_id': 'vid123',
'url': 'https://youtube.com/watch?v=vid123',
'title': 'Title',
},
)
started.wait(timeout=1.0)
record = engine.get_record('youtube', download_id)
assert record['video_id'] == 'vid123'
assert record['url'] == 'https://youtube.com/watch?v=vid123'
assert record['title'] == 'Title'
release.set()
def test_dispatch_username_override_preserves_legacy_slot():
"""Pinning: Deezer's record stores `'deezer_dl'` (legacy) in the
username slot, not the canonical `'deezer'`. Worker accepts
override so frontend status indicators keep their key."""
engine = DownloadEngine()
release = threading.Event()
def impl(download_id, target_id, display_name):
release.wait(timeout=1.0)
return '/tmp/x.flac'
download_id = engine.worker.dispatch(
source_name='deezer',
target_id='999',
display_name='X',
original_filename='999||X',
impl_callable=impl,
username_override='deezer_dl',
)
record = engine.get_record('deezer', download_id)
assert record['username'] == 'deezer_dl'
release.set()
# ---------------------------------------------------------------------------
# Worker lifecycle — state transitions
# ---------------------------------------------------------------------------
def test_worker_marks_completed_on_successful_impl():
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
return '/tmp/done.flac'
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
# Wait for thread to finish.
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] == 'Completed, Succeeded':
break
time.sleep(0.01)
record = engine.get_record('youtube', download_id)
assert record['state'] == 'Completed, Succeeded'
assert record['progress'] == 100.0
assert record['file_path'] == '/tmp/done.flac'
def test_worker_preserves_cancelled_when_impl_returns_none():
"""Pinning: if the user cancels mid-download (state flips to
Cancelled via engine.update_record from cancel_download), the
worker must NOT clobber it back to Errored when impl returns
None. The legacy per-client thread workers had this guard
(``if state != 'Cancelled': state = 'Errored'``); the shared
worker preserves that contract."""
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
# Simulate user cancelling mid-impl by writing Cancelled.
engine.update_record('youtube', download_id, {'state': 'Cancelled'})
return None # impl returns None because download was interrupted
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] in ('Cancelled', 'Errored'):
break
time.sleep(0.01)
record = engine.get_record('youtube', download_id)
assert record['state'] == 'Cancelled', (
f"Worker clobbered user's Cancelled with {record['state']}"
)
def test_worker_preserves_cancelled_when_impl_returns_success():
"""Cin's bug 3 follow-up: the success path also has a read-then-write
race. If the user cancels between the impl returning a valid file
path and the worker writing 'Completed, Succeeded', the cancel is
overwritten. The success-path write must use the same atomic
Cancelled-preserve guard as _mark_terminal."""
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
# User cancels mid-impl, then impl finishes successfully.
engine.update_record('youtube', download_id, {'state': 'Cancelled'})
return '/tmp/file.flac'
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] in ('Cancelled', 'Completed, Succeeded'):
break
time.sleep(0.01)
record = engine.get_record('youtube', download_id)
assert record['state'] == 'Cancelled', (
f"Worker clobbered user's Cancelled with {record['state']}"
)
def test_worker_preserves_cancelled_when_impl_raises():
"""Same Cancelled-preserve guard, but for the impl-raises path."""
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
engine.update_record('youtube', download_id, {'state': 'Cancelled'})
raise RuntimeError("simulated mid-cancel exception")
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] in ('Cancelled', 'Errored'):
break
time.sleep(0.01)
assert engine.get_record('youtube', download_id)['state'] == 'Cancelled'
def test_worker_marks_errored_when_impl_returns_none():
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
return None # signaling failure
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] == 'Errored':
break
time.sleep(0.01)
record = engine.get_record('youtube', download_id)
assert record['state'] == 'Errored'
# file_path stays None (default).
assert record['file_path'] is None
def test_worker_marks_errored_and_captures_message_when_impl_raises():
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
raise RuntimeError("api blew up")
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] == 'Errored':
break
time.sleep(0.01)
record = engine.get_record('youtube', download_id)
assert record['state'] == 'Errored'
assert 'api blew up' in record.get('error', '')
# ---------------------------------------------------------------------------
# Per-source semaphore serialization
# ---------------------------------------------------------------------------
def test_semaphore_serializes_downloads_for_same_source():
"""Pinning: with concurrency=1 (default), two dispatches against
the same source run sequentially. The legacy per-client
semaphore did the same — consumers depend on this for
rate-limit safety against APIs like YouTube."""
engine = DownloadEngine()
in_progress = threading.Event()
can_finish = threading.Event()
overlap_count = 0
overlap_lock = threading.Lock()
active_count = [0]
def impl(download_id, target_id, display_name):
nonlocal overlap_count
with overlap_lock:
active_count[0] += 1
if active_count[0] > 1:
overlap_count += 1
in_progress.set()
can_finish.wait(timeout=2.0)
with overlap_lock:
active_count[0] -= 1
return '/tmp/x.flac'
# Default concurrency=1 — two dispatches must serialize.
dl1 = engine.worker.dispatch(
source_name='youtube', target_id='a', display_name='A',
original_filename='a||A', impl_callable=impl,
)
in_progress.wait(timeout=1.0)
in_progress.clear()
dl2 = engine.worker.dispatch(
source_name='youtube', target_id='b', display_name='B',
original_filename='b||B', impl_callable=impl,
)
# Give second dispatch a chance to attempt running in parallel
# (it should be blocked on the semaphore).
time.sleep(0.1)
assert overlap_count == 0, "second dispatch should be blocked behind semaphore"
# Release first; second proceeds.
can_finish.set()
# Wait for both to finish.
deadline = time.time() + 3.0
while time.time() < deadline:
r1 = engine.get_record('youtube', dl1)
r2 = engine.get_record('youtube', dl2)
if r1 and r2 and r1['state'] == 'Completed, Succeeded' and r2['state'] == 'Completed, Succeeded':
break
time.sleep(0.01)
assert overlap_count == 0
def test_semaphore_concurrency_can_be_increased():
"""When `set_concurrency(source, N)` is called, N downloads can
run in parallel for that source. Used by sources that support
parallel transfers (none today, but contract supports it)."""
engine = DownloadEngine()
engine.worker.set_concurrency('parallel-source', 3)
in_flight = []
in_flight_lock = threading.Lock()
can_finish = threading.Event()
max_observed = [0]
def impl(download_id, target_id, display_name):
with in_flight_lock:
in_flight.append(download_id)
max_observed[0] = max(max_observed[0], len(in_flight))
can_finish.wait(timeout=2.0)
with in_flight_lock:
in_flight.remove(download_id)
return '/tmp/x.flac'
for i in range(3):
engine.worker.dispatch(
source_name='parallel-source',
target_id=str(i),
display_name=f'd{i}',
original_filename=f'{i}||d{i}',
impl_callable=impl,
)
# Give threads time to ramp up.
time.sleep(0.2)
can_finish.set()
# Wait for them to finish.
time.sleep(0.5)
assert max_observed[0] == 3
# ---------------------------------------------------------------------------
# Per-source rate-limit delay
# ---------------------------------------------------------------------------
def test_impl_can_observe_cancel_mid_flight_via_state_check():
"""Per JohnBaumb: existing tests cover Cancelled-preserve AFTER impl
returns — but plugins also poll engine state mid-download (via
``_is_cancelled`` helpers) to abort partial transfers. Pin that
contract: a cancel landing while impl is mid-flight must be
visible to a subsequent ``engine.get_record()`` from the impl
thread.
"""
engine = DownloadEngine()
impl_started = threading.Event()
impl_can_finish = threading.Event()
observed_state_during_impl = []
def impl(download_id, target_id, display_name):
impl_started.set()
# Wait for the test thread to write Cancelled, then check
# what we can observe from inside the impl callback.
impl_can_finish.wait(timeout=2.0)
record = engine.get_record('youtube', download_id)
observed_state_during_impl.append(record.get('state') if record else None)
return None # impl noticed cancel + bailed out
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
# Wait for impl to start, then inject a cancel.
impl_started.wait(timeout=1.0)
engine.update_record('youtube', download_id, {'state': 'Cancelled'})
impl_can_finish.set()
# Wait for impl to finish (its append populates observed_state).
# Engine state is already Cancelled at this point, so polling on
# state would race: it'd break before impl ran the get_record line.
deadline = time.time() + 2.0
while not observed_state_during_impl and time.time() < deadline:
time.sleep(0.01)
# impl observed the Cancelled state mid-flight via get_record,
# AND the worker preserved Cancelled after impl returned None.
assert observed_state_during_impl == ['Cancelled']
assert engine.get_record('youtube', download_id)['state'] == 'Cancelled'
def test_per_source_delays_dont_block_other_sources():
"""Per JohnBaumb: per-source semaphores + delays must not let one
slow source stall another. YouTube's 3s rate-limit delay should
not delay a Tidal download starting in parallel.
Configure YouTube with a 0.5s delay, dispatch one YouTube download
(which holds the source's serial slot + arms the next-call delay),
then immediately dispatch a Tidal download. Tidal must complete
well before YouTube's delay window would have elapsed.
"""
engine = DownloadEngine()
engine.worker.set_delay('youtube', 0.5)
yt_completed = threading.Event()
td_completed = threading.Event()
def yt_impl(download_id, target_id, display_name):
time.sleep(0.05)
yt_completed.set()
return '/tmp/yt.mp3'
def td_impl(download_id, target_id, display_name):
td_completed.set()
return '/tmp/td.flac'
# First YouTube call. After it finishes, the worker arms the
# 0.5s delay BEFORE the next youtube dispatch can run.
engine.worker.dispatch(
source_name='youtube', target_id='a', display_name='A',
original_filename='a||A', impl_callable=yt_impl,
)
yt_completed.wait(timeout=1.0)
# Second YouTube would now block 0.5s on the rate-limit delay.
# Dispatch one to occupy that wait, then dispatch Tidal — Tidal
# must NOT wait on YouTube's delay.
engine.worker.dispatch(
source_name='youtube', target_id='b', display_name='B',
original_filename='b||B', impl_callable=lambda *a: '/tmp/y.mp3',
)
td_start = time.time()
engine.worker.dispatch(
source_name='tidal', target_id='t1', display_name='T',
original_filename='t1||T', impl_callable=td_impl,
)
td_completed.wait(timeout=0.4)
td_elapsed = time.time() - td_start
# Tidal must have finished in well under 0.5s (the YouTube delay).
assert td_completed.is_set(), "Tidal blocked on YouTube's per-source delay"
assert td_elapsed < 0.4, f"Tidal took {td_elapsed:.2f}s — should be near-instant"
def test_delay_enforces_minimum_gap_between_downloads():
"""Pinning: YouTube uses 3s delay today (legacy
`_download_delay`). Worker-driven delay must enforce the same
gap so YouTube doesn't 429."""
engine = DownloadEngine()
engine.worker.set_delay('youtube', 0.2) # 200ms — short for test speed
completion_times = []
def impl(download_id, target_id, display_name):
completion_times.append(time.time())
return '/tmp/x.flac'
# Two back-to-back dispatches.
engine.worker.dispatch(
source_name='youtube', target_id='a', display_name='A',
original_filename='a||A', impl_callable=impl,
)
engine.worker.dispatch(
source_name='youtube', target_id='b', display_name='B',
original_filename='b||B', impl_callable=impl,
)
# Wait for both to finish (semaphore serializes + delay).
deadline = time.time() + 3.0
while time.time() < deadline and len(completion_times) < 2:
time.sleep(0.01)
assert len(completion_times) == 2
gap = completion_times[1] - completion_times[0]
# Gap is at LEAST the configured delay.
assert gap >= 0.18, f"expected gap >= 0.2s, got {gap:.3f}"