Files
shelfmark/tests/e2e/test_prowlarr_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

425 lines
16 KiB
Python

"""
E2E Tests for Prowlarr Integration.
These tests verify the Prowlarr release source and download client flow.
Requires Prowlarr and a download client (qBittorrent, Transmission, etc.) to be configured.
Run with: uv run pytest tests/e2e/test_prowlarr_flow.py -v -m e2e
"""
import pytest
from .conftest import (
DOWNLOAD_TIMEOUT,
SUCCESS_DOWNLOAD_STATES,
APIClient,
DownloadTracker,
assert_queued_download_response,
)
def _assert_terminal_download_result(
result: dict[str, object],
*,
source_id: str,
expected_title: str,
expected_source: str,
) -> None:
"""Assert that a finished Prowlarr download produced a structured 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()
normalized_error = error_message.lower()
if any(
marker in normalized_error
for marker in (
"failed to connect",
"connection error",
"connection refused",
"name or service not known",
"max retries exceeded",
"timed out",
)
):
pytest.skip(f"{expected_source} dependency unavailable: {error_message}")
pytest.fail(
f"{expected_source} download failed"
f"{f': {error_message}' if error_message else f': {entry!r}'}"
)
assert state in SUCCESS_DOWNLOAD_STATES, (
f"{expected_source} ended in unexpected state {state!r}: {entry!r}"
)
assert entry.get("id") == source_id
assert entry.get("title") == expected_title
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 _is_prowlarr_configured(api_client: APIClient) -> bool:
"""Check if Prowlarr is configured and available."""
resp = api_client.get("/api/release-sources")
if resp.status_code == 503:
pytest.skip("Release sources unavailable")
assert resp.status_code == 200, f"Release sources failed: {resp.status_code} {resp.text}"
sources = resp.json()
if not isinstance(sources, list):
pytest.fail(f"Release sources returned an unexpected payload: {sources!r}")
if not all(isinstance(source, dict) for source in sources):
pytest.fail(f"Release sources returned a malformed payload: {sources!r}")
return any(source.get("name") == "prowlarr" for source in sources)
def _get_first_provider_name(api_client: APIClient) -> str | None:
"""Get the first available provider name."""
providers_resp = api_client.get("/api/metadata/providers")
if providers_resp.status_code == 503:
pytest.skip("Metadata providers unavailable")
assert providers_resp.status_code == 200, (
f"Metadata providers failed: {providers_resp.status_code} {providers_resp.text}"
)
providers_data = providers_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
)
for provider in providers:
if (
isinstance(provider, dict)
and provider.get("name")
and provider.get("enabled") is True
and provider.get("available") is True
):
return provider["name"]
return None
@pytest.mark.e2e
class TestProwlarrConfiguration:
"""Tests for Prowlarr configuration."""
def test_prowlarr_in_release_sources(self, protected_api_client: APIClient):
"""Test that Prowlarr appears in release sources."""
resp = protected_api_client.get("/api/release-sources")
assert resp.status_code == 200
sources = resp.json()
assert isinstance(sources, list), f"Unexpected release sources payload: {sources!r}"
assert all(isinstance(source, dict) for source in sources), (
f"Unexpected release sources payload: {sources!r}"
)
source_names = [s.get("name") for s in sources]
assert "prowlarr" in source_names
def test_prowlarr_settings_tab_exists(self, protected_api_client: APIClient):
"""Test that Prowlarr settings tab exists."""
resp = protected_api_client.get("/api/settings")
if resp.status_code == 403:
pytest.skip("Settings disabled")
assert resp.status_code == 200
data = resp.json()
# Settings may have nested structure with groups/tabs
if isinstance(data, dict):
# Could have groups containing tabs, or be flat
if "groups" in data:
# Nested: look in groups for prowlarr tabs
all_tab_names = []
for group in data.get("groups", []):
if isinstance(group, dict):
all_tab_names.extend(
tab.get("name") or tab.get("id", "")
for tab in group.get("tabs", [])
if isinstance(tab, dict)
)
tab_names = all_tab_names
else:
tab_names = list(data.keys())
else:
tab_names = [t.get("name") or t.get("id") for t in data if isinstance(t, dict)]
# Prowlarr settings should exist (may be under different name)
prowlarr_tabs = [n for n in tab_names if n and "prowlarr" in n.lower()]
# Also check if we can directly access the prowlarr_clients settings
prowlarr_resp = protected_api_client.get("/api/settings/prowlarr_clients")
has_prowlarr_settings = prowlarr_resp.status_code == 200
assert prowlarr_tabs or has_prowlarr_settings, (
f"No prowlarr settings found. Tab names: {tab_names}"
)
@pytest.mark.e2e
@pytest.mark.slow
class TestProwlarrSearch:
"""Tests for searching via Prowlarr."""
def test_prowlarr_search_with_metadata(self, protected_api_client: APIClient):
"""Test searching Prowlarr with metadata from a provider."""
if not _is_prowlarr_configured(protected_api_client):
pytest.skip("Prowlarr not configured")
provider = _get_first_provider_name(protected_api_client)
if not provider:
pytest.skip("No metadata providers")
# Search for a book
search_resp = protected_api_client.get(
"/api/metadata/search",
params={"query": "The Great Gatsby", "provider": provider},
timeout=30,
)
search_data = _require_json_object(search_resp, context="metadata search")
results = _extract_result_list(search_data, context="metadata search")
book = results[0]
book_id = book.get("id") or book.get("provider_id")
assert book_id, "Metadata search result missing ID"
# Now search releases specifically from Prowlarr
releases_resp = protected_api_client.get(
"/api/releases",
params={
"provider": provider,
"book_id": book_id,
"source": "prowlarr",
"title": book.get("title", ""),
"author": book.get("author", ""),
},
timeout=60,
)
data = _require_json_object(releases_resp, context="Prowlarr release search")
assert data.get("sources_searched") == ["prowlarr"]
releases = data.get("releases")
assert isinstance(releases, list), f"Unexpected Prowlarr release payload: {data!r}"
if not releases:
pytest.skip("No Prowlarr releases found")
assert "book" in data
@pytest.mark.e2e
class TestProwlarrClientSettings:
"""Tests for Prowlarr download client settings."""
def test_client_settings_structure(self, protected_api_client: APIClient):
"""Test that client settings have expected structure."""
resp = protected_api_client.get("/api/settings/prowlarr_clients")
if resp.status_code == 403:
pytest.skip("Settings disabled")
if resp.status_code == 404:
pytest.skip("Prowlarr clients settings tab not found")
assert resp.status_code == 200
data = resp.json()
# Should have fields for client configuration
assert isinstance(data, (dict, list))
def test_can_save_client_settings(self, protected_api_client: APIClient):
"""Test that client settings can be saved."""
# Get current settings
get_resp = protected_api_client.get("/api/settings/prowlarr_clients")
if get_resp.status_code in [403, 404]:
pytest.skip("Settings not available")
current = get_resp.json()
assert isinstance(current, dict), f"Unexpected prowlarr_clients payload: {current!r}"
fields = current.get("fields")
assert isinstance(fields, list) and fields, (
f"Prowlarr client settings payload missing fields: {current!r}"
)
values = {}
for field in fields:
if not isinstance(field, dict):
continue
key = field.get("key") or field.get("name")
if key:
values[key] = field.get("value", "")
assert values, f"No editable values found in prowlarr_clients payload: {current!r}"
put_resp = protected_api_client.put(
"/api/settings/prowlarr_clients",
json=values,
)
assert put_resp.status_code in [200, 204]
if put_resp.status_code == 200:
payload = put_resp.json()
assert isinstance(payload, dict)
@pytest.mark.e2e
@pytest.mark.slow
class TestProwlarrDownload:
"""Tests for downloading via Prowlarr."""
def test_queue_prowlarr_release(
self, protected_api_client: APIClient, download_tracker: DownloadTracker
):
"""Test queueing a Prowlarr release for download."""
if not _is_prowlarr_configured(protected_api_client):
pytest.skip("Prowlarr not configured")
provider = _get_first_provider_name(protected_api_client)
if not provider:
pytest.skip("No providers")
# Search metadata
search_resp = protected_api_client.get(
"/api/metadata/search",
params={"query": "Dracula Bram Stoker", "provider": provider},
timeout=30,
)
search_data = _require_json_object(search_resp, context="metadata search")
results = _extract_result_list(search_data, context="metadata search")
book = results[0]
book_id = book.get("id") or book.get("provider_id")
assert book_id, "Metadata search result missing ID"
# Search Prowlarr releases
releases_resp = protected_api_client.get(
"/api/releases",
params={
"provider": provider,
"book_id": book_id,
"source": "prowlarr",
"title": book.get("title", ""),
},
timeout=60,
)
releases_data = _require_json_object(releases_resp, context="Prowlarr release search")
releases = releases_data.get("releases")
assert isinstance(releases, list), f"Unexpected Prowlarr release payload: {releases_data!r}"
if not releases:
pytest.skip("No Prowlarr releases found")
# Get the first release
release = releases[0]
source_id = release.get("source_id") or release.get("id")
download_tracker.track(source_id)
# Queue it
queue_resp = protected_api_client.post(
"/api/releases/download",
json={
"source": "prowlarr",
"source_id": source_id,
"title": release.get("title", book.get("title", "Test")),
"format": release.get("format"),
"size": release.get("size"),
"extra": release.get("extra", {}),
},
)
if not _is_duplicate_queue_error(queue_resp):
assert_queued_download_response(queue_resp)
result = download_tracker.wait_for_status(
source_id,
target_states=["complete", "done", "available"],
timeout=DOWNLOAD_TIMEOUT,
)
if result is None:
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"Prowlarr download failed: {error_info}")
pytest.fail("Prowlarr download timed out")
_assert_terminal_download_result(
result,
source_id=source_id,
expected_title=release.get("title", book.get("title", "Test")),
expected_source="prowlarr",
)
@pytest.mark.e2e
class TestProwlarrClientConnection:
"""Tests for testing download client connections."""
def test_connection_test_action(self, protected_api_client: APIClient):
"""Test the connection test action for download clients."""
# This tests the action button functionality in settings
resp = protected_api_client.post(
"/api/settings/prowlarr_clients/action/test_torrent_connection"
)
# May succeed, fail, or not exist depending on configuration.
# A 500 is a real server error and should fail the test.
assert resp.status_code in [200, 400, 404]
if resp.status_code == 200:
data = resp.json()
assert isinstance(data, dict)
assert "success" in data or "message" in data
if "message" in data:
assert isinstance(data["message"], str)