soulsync/tests/test_downloads_pagination.py
Antti Kettunen 0bbf44809f
Move the import flows and related post-processing pipelines into separate modules
- Extract the import pipeline, album import, staging, path, file ops, guards, runtime state, side effects, and metadata enrichment out of .
- Canonicalize the refactored import path around  and remove legacy , , , and  request shapes from the import endpoints.
- Make album and track metadata lookups follow the configured provider priority instead of hard-coding Spotify, while still falling back when needed.
- Update the import routes and frontend payloads to use the new core helpers.
- Add coverage for the extracted helpers and the refactored import flows.

PS. apologies to anyone who might check this commit out - the intention was to start small, but things kinda snowballed out of control at some point since the logic just kept going on and on, and everything kinda had to be changed all at once for it all to make any sense
2026-04-27 19:54:43 +03:00

178 lines
5.4 KiB
Python

"""Unit tests for downloads endpoint pagination.
Fix 4.1: `GET /api/v1/downloads` previously returned every task in the
in-memory `download_tasks` dict on every call. With many downloads this
produces a huge payload. The endpoint now supports `limit`, `offset`,
and `status` query params and includes a `total` count.
"""
import sys
import types
from unittest.mock import patch
import pytest
# api/__init__.py eagerly imports flask_limiter. Stub before import.
def _install_flask_limiter_stub():
if "flask_limiter" in sys.modules:
return
stub = types.ModuleType("flask_limiter")
class _Limiter:
def __init__(self, *args, **kwargs):
pass
def limit(self, *args, **kwargs):
def decorator(target):
return target
return decorator
def init_app(self, app):
pass
stub.Limiter = _Limiter
sys.modules["flask_limiter"] = stub
util_stub = types.ModuleType("flask_limiter.util")
util_stub.get_remote_address = lambda: "127.0.0.1"
sys.modules["flask_limiter.util"] = util_stub
_install_flask_limiter_stub()
from flask import Flask, Blueprint # noqa: E402
from api import downloads as downloads_mod # noqa: E402
import core.import_runtime_state as runtime_state # noqa: E402
def _make_task(status="downloading", when=None):
return {
"status": status,
"track_name": f"Track {when}",
"artist_name": "Artist",
"album_name": "Album",
"username": "user",
"filename": "file.mp3",
"progress": 0,
"size": 1000,
"status_change_time": when or "2026-01-01T00:00:00",
}
def _make_app_with_tasks(tasks_dict):
"""Create a minimal Flask app with the downloads blueprint mounted and
the shared import runtime state populated with the given download_tasks dict."""
original_tasks = dict(runtime_state.download_tasks)
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(tasks_dict)
# Bypass API key auth for tests.
def _passthrough(f):
return f
app = Flask(__name__)
bp = Blueprint("v1", __name__, url_prefix="/api/v1")
with patch.object(downloads_mod, "require_api_key", _passthrough):
# downloads.register_routes was already imported with the real
# decorator bound, but register_routes runs fresh decorators at
# call time against the passed blueprint.
downloads_mod.register_routes(bp)
app.register_blueprint(bp)
app._original_download_tasks = original_tasks
return app
@pytest.fixture
def client():
tasks = {
f"task-{i:03d}": _make_task(
status="downloading" if i % 2 == 0 else "queued",
when=f"2026-01-{i+1:02d}T00:00:00",
)
for i in range(25)
}
app = _make_app_with_tasks(tasks)
try:
with app.test_client() as c:
yield c
finally:
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(app._original_download_tasks)
def test_default_limit_applied(client):
# 25 tasks, default limit is 100 -> all fit in one page.
resp = client.get("/api/v1/downloads")
assert resp.status_code == 200
data = resp.get_json()["data"]
assert data["total"] == 25
assert data["limit"] == 100
assert data["offset"] == 0
assert len(data["downloads"]) == 25
def test_limit_and_offset_slice_correctly(client):
resp = client.get("/api/v1/downloads?limit=5&offset=0")
data = resp.get_json()["data"]
assert len(data["downloads"]) == 5
assert data["total"] == 25
resp2 = client.get("/api/v1/downloads?limit=5&offset=20")
data2 = resp2.get_json()["data"]
assert len(data2["downloads"]) == 5
# Pages should not overlap.
page1_ids = {t["id"] for t in data["downloads"]}
page5_ids = {t["id"] for t in data2["downloads"]}
assert page1_ids.isdisjoint(page5_ids)
def test_status_filter_single(client):
resp = client.get("/api/v1/downloads?status=downloading&limit=100")
data = resp.get_json()["data"]
# 13 even-indexed tasks (0,2,...,24)
assert data["total"] == 13
for t in data["downloads"]:
assert t["status"] == "downloading"
def test_status_filter_multiple_comma_separated(client):
resp = client.get("/api/v1/downloads?status=downloading,queued&limit=100")
data = resp.get_json()["data"]
assert data["total"] == 25
def test_status_filter_no_match_returns_empty(client):
resp = client.get("/api/v1/downloads?status=nonexistent_status")
data = resp.get_json()["data"]
assert data["total"] == 0
assert data["downloads"] == []
def test_limit_is_clamped_to_max(client):
resp = client.get("/api/v1/downloads?limit=99999")
data = resp.get_json()["data"]
assert data["limit"] == 500
def test_negative_offset_is_normalized(client):
resp = client.get("/api/v1/downloads?offset=-5")
data = resp.get_json()["data"]
assert data["offset"] == 0
def test_invalid_limit_falls_back_to_default(client):
resp = client.get("/api/v1/downloads?limit=not_a_number")
data = resp.get_json()["data"]
assert data["limit"] == 100
def test_tasks_sorted_newest_first(client):
resp = client.get("/api/v1/downloads?limit=3&offset=0")
data = resp.get_json()["data"]
times = [t["status_change_time"] for t in data["downloads"]]
# Most recent (2026-01-25) should come first.
assert times == sorted(times, reverse=True)