@ -1,11 +1,9 @@
""" Phase A pinning tests for TidalDownloadClient ' s download lifecycle.
Tidal authenticates via tidalapi OAuth , fetches HLS manifests for a
track_id , demuxes the FLAC stream from MP4 container with ffmpeg ,
and writes the result to disk . The thread worker + state - dict
pattern is identical to YouTube ' s — Phase C will lift both into
the engine . These tests pin the SHAPE of the per - download record
and the filename encoding so the lift can ' t drift the contract.
""" 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
@ -17,7 +15,7 @@ from unittest.mock import patch
import pytest
# tidalapi may not be importable; tidal_download_client guards for that.
from core . download_engine import DownloadEngine
from core . tidal_download_client import TidalDownloadClient
@ -30,19 +28,18 @@ def _run_async(coro):
@pytest.fixture
def tidal_client ( ) :
""" A bare TidalDownloadClient — bypasses tidalapi.Session init.
Tests that need an authenticated state set client . session . check_login
via mock . """
def tidal_client_with_engine ( ) :
client = TidalDownloadClient . __new__ ( TidalDownloadClient )
client . download_path = Path ( ' ./test_tidal_downloads ' )
client . shutdown_check = None
client . session = None
client . active_downloads = { }
client . _download_lock = threading . Lock ( )
client . _device_auth_future = None
client . _device_auth_link = None
return client
client . _engine = None
engine = DownloadEngine ( )
client . set_engine ( engine )
return client , engine
# ---------------------------------------------------------------------------
@ -50,21 +47,18 @@ def tidal_client():
# ---------------------------------------------------------------------------
def test_is_authenticated_false_when_no_session ( tidal_client ) :
""" Pinning: no session → not authenticated. Used by orchestrator
fallback to skip Tidal when user hasn ' t logged in. " " "
assert tidal_client . is_authenticated ( ) is False
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 ) :
""" Pinning: tidalapi.Session.check_login() can raise on expired
tokens . Client swallows + reports False — orchestrator skip
behavior depends on this . """
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 " ) ) ,
} ) ( )
tidal_ client. session = fake_session
assert tidal_ client. is_authenticated ( ) is False
client. session = fake_session
assert client. is_authenticated ( ) is False
# ---------------------------------------------------------------------------
@ -72,102 +66,102 @@ def test_is_authenticated_false_when_session_check_login_raises(tidal_client):
# ---------------------------------------------------------------------------
def test_download_returns_none_for_invalid_filename_format ( tidal_client ) :
""" Pinning: Tidal encodes search results as `track_id||display`.
Missing ` | | ` → None ( not exception ) . """
result = _run_async ( tidal_client . download ( ' tidal ' , ' no-separator ' , 0 ) )
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 ) :
""" Pinning: track_id portion MUST parse as int. Tidal API uses
integer track IDs . Non - int → None ( not exception ) . """
result = _run_async ( tidal_client . download ( ' tidal ' , ' not-a-number||some title ' , 0 ) )
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_returns_ uuid_for_valid_filename( tidal_client ) :
""" Pinning: valid `<int>||display` filename returns a UUID
download_id immediately ; download runs in background thread . """
with patch ( ' core.tidal_download_client.threading.Thread ' ) as fake_thread_cls :
fake_thread_cls . return_value . start = lambda : None
result = _run_async ( tidal_client . download ( ' tidal ' , ' 12345||Some Song ' , 0 ) )
def test_download_returns_ none_when_engine_not_wired( ) :
client = TidalDownloadClient . __new__ ( TidalDownloadClient )
client. _engine = None
result = _run_async ( client . download ( ' tidal ' , ' 12345||x ' , 0 ) )
assert result is None
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 # UUID4 format
def test_download_populates_active_downloads_with_initial_state ( tidal_client ) :
""" Pinning: per-download record schema. Engine refactor moves
this dict into central state but the SHAPE must stay the same
for status APIs / frontend / post - processing consumers . """
with patch ( ' core.tidal_download_client.threading.Thread ' ) as fake_thread_cls :
fake_thread_cls . return_value . start = lambda : None
download_id = _run_async (
tidal_client . download ( ' tidal ' , ' 999||My Tidal Song ' , 0 )
)
record = tidal_client . active_downloads [ download_id ]
assert record [ ' id ' ] == download_id
assert record [ ' filename ' ] == ' 999||My Tidal Song ' # ORIGINAL encoded form
assert record [ ' username ' ] == ' tidal '
assert record [ ' state ' ] == ' Initializing '
assert record [ ' progress ' ] == 0.0
assert record [ ' size ' ] == 0 # filled in by worker once HLS manifest fetched
assert record [ ' track_id ' ] == 999 # parsed as int
assert record [ ' display_name ' ] == ' My Tidal Song '
assert record [ ' file_path ' ] is None
def test_download_spawns_daemon_thread_targeting_worker ( tidal_client ) :
""" Pinning: daemon thread targeting `_download_thread_worker`
with ( download_id , track_id , display_name , original_filename ) .
Phase C replaces this with ` engine . dispatch_download ( plugin , . . . ) `
that calls ` plugin . _download_impl ( track_id ) ` . """
captured_kwargs = { }
def capture_thread ( * args , * * kwargs ) :
captured_kwargs . update ( kwargs )
return type ( ' FakeThread ' , ( ) , { ' start ' : lambda self : None } ) ( )
with patch ( ' core.tidal_download_client.threading.Thread ' , side_effect = capture_thread ) :
_run_async ( tidal_client . download ( ' tidal ' , ' 777||Title ' , 0 ) )
assert captured_kwargs . get ( ' daemon ' ) is True
assert captured_kwargs . get ( ' target ' ) == tidal_client . _download_thread_worker
args = captured_kwargs . get ( ' args ' , ( ) )
assert len ( args ) == 4
# Args: (download_id, track_id, display_name, original_filename)
assert args [ 1 ] == 777 # track_id parsed as int
assert args [ 2 ] == ' Title '
assert args [ 3 ] == ' 777||Title ' # original encoded filename
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 ( )
# ---------------------------------------------------------------------------
# get_all_downloads()
# Query / cancel — engine-backed reads
# ---------------------------------------------------------------------------
def test_get_all_downloads_iterates_active_downloads ( tidal_client ) :
""" Pinning: returns one DownloadStatus per entry in
active_downloads . Engine refactor will replace this with a
central query — the per - record - to - DownloadStatus translation
must preserve the field mapping . """
tidal_client . active_downloads = {
' dl-1 ' : {
' id ' : ' dl-1 ' , ' filename ' : ' 111||Song A ' , ' username ' : ' tidal ' ,
' state ' : ' InProgress, Downloading ' , ' progress ' : 50.0 ,
' size ' : 1000 , ' transferred ' : 500 , ' speed ' : 100 ,
' time_remaining ' : None ,
} ,
' dl-2 ' : {
' id ' : ' dl-2 ' , ' filename ' : ' 222||Song B ' , ' username ' : ' tidal ' ,
' state ' : ' Completed, Succeeded ' , ' progress ' : 100.0 ,
' size ' : 2000 , ' transferred ' : 2000 , ' speed ' : 0 ,
' time_remaining ' : None ,
} ,
}
result = _run_async ( tidal_client . get_all_downloads ( ) )
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