From 366ee445c7bccbd0ef62dc126eeb07299dd9ec48 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 4 May 2026 12:00:22 -0700 Subject: [PATCH] A3: Pin TidalDownloadClient download lifecycle behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 tests pin the Tidal contract: filename encoding (`||display` where track_id parses as int), UUID download_id format, initial state-dict schema, daemon-thread spawn semantics, and the active_downloads → DownloadStatus translation. is_authenticated false on no-session AND on tidalapi.check_login() exceptions (orchestrator skip behavior depends on this). --- tests/downloads/test_tidal_pinning.py | 173 ++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/downloads/test_tidal_pinning.py diff --git a/tests/downloads/test_tidal_pinning.py b/tests/downloads/test_tidal_pinning.py new file mode 100644 index 00000000..c84b42e8 --- /dev/null +++ b/tests/downloads/test_tidal_pinning.py @@ -0,0 +1,173 @@ +"""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. +""" + +from __future__ import annotations + +import asyncio +import threading +from pathlib import Path +from unittest.mock import patch + +import pytest + +# tidalapi may not be importable; tidal_download_client guards for that. +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(): + """A bare TidalDownloadClient — bypasses tidalapi.Session init. + Tests that need an authenticated state set client.session.check_login + via mock.""" + 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 + + +# --------------------------------------------------------------------------- +# is_configured / is_authenticated +# --------------------------------------------------------------------------- + + +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_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.""" + 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 + + +# --------------------------------------------------------------------------- +# download() — filename parsing + id contract +# --------------------------------------------------------------------------- + + +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)) + 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)) + assert result is None + + +def test_download_returns_uuid_for_valid_filename(tidal_client): + """Pinning: valid `||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)) + + 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 + + +# --------------------------------------------------------------------------- +# get_all_downloads() +# --------------------------------------------------------------------------- + + +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()) + assert len(result) == 2 + assert {r.id for r in result} == {'dl-1', 'dl-2'} + assert {r.username for r in result} == {'tidal'}