13 tests pin slskd HTTP API contract: endpoint format (`transfers/downloads/<username>` POST), payload shape (slskd web-interface array format), id extraction from dict / list / fallback responses, and the username-lookup fallback in cancel_download when no username hint is provided. Phase A of the download engine refactor — pinning current behavior of every source BEFORE moving any code so the engine extraction can't drift the per-source contract. Includes the plan doc at docs/download-engine-refactor-plan.md. Pure additive — no client code changes.
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""Phase A pinning tests for SoulseekClient's download lifecycle.
|
|
|
|
These tests pin the OBSERVABLE BEHAVIOR of `SoulseekClient.download` /
|
|
`get_all_downloads` / `cancel_download` so the upcoming download
|
|
engine refactor (which lifts shared state + thread workers + search
|
|
retry into a central engine) can't drift the per-source contract.
|
|
|
|
The contract these tests pin is what the engine will call into via
|
|
`plugin.download_raw(target_id)` / `plugin.cancel_raw(target_id)`
|
|
after the refactor lands. If a future commit breaks any of these
|
|
expectations, the diff fails fast — long before a real download
|
|
attempt against a live slskd would have surfaced the bug.
|
|
|
|
NOTE: Soulseek is structurally different from the streaming sources.
|
|
It has NO local thread worker — slskd manages downloads server-side
|
|
and the client just polls for state. So Soulseek skips most of the
|
|
engine refactor's thread-extraction work; what stays critical is
|
|
the slskd HTTP API contract (endpoints, payload shape, id
|
|
extraction). That's what these tests pin.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from core.soulseek_client import SoulseekClient
|
|
|
|
|
|
def _run_async(coro):
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration / lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_configured_returns_false_when_no_base_url():
|
|
"""Pinning: an unconfigured client (no slskd URL set) reports
|
|
is_configured() == False. The orchestrator's hybrid fallback +
|
|
every consumer that gates on is_configured() depends on this."""
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = None
|
|
client.api_key = None
|
|
assert client.is_configured() is False
|
|
|
|
|
|
def test_is_configured_returns_true_when_base_url_set():
|
|
"""Pinning: configured client (slskd URL present) reports True."""
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = 'http://localhost:5030'
|
|
client.api_key = 'test-key'
|
|
assert client.is_configured() is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# download()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def configured_client():
|
|
"""A SoulseekClient with the slskd URL set but no real network. Tests
|
|
individually patch `_make_request` to return whatever shape they
|
|
want to exercise."""
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = 'http://localhost:5030'
|
|
client.api_key = 'test-key'
|
|
client.download_path = Path('./test_downloads')
|
|
return client
|
|
|
|
|
|
def test_download_returns_none_when_not_configured():
|
|
"""Pinning: an unconfigured client refuses downloads — returns
|
|
None silently rather than raising. Used as the soft-fail signal
|
|
by the orchestrator's per-source fallback chain."""
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = None
|
|
result = _run_async(client.download('user', 'song.flac', 1024))
|
|
assert result is None
|
|
|
|
|
|
def test_download_hits_transfers_downloads_username_endpoint(configured_client):
|
|
"""Pinning: the primary download endpoint is
|
|
`transfers/downloads/<username>` POST. This shape was chosen to
|
|
match slskd's web-interface API exactly. Changing it breaks
|
|
every download against current slskd builds."""
|
|
captured = []
|
|
|
|
async def fake_request(method, endpoint, json=None, **kwargs):
|
|
captured.append((method, endpoint, json))
|
|
return {'id': 'dl-id-from-slskd'}
|
|
|
|
with patch.object(configured_client, '_make_request', side_effect=fake_request):
|
|
result = _run_async(configured_client.download('user', 'song.flac', 1024))
|
|
|
|
assert result == 'dl-id-from-slskd'
|
|
method, endpoint, payload = captured[0]
|
|
assert method == 'POST'
|
|
assert endpoint == 'transfers/downloads/user'
|
|
# Payload is the slskd web-interface array format.
|
|
assert isinstance(payload, list)
|
|
assert payload[0]['filename'] == 'song.flac'
|
|
assert payload[0]['size'] == 1024
|
|
|
|
|
|
def test_download_extracts_id_from_dict_response(configured_client):
|
|
"""Pinning: when slskd returns `{id: ...}`, that's the
|
|
download_id the orchestrator uses to track the download."""
|
|
with patch.object(configured_client, '_make_request',
|
|
AsyncMock(return_value={'id': 'abc123'})):
|
|
result = _run_async(configured_client.download('user', 'song.flac', 1024))
|
|
assert result == 'abc123'
|
|
|
|
|
|
def test_download_extracts_id_from_list_response(configured_client):
|
|
"""Pinning: slskd sometimes returns a list of file objects.
|
|
The first item's id is the download_id."""
|
|
with patch.object(configured_client, '_make_request',
|
|
AsyncMock(return_value=[{'id': 'list-id'}, {'id': 'second'}])):
|
|
result = _run_async(configured_client.download('user', 'song.flac', 1024))
|
|
assert result == 'list-id'
|
|
|
|
|
|
def test_download_falls_back_to_filename_when_no_id_in_response(configured_client):
|
|
"""Pinning: defensive — older slskd builds returned 201 Created
|
|
with no id field. The client uses the filename as the download
|
|
identifier in that case so downstream tracking still works."""
|
|
with patch.object(configured_client, '_make_request',
|
|
AsyncMock(return_value={'status': 'queued'})):
|
|
result = _run_async(configured_client.download('user', 'song.flac', 1024))
|
|
assert result == 'song.flac'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_all_downloads()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_all_downloads_returns_empty_when_not_configured():
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = None
|
|
result = _run_async(client.get_all_downloads())
|
|
assert result == []
|
|
|
|
|
|
def test_get_all_downloads_parses_nested_user_directory_files_response(configured_client):
|
|
"""Pinning: slskd's `transfers/downloads` returns
|
|
`[{username, directories: [{files: [...]}]}]`. The client
|
|
flattens that into a list of DownloadStatus objects, one per
|
|
file. Engine refactor's state aggregation depends on this shape."""
|
|
fake_response = [
|
|
{
|
|
'username': 'peer1',
|
|
'directories': [{
|
|
'files': [
|
|
{'id': 'f1', 'filename': 'a.flac', 'state': 'InProgress',
|
|
'size': 100, 'bytesTransferred': 50, 'averageSpeed': 1024},
|
|
{'id': 'f2', 'filename': 'b.flac', 'state': 'Completed, Succeeded',
|
|
'size': 200, 'bytesTransferred': 200, 'averageSpeed': 2048},
|
|
],
|
|
}],
|
|
},
|
|
]
|
|
|
|
with patch.object(configured_client, '_make_request',
|
|
AsyncMock(return_value=fake_response)):
|
|
result = _run_async(configured_client.get_all_downloads())
|
|
|
|
assert len(result) == 2
|
|
assert result[0].id == 'f1'
|
|
assert result[0].username == 'peer1'
|
|
assert result[0].state == 'InProgress'
|
|
assert result[1].id == 'f2'
|
|
# Pinning: 'Completed' state forces progress=100 regardless of source data.
|
|
assert result[1].progress == 100.0
|
|
|
|
|
|
def test_get_all_downloads_endpoint_is_transfers_downloads(configured_client):
|
|
"""Pinning: the listing endpoint is `transfers/downloads` (no
|
|
username). The 404'd `users/.../downloads` variant was tried
|
|
once and removed — keep it gone."""
|
|
captured = []
|
|
|
|
async def fake_request(method, endpoint, **kwargs):
|
|
captured.append((method, endpoint))
|
|
return []
|
|
|
|
with patch.object(configured_client, '_make_request', side_effect=fake_request):
|
|
_run_async(configured_client.get_all_downloads())
|
|
|
|
assert captured == [('GET', 'transfers/downloads')]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cancel_download()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cancel_download_returns_false_when_not_configured():
|
|
client = SoulseekClient.__new__(SoulseekClient)
|
|
client.base_url = None
|
|
result = _run_async(client.cancel_download('dl-id', 'user', remove=False))
|
|
assert result is False
|
|
|
|
|
|
def test_cancel_download_looks_up_username_when_not_provided(configured_client):
|
|
"""Pinning: orchestrator may call cancel_download without a
|
|
username hint. The client falls back to scanning all downloads
|
|
to find which peer owns it. Engine refactor must preserve this
|
|
so existing API endpoints that don't pass username keep working."""
|
|
fake_listing = [
|
|
{
|
|
'username': 'peer-owner',
|
|
'directories': [{
|
|
'files': [{'id': 'target-dl', 'filename': 'x.flac',
|
|
'state': 'InProgress', 'size': 0,
|
|
'bytesTransferred': 0, 'averageSpeed': 0}],
|
|
}],
|
|
},
|
|
]
|
|
|
|
captured_endpoints = []
|
|
|
|
async def fake_request(method, endpoint, **kwargs):
|
|
captured_endpoints.append((method, endpoint))
|
|
if method == 'GET' and endpoint == 'transfers/downloads':
|
|
return fake_listing
|
|
# The DELETE for cancel — return success
|
|
return True
|
|
|
|
with patch.object(configured_client, '_make_request', side_effect=fake_request):
|
|
_run_async(configured_client.cancel_download('target-dl', None, remove=False))
|
|
|
|
# The lookup hit get_all_downloads first, then the DELETE used the discovered username.
|
|
assert ('GET', 'transfers/downloads') in captured_endpoints
|
|
delete_calls = [(m, e) for m, e in captured_endpoints if m == 'DELETE']
|
|
assert delete_calls, "Expected at least one DELETE after username lookup"
|
|
# The cancel endpoint URL contains the discovered username.
|
|
assert any('peer-owner' in e for _, e in delete_calls)
|
|
|
|
|
|
def test_cancel_download_returns_false_when_username_lookup_fails(configured_client):
|
|
"""Pinning: if the download_id isn't in the active list, return
|
|
False rather than raising. Orchestrator treats False as "couldn't
|
|
cancel" and continues; an exception would propagate to the user."""
|
|
with patch.object(configured_client, '_make_request',
|
|
AsyncMock(return_value=[])):
|
|
result = _run_async(configured_client.cancel_download('missing-id', None))
|
|
assert result is False
|