Files
shelfmark/tests/e2e/test_download_flow.py
T
Alex d7b9f2e67f Backend test hardening + quality enforcement (#872)
- Reworked many tests
- Enforcing lint + type checking for test suite
- Fixed various issues surfaced by the new tests
- CI tweaks
2026-04-12 12:01:52 +01:00

550 lines
19 KiB
Python

"""
E2E Download Flow Tests.
These tests verify the complete download journey from search to file retrieval.
They require external services to be available and may take longer to run.
Run with: uv run pytest tests/e2e/test_download_flow.py -v -m e2e
"""
import time
import pytest
from .conftest import (
DOWNLOAD_TIMEOUT,
SUCCESS_DOWNLOAD_STATES,
APIClient,
DownloadTracker,
assert_queue_order_response,
assert_queued_download_response,
)
def _assert_terminal_download_result(
result: dict[str, object],
*,
source_id: str,
expected_title: str,
expected_source: str | None = None,
) -> None:
"""Assert that a finished download produced a structured queue payload."""
state = result["state"]
entry = result["data"]
assert isinstance(entry, dict)
if state == "error":
error_message = str(
entry.get("status_message") or entry.get("last_error_message") or ""
).strip()
pytest.fail(
f"{expected_source or source_id} download failed"
f"{f': {error_message}' if error_message else f': {entry!r}'}"
)
assert state in SUCCESS_DOWNLOAD_STATES, (
f"{expected_source or source_id} ended in unexpected state {state!r}: {entry!r}"
)
assert entry.get("id") == source_id
assert entry.get("title") == expected_title
if expected_source is not None:
assert entry.get("source") == expected_source
status = entry.get("status")
assert status is None or status in SUCCESS_DOWNLOAD_STATES | {"queued"}
def _is_duplicate_queue_error(response) -> bool:
"""Whether the API refused to queue a release because it already exists."""
if response.status_code != 500:
return False
try:
payload = response.json()
except ValueError:
return False
return payload == {"error": "Release is already in the download queue"}
def _require_json_object(
response, *, context: str, skip_statuses: set[int] | frozenset[int] = frozenset({503})
) -> dict[str, object]:
if response.status_code in skip_statuses:
pytest.skip(f"{context} unavailable: {response.status_code}")
assert response.status_code == 200, f"{context} failed: {response.status_code} {response.text}"
payload = response.json()
assert isinstance(payload, dict), f"{context} did not return a JSON object: {payload!r}"
return payload
def _extract_result_list(payload: object, *, context: str) -> list[dict[str, object]]:
if isinstance(payload, dict):
if "books" in payload:
results = payload["books"]
elif "releases" in payload:
results = payload["releases"]
else:
results = payload.get("results", payload)
else:
results = payload
if isinstance(results, dict):
list_values = [value for value in results.values() if isinstance(value, list)]
if not list_values:
pytest.fail(f"{context} returned an unexpected result structure: {payload!r}")
if not any(list_values):
pytest.skip(f"{context} returned no results")
results = next(value for value in list_values if value)
if not isinstance(results, list):
pytest.fail(f"{context} did not return a result list: {payload!r}")
if not results:
pytest.skip(f"{context} returned no results")
return results
def _require_queue_entry(
api_client: APIClient,
book_id: str,
*,
context: str,
timeout: int = 20,
) -> dict[str, object]:
deadline = time.time() + timeout
while time.time() < deadline:
queue_resp = api_client.get("/api/queue/order")
if queue_resp.status_code == 200:
queue_order = assert_queue_order_response(queue_resp)
for entry in queue_order:
if entry.get("id") == book_id:
return entry
elif queue_resp.status_code == 503:
pytest.skip(f"{context} queue endpoint unavailable")
else:
pytest.fail(
f"{context} queue lookup failed: {queue_resp.status_code} {queue_resp.text}"
)
status_resp = api_client.get("/api/status")
if status_resp.status_code == 200:
status_data = status_resp.json()
if isinstance(status_data, dict):
for state in ("complete", "done", "available", "error", "cancelled"):
state_entries = status_data.get(state)
if isinstance(state_entries, dict) and book_id in state_entries:
pytest.fail(
f"{context} reached terminal state {state} before it was observed in the queue: "
f"{state_entries[book_id]!r}"
)
time.sleep(1)
pytest.fail(f"{context} never appeared in the queue")
def _wait_for_queue_absence(
api_client: APIClient,
book_id: str,
*,
context: str,
timeout: int = 20,
) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
queue_resp = api_client.get("/api/queue/order")
if queue_resp.status_code == 200:
queue_order = assert_queue_order_response(queue_resp)
if all(entry.get("id") != book_id for entry in queue_order):
return
elif queue_resp.status_code == 503:
pytest.skip(f"{context} queue endpoint unavailable")
else:
pytest.fail(
f"{context} queue lookup failed: {queue_resp.status_code} {queue_resp.text}"
)
time.sleep(1)
pytest.fail(f"{context} still appeared in the queue after cancellation")
def _find_available_provider(api_client: APIClient) -> str | None:
"""Find a working metadata provider."""
resp = api_client.get("/api/metadata/providers")
if resp.status_code == 503:
pytest.skip("Metadata providers unavailable")
assert resp.status_code == 200, f"Metadata providers failed: {resp.status_code} {resp.text}"
providers_data = resp.json()
if not isinstance(providers_data, (dict, list)):
pytest.fail(f"Metadata providers returned an unexpected payload: {providers_data!r}")
providers = (
providers_data.get("providers", []) if isinstance(providers_data, dict) else providers_data
)
provider_names = [
provider.get("name")
for provider in providers
if (
isinstance(provider, dict)
and provider.get("name")
and provider.get("enabled") is True
and provider.get("available") is True
)
]
for name in provider_names:
test_resp = api_client.get(
"/api/metadata/search",
params={"query": "test", "provider": name},
timeout=30,
)
if test_resp.status_code == 200:
return name
if test_resp.status_code != 503:
pytest.fail(
f"Metadata provider {name} failed during availability check: "
f"{test_resp.status_code} {test_resp.text}"
)
pytest.skip("No working metadata providers available")
@pytest.mark.e2e
@pytest.mark.slow
class TestMetadataToReleaseFlow:
"""Test the flow from metadata search to release listing."""
def test_search_to_releases_flow(self, protected_api_client: APIClient):
"""Test searching metadata then finding releases."""
# Find a working provider
provider = _find_available_provider(protected_api_client)
# Search for a public domain book
search_resp = protected_api_client.get(
"/api/metadata/search",
params={"query": "Moby Dick Herman Melville", "provider": provider},
timeout=30,
)
search_data = _require_json_object(search_resp, context="metadata search")
results = _extract_result_list(search_data, context="metadata search")
# Get the first result
first_result = results[0]
book_id = first_result.get("id") or first_result.get("provider_id")
assert book_id, "Search result missing ID"
# Now search for releases
releases_resp = protected_api_client.get(
"/api/releases",
params={
"provider": provider,
"book_id": book_id,
"title": first_result.get("title", ""),
"author": first_result.get("author", ""),
},
timeout=60,
)
releases_data = _require_json_object(releases_resp, context="release lookup")
releases = releases_data.get("releases")
if not isinstance(releases, list):
pytest.fail(f"Release lookup returned an invalid releases payload: {releases_data!r}")
if not releases:
pytest.skip("No releases available")
assert "book" in releases_data
@pytest.mark.e2e
@pytest.mark.slow
class TestFullDownloadJourney:
"""
Test the complete download journey.
This test:
1. Searches for a book
2. Finds releases
3. Queues a download
4. Waits for completion
5. Verifies the file exists
"""
def test_complete_download_flow(
self, protected_api_client: APIClient, download_tracker: DownloadTracker
):
"""Test the complete search -> download -> verify flow."""
# Find a working provider
provider = _find_available_provider(protected_api_client)
if not provider:
pytest.skip("No metadata providers available")
# Search for a public domain book
search_resp = protected_api_client.get(
"/api/metadata/search",
params={"query": "Pride and Prejudice Jane Austen", "provider": provider},
timeout=30,
)
search_data = _require_json_object(search_resp, context="metadata search")
results = _extract_result_list(search_data, context="metadata search")
first_result = results[0]
book_id = first_result.get("id") or first_result.get("provider_id")
# Get releases
releases_resp = protected_api_client.get(
"/api/releases",
params={
"provider": provider,
"book_id": book_id,
"title": first_result.get("title", ""),
},
timeout=60,
)
releases_data = _require_json_object(releases_resp, context="release lookup")
releases = releases_data.get("releases", [])
if not releases:
pytest.skip("No releases available")
# Find an epub release (prefer smaller files)
target_release = None
for release in releases:
fmt = release.get("format", "").lower()
if fmt == "epub":
target_release = release
break
if not target_release:
# Fall back to first release
target_release = releases[0]
# Queue the download
source_id = target_release.get("source_id") or target_release.get("id")
download_tracker.track(source_id)
queue_resp = protected_api_client.post(
"/api/releases/download",
json={
"source": target_release.get("source", "direct_download"),
"source_id": source_id,
"title": target_release.get("title", "Test Book"),
"format": target_release.get("format"),
"size": target_release.get("size"),
},
)
if not _is_duplicate_queue_error(queue_resp):
assert_queued_download_response(queue_resp)
# Wait for download to complete (or error)
result = download_tracker.wait_for_status(
source_id,
target_states=["complete", "done", "available"],
timeout=DOWNLOAD_TIMEOUT,
)
if result is None:
# Check if it errored
status_resp = protected_api_client.get("/api/status")
if status_resp.status_code == 200:
status_data = status_resp.json()
error_info = status_data.get("error", {}).get(source_id)
if error_info:
pytest.fail(f"Download failed: {error_info}")
pytest.fail("Download timed out")
_assert_terminal_download_result(
result,
source_id=source_id,
expected_title=target_release.get("title", "Test Book"),
expected_source=target_release.get("source", "direct_download"),
)
@pytest.mark.e2e
@pytest.mark.slow
class TestDirectSourceReleaseFlow:
"""Test direct-mode search, record lookup, and download via shared release APIs."""
def test_direct_source_search_and_download(
self, protected_api_client: APIClient, download_tracker: DownloadTracker
):
"""Test the shared direct-mode source query -> record -> release download flow."""
search_resp = protected_api_client.get(
"/api/releases",
params={"source": "direct_download", "query": "Frankenstein Mary Shelley"},
timeout=30,
)
if search_resp.status_code == 503:
pytest.skip("Direct source query unavailable")
assert search_resp.status_code == 200, (
f"Direct source query failed: {search_resp.status_code} {search_resp.text}"
)
payload = search_resp.json()
assert isinstance(payload, dict), (
f"Direct source query returned an unexpected payload: {payload!r}"
)
results = payload.get("releases") or []
if not isinstance(results, list):
pytest.fail(f"Direct source query returned an unexpected payload: {payload!r}")
if not results:
pytest.skip("No direct source query results")
first_result = results[0]
source = first_result.get("source")
source_id = first_result.get("source_id")
assert source == "direct_download", "Result missing direct source context"
assert source_id, "Result missing source_id"
info_resp = protected_api_client.get(f"/api/release-sources/{source}/records/{source_id}")
if info_resp.status_code != 200:
pytest.fail(f"Source record endpoint failed: {info_resp.status_code}")
# Queue download from the shared release payload
download_tracker.track(source_id)
download_resp = protected_api_client.post(
"/api/releases/download",
json={**first_result, "content_type": "ebook", "search_mode": "direct"},
)
if not _is_duplicate_queue_error(download_resp):
assert_queued_download_response(download_resp)
result = download_tracker.wait_for_status(
source_id,
target_states=["complete", "done", "available"],
timeout=DOWNLOAD_TIMEOUT,
)
assert result is not None, "Direct source download did not reach a terminal state"
_assert_terminal_download_result(
result,
source_id=source_id,
expected_title=first_result.get("title", "Unknown title"),
expected_source="direct_download",
)
@pytest.mark.e2e
class TestDownloadCancellation:
"""Test download cancellation functionality."""
def test_cancel_queued_download(
self, protected_api_client: APIClient, download_tracker: DownloadTracker
):
"""Test cancelling a queued download."""
# Queue a fake download
test_id = f"cancel-test-{int(time.time())}"
download_tracker.track(test_id)
queue_resp = protected_api_client.post(
"/api/releases/download",
json={
"source": "test_source",
"source_id": test_id,
"title": "Cancel Test Book",
},
)
assert_queued_download_response(queue_resp)
_require_queue_entry(
protected_api_client,
test_id,
context="cancel download precondition",
)
# Cancel it
cancel_resp = protected_api_client.delete(f"/api/download/{test_id}/cancel")
assert cancel_resp.status_code == 200
cancel_data = cancel_resp.json()
assert cancel_data == {"status": "cancelled", "book_id": test_id}
_wait_for_queue_absence(
protected_api_client,
test_id,
context="cancel download",
)
def test_cancel_removes_from_queue(
self, protected_api_client: APIClient, download_tracker: DownloadTracker
):
"""Test that cancellation removes item from queue."""
test_id = f"cancel-verify-{int(time.time())}"
download_tracker.track(test_id)
# Queue it
protected_api_client.post(
"/api/releases/download",
json={
"source": "test_source",
"source_id": test_id,
"title": "Cancel Verify Test",
},
)
_require_queue_entry(
protected_api_client,
test_id,
context="cancel verification precondition",
)
# Cancel it
cancel_resp = protected_api_client.delete(f"/api/download/{test_id}/cancel")
assert cancel_resp.status_code == 200
assert cancel_resp.json() == {"status": "cancelled", "book_id": test_id}
# Check it's not in the queue
_wait_for_queue_absence(
protected_api_client,
test_id,
context="cancel verification",
)
@pytest.mark.e2e
class TestQueuePriority:
"""Test queue priority functionality."""
def test_set_priority(self, protected_api_client: APIClient, download_tracker: DownloadTracker):
"""Test setting download priority."""
test_id = f"priority-test-{int(time.time())}"
download_tracker.track(test_id)
# Queue it
queue_resp = protected_api_client.post(
"/api/releases/download",
json={
"source": "test_source",
"source_id": test_id,
"title": "Priority Test",
"priority": 0,
},
)
assert_queued_download_response(queue_resp)
_require_queue_entry(
protected_api_client,
test_id,
context="priority update precondition",
)
# Update priority
priority_resp = protected_api_client.put(
f"/api/queue/{test_id}/priority",
json={"priority": 10},
)
assert priority_resp.status_code == 200
assert priority_resp.json() == {"status": "updated", "book_id": test_id, "priority": 10}
queue_resp = protected_api_client.get("/api/queue/order")
queue_order = assert_queue_order_response(queue_resp)
matching_entries = [entry for entry in queue_order if entry.get("id") == test_id]
assert len(matching_entries) == 1
assert matching_entries[0]["priority"] == 10