soulsync/tests/downloads/test_tidal_pinning.py
Broque Thomas 3375b6c4bd Handle non-JSON Tidal auth responses
Detect JSON decode-like exceptions from Tidal's token endpoint and return a safer, more actionable error message. Adds a _looks_like_json_decode_error helper and special-cases that error in check_device_auth to log the non-JSON response and advise disabling VPN/proxy/network filtering and restarting SoulSync. A test was added to ensure the user-facing message does not leak the raw exception text while still returning an error status. Other errors continue to fall back to the existing behavior.
2026-05-20 14:04:45 -07:00

192 lines
6.9 KiB
Python

"""Phase A pinning tests for TidalDownloadClient — UPDATED for Phase C3.
Post-C3 the client no longer owns its own ``active_downloads`` dict
or thread spawn — both moved into the engine's BackgroundDownloadWorker.
Pinning tests now read state from ``engine.get_record('tidal', ...)``
instead of ``client.active_downloads[...]``.
"""
from __future__ import annotations
import asyncio
import threading
from pathlib import Path
from unittest.mock import patch
import pytest
from core.download_engine import DownloadEngine
from core.tidal_download_client import TidalDownloadClient
def _run_async(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
@pytest.fixture
def tidal_client_with_engine():
client = TidalDownloadClient.__new__(TidalDownloadClient)
client.download_path = Path('./test_tidal_downloads')
client.shutdown_check = None
client.session = None
client._device_auth_future = None
client._device_auth_link = None
client._engine = None
engine = DownloadEngine()
client.set_engine(engine)
return client, engine
# ---------------------------------------------------------------------------
# is_configured / is_authenticated
# ---------------------------------------------------------------------------
def test_is_authenticated_false_when_no_session(tidal_client_with_engine):
client, _ = tidal_client_with_engine
assert client.is_authenticated() is False
def test_is_authenticated_false_when_session_check_login_raises(tidal_client_with_engine):
client, _ = tidal_client_with_engine
fake_session = type('FakeSession', (), {
'check_login': lambda self: (_ for _ in ()).throw(RuntimeError("expired")),
})()
client.session = fake_session
assert client.is_authenticated() is False
def test_check_device_auth_returns_helpful_message_for_non_json_tidal_response(tidal_client_with_engine):
client, _ = tidal_client_with_engine
class FakeFuture:
def running(self):
return False
def result(self, timeout=0):
raise ValueError("Expecting value: line 1 column 1 (char 0)")
client._device_auth_future = FakeFuture()
client._device_auth_link = {'verification_uri': 'https://link.tidal.com', 'user_code': 'ABCD'}
result = client.check_device_auth()
assert result['status'] == 'error'
assert 'invalid auth response' in result['message']
assert 'Expecting value' not in result['message']
# ---------------------------------------------------------------------------
# download() — filename parsing + id contract
# ---------------------------------------------------------------------------
def test_download_returns_none_for_invalid_filename_format(tidal_client_with_engine):
client, _ = tidal_client_with_engine
result = _run_async(client.download('tidal', 'no-separator', 0))
assert result is None
def test_download_returns_none_for_non_integer_track_id(tidal_client_with_engine):
client, _ = tidal_client_with_engine
result = _run_async(client.download('tidal', 'not-int||title', 0))
assert result is None
def test_download_raises_when_engine_not_wired():
"""Defensive: client without engine reference must raise so the
orchestrator's download_with_fallback surfaces the error and
moves on to the next source. Returning None silently would drop
the download with no user feedback (per JohnBaumb)."""
import pytest
client = TidalDownloadClient.__new__(TidalDownloadClient)
client._engine = None
with pytest.raises(RuntimeError, match="engine reference"):
_run_async(client.download('tidal', 'v||t', 0))
def test_download_returns_uuid_for_valid_filename(tidal_client_with_engine):
client, _ = tidal_client_with_engine
with patch.object(client, '_download_sync', return_value='/tmp/x.flac'):
result = _run_async(client.download('tidal', '12345||Some Song', 0))
assert result is not None
assert len(result) == 36
def test_download_populates_engine_record_with_initial_state(tidal_client_with_engine):
client, engine = tidal_client_with_engine
started = threading.Event()
release = threading.Event()
def slow_impl(*args, **kwargs):
started.set()
release.wait(timeout=1.0)
return '/tmp/done.flac'
with patch.object(client, '_download_sync', side_effect=slow_impl):
download_id = _run_async(client.download('tidal', '999||My Tidal Song', 0))
started.wait(timeout=1.0)
record = engine.get_record('tidal', download_id)
assert record is not None
assert record['id'] == download_id
assert record['filename'] == '999||My Tidal Song'
assert record['username'] == 'tidal'
assert record['state'] in ('Initializing', 'InProgress, Downloading')
assert record['progress'] == 0.0
assert record['track_id'] == 999 # parsed as int
assert record['display_name'] == 'My Tidal Song'
assert record['file_path'] is None
release.set()
# ---------------------------------------------------------------------------
# Query / cancel — engine-backed reads
# ---------------------------------------------------------------------------
def test_get_all_downloads_reads_engine_records(tidal_client_with_engine):
client, engine = tidal_client_with_engine
engine.add_record('tidal', 'dl-1', {
'id': 'dl-1', 'filename': '111||Song A', 'username': 'tidal',
'state': 'InProgress, Downloading', 'progress': 50.0,
'size': 1000, 'transferred': 500, 'speed': 100,
})
engine.add_record('tidal', 'dl-2', {
'id': 'dl-2', 'filename': '222||Song B', 'username': 'tidal',
'state': 'Completed, Succeeded', 'progress': 100.0,
'size': 2000, 'transferred': 2000, 'speed': 0,
})
result = _run_async(client.get_all_downloads())
assert len(result) == 2
assert {r.id for r in result} == {'dl-1', 'dl-2'}
assert {r.username for r in result} == {'tidal'}
def test_cancel_download_marks_cancelled(tidal_client_with_engine):
client, engine = tidal_client_with_engine
engine.add_record('tidal', 'dl-1', {'id': 'dl-1', 'state': 'InProgress, Downloading'})
ok = _run_async(client.cancel_download('dl-1', None, remove=False))
assert ok is True
assert engine.get_record('tidal', 'dl-1')['state'] == 'Cancelled'
ok = _run_async(client.cancel_download('dl-1', None, remove=True))
assert ok is True
assert engine.get_record('tidal', 'dl-1') is None
def test_clear_all_completed_drops_only_terminal_records(tidal_client_with_engine):
client, engine = tidal_client_with_engine
engine.add_record('tidal', 'done', {'id': 'done', 'state': 'Completed, Succeeded'})
engine.add_record('tidal', 'live', {'id': 'live', 'state': 'InProgress, Downloading'})
_run_async(client.clear_all_completed_downloads())
assert engine.get_record('tidal', 'done') is None
assert engine.get_record('tidal', 'live') is not None