mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-02 12:55:46 +01:00
- Reworked many tests - Enforcing lint + type checking for test suite - Fixed various issues surfaced by the new tests - CI tweaks
425 lines
16 KiB
Python
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)
|