soulsync/tests/streaming/test_prepare.py
BoulderBadgeDad 07d09d7d0e Stream button: the player never learns its stream is ready (2.6.5 regression)
Since the per-listener stream sessions refactor (Phase 3b), every browser gets
its own stream session — but the 1s 'tool:stream' socket broadcast still read
the legacy GLOBAL state (the DEFAULT session no real browser uses), so it told
every client "stopped" forever. The frontend skipped HTTP polling whenever the
WebSocket was up, so it only ever saw that wrong broadcast: the backend prep
downloaded the track, moved it into the session's stream folder and sat at
"ready" while the mini player showed nothing. Proxy users whose WebSockets
don't connect fell back to HTTP polling (session-correct) and streamed fine —
which is why this hid so well.

Fix: stream status is inherently per-listener, so stop pretending a global
broadcast can carry it —
- web_server.py: remove the 'tool:stream' emit from the tool-progress loop
  (the broadcast thread has no request context; it can only ever see DEFAULT)
- media-player.js: the status poller always polls /api/stream/status (resolves
  the caller's own session from the cookie); drop the dead broadcast handler
- core.js: unwire the 'tool:stream' socket listener

Observability fix that made this undebuggable: core/streaming/prepare.py used
getLogger(__name__) — outside the soulsync.* namespace where handlers attach —
so every prep log line (including failures) vanished from app.log. Moved to
get_logger("streaming.prepare") + a regression test locking the namespace.

34 streaming tests pass; ruff clean; web_server compiles; JS syntax-checked.
2026-06-06 12:43:20 -07:00

217 lines
8.2 KiB
Python

"""Tests for core/streaming/prepare.py — stream-prep worker."""
from __future__ import annotations
import threading
import pytest
from core.streaming import prepare as sp
class _FakeSoulseek:
"""Minimal download_orchestrator stub for the stream-prep worker."""
def __init__(self, *, download_id='dl-1', all_downloads=None):
self._download_id = download_id
self._all_downloads = all_downloads if all_downloads is not None else []
async def download(self, username, filename, size):
return self._download_id
async def get_all_downloads(self):
return self._all_downloads
async def signal_download_completion(self, download_id, username, remove=True):
return True
@pytest.fixture(autouse=True)
def _no_sleep(monkeypatch):
"""Keep stream-prep tests fast while still exercising the polling branches."""
monkeypatch.setattr(sp.time, 'sleep', lambda *_args, **_kwargs: None)
def _build_deps(
*,
state=None,
soulseek=None,
project_root='/tmp/proj',
find_streaming_result=None,
find_downloaded_result=None,
):
state = state if state is not None else {}
deps = sp.PrepareStreamDeps(
config_manager=type('C', (), {'get': lambda self, k, d=None: d})(),
download_orchestrator=soulseek or _FakeSoulseek(),
stream_lock=threading.Lock(),
project_root=project_root,
docker_resolve_path=lambda p: p,
find_streaming_download_in_all_downloads=lambda all_dl, td: find_streaming_result,
find_downloaded_file=lambda dl_path, td: find_downloaded_result,
extract_filename=lambda fp: __import__('os').path.basename(fp),
cleanup_empty_directories=lambda dl_path, found_file: None,
_get_stream_state=lambda: state,
_set_stream_state=lambda v: state.clear() or state.update(v),
)
deps._state = state
return deps
# ---------------------------------------------------------------------------
# Initial state setup
# ---------------------------------------------------------------------------
def test_state_starts_loading_with_track_info(tmp_path):
"""First action sets state to 'loading' with the track_info."""
sk = _FakeSoulseek(download_id=None) # forces an early "Failed to initiate" exit
deps = _build_deps(soulseek=sk, project_root=str(tmp_path))
track_data = {'username': 'u', 'filename': 'song.flac', 'size': 1000}
sp.prepare_stream_task(track_data, deps)
# First mutation set status='loading', track_info=track_data
# Then early exit because download() returned None — state ends up 'error'
assert deps._state['status'] == 'error'
assert 'Failed to initiate' in deps._state['error_message']
def test_stream_folder_created(tmp_path):
"""Stream/ subfolder is created under project_root."""
sk = _FakeSoulseek(download_id=None)
deps = _build_deps(soulseek=sk, project_root=str(tmp_path))
sp.prepare_stream_task({'username': 'u', 'filename': 'x', 'size': 0}, deps)
assert (tmp_path / 'Stream').is_dir()
def test_stream_folder_cleared_before_download(tmp_path):
"""Existing files in Stream/ are removed before each prepare."""
stream_dir = tmp_path / 'Stream'
stream_dir.mkdir()
old_file = stream_dir / 'old.flac'
old_file.write_bytes(b'old data')
assert old_file.exists()
sk = _FakeSoulseek(download_id=None)
deps = _build_deps(soulseek=sk, project_root=str(tmp_path))
sp.prepare_stream_task({'username': 'u', 'filename': 'x', 'size': 0}, deps)
# Old file gone (cleared at start of prep)
assert not old_file.exists()
# ---------------------------------------------------------------------------
# Download initiation failure
# ---------------------------------------------------------------------------
def test_download_returns_none_marks_error(tmp_path):
"""download_orchestrator.download() returning None → state.error."""
sk = _FakeSoulseek(download_id=None)
deps = _build_deps(soulseek=sk, project_root=str(tmp_path))
sp.prepare_stream_task({'username': 'u', 'filename': 'x', 'size': 0}, deps)
assert deps._state['status'] == 'error'
# ---------------------------------------------------------------------------
# Successful completion
# ---------------------------------------------------------------------------
def test_completed_download_moves_to_stream_and_marks_ready(tmp_path):
"""When the polled status reports succeeded + bytes match, file moved + state ready."""
download_path = tmp_path / 'downloads'
download_path.mkdir()
src_file = download_path / 'song.flac'
src_file.write_bytes(b'audio')
download_status = {
'id': 'dl-99',
'state': 'Succeeded',
'percentComplete': 100,
'size': 5,
'bytesTransferred': 5,
}
sk = _FakeSoulseek(download_id='dl-99', all_downloads=['stub'])
deps = _build_deps(
soulseek=sk,
project_root=str(tmp_path),
find_streaming_result=download_status,
find_downloaded_result=str(src_file),
)
deps.config_manager = type('C', (), {
'get': lambda self, k, d=None: str(download_path) if k == 'soulseek.download_path' else d,
})()
sp.prepare_stream_task(
{'username': 'u', 'filename': 'song.flac', 'size': 5},
deps,
)
assert deps._state['status'] == 'ready'
assert deps._state['progress'] == 100
assert (tmp_path / 'Stream' / 'song.flac').exists()
assert deps._state['file_path'] == str(tmp_path / 'Stream' / 'song.flac')
def test_succeeded_state_with_partial_bytes_keeps_polling(tmp_path):
"""If state is 'Succeeded' but bytes < size, marks _incomplete_warned and continues."""
download_status = {
'id': 'dl-99',
'state': 'Succeeded',
'percentComplete': 100,
'size': 100,
'bytesTransferred': 50, # incomplete
}
sk = _FakeSoulseek(download_id='dl-99', all_downloads=['stub'])
deps = _build_deps(
soulseek=sk,
project_root=str(tmp_path),
find_streaming_result=download_status,
)
# Force quick exit by capping the loop with no further state change
# Worker times out via max_wait_time in real code — we just verify state didn't go ready
sp.prepare_stream_task({'username': 'u', 'filename': 'x', 'size': 100}, deps)
# Should NOT have gone to 'ready' because bytes were incomplete
assert deps._state['status'] != 'ready'
# ---------------------------------------------------------------------------
# Real StreamSession compatibility (player-revamp Phase 3 wiring)
# ---------------------------------------------------------------------------
def test_worker_drives_a_real_stream_session(tmp_path):
"""web_server.py now binds stream_state to a StreamStateStore session
(not a bare dict). Prove the prepare worker drives that real object
correctly end-to-end through the deps proxy — the actual production type."""
from core.streaming.state import StreamStateStore
session = StreamStateStore().get() # the real production object
sk = _FakeSoulseek(download_id=None) # early error exit is enough to mutate state
deps = _build_deps(soulseek=sk, project_root=str(tmp_path), state=session)
sp.prepare_stream_task({'username': 'u', 'filename': 'song.flac', 'size': 1}, deps)
# Worker mutated the SAME session via update()/[k]= — proves dict-compat.
assert session['status'] == 'error'
assert 'Failed to initiate' in session['error_message']
assert session['track_info'] == {'username': 'u', 'filename': 'song.flac', 'size': 1}
# ---------------------------------------------------------------------------
# Observability: prep logs must actually reach app.log
# ---------------------------------------------------------------------------
def test_prepare_logger_is_in_soulsync_namespace():
"""Handlers only attach to the soulsync.* hierarchy. A bare
getLogger(__name__) gave this module a 'core.streaming.prepare' logger with
no handler — every prep log (including failures) vanished, which made the
broken-stream report undebuggable from app.log. Lock the namespace."""
assert sp.logger.name.startswith('soulsync.'), (
f"prepare logger '{sp.logger.name}' is outside the soulsync.* namespace "
"— its output never reaches app.log"
)