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.
192 lines
6.9 KiB
Python
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
|