""" E2E Test Configuration and Fixtures. These tests require the full application stack to be running. Run with: uv run pytest tests/e2e/ -v -m e2e """ from __future__ import annotations import os import time from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any import pytest import requests if TYPE_CHECKING: from collections.abc import Iterator # Default test configuration DEFAULT_BASE_URL = "http://localhost:8084" DEFAULT_TIMEOUT = 10 POLL_INTERVAL = 2 DOWNLOAD_TIMEOUT = 300 # 5 minutes max for downloads E2E_USERNAME_ENV = "E2E_USERNAME" E2E_PASSWORD_ENV = "E2E_PASSWORD" TERMINAL_DOWNLOAD_STATES = {"complete", "done", "available", "error", "cancelled"} SUCCESS_DOWNLOAD_STATES = {"complete", "done", "available"} @dataclass class APIClient: """HTTP client for E2E API testing.""" base_url: str timeout: int = DEFAULT_TIMEOUT session: requests.Session = field(default_factory=requests.Session) def get(self, path: str, **kwargs) -> requests.Response: """Make a GET request.""" kwargs.setdefault("timeout", self.timeout) return self.session.get(f"{self.base_url}{path}", **kwargs) def post(self, path: str, **kwargs) -> requests.Response: """Make a POST request.""" kwargs.setdefault("timeout", self.timeout) return self.session.post(f"{self.base_url}{path}", **kwargs) def put(self, path: str, **kwargs) -> requests.Response: """Make a PUT request.""" kwargs.setdefault("timeout", self.timeout) return self.session.put(f"{self.base_url}{path}", **kwargs) def delete(self, path: str, **kwargs) -> requests.Response: """Make a DELETE request.""" kwargs.setdefault("timeout", self.timeout) return self.session.delete(f"{self.base_url}{path}", **kwargs) def close(self) -> None: """Close the underlying HTTP session.""" self.session.close() def wait_for_health(self, max_wait: int = 30) -> bool: """Wait for the server to be healthy.""" start = time.time() while time.time() - start < max_wait: try: resp = self.get("/api/health") if resp.status_code == 200: return True except requests.exceptions.RequestException: pass time.sleep(1) return False def assert_json_object(response: requests.Response, *, context: str) -> dict[str, Any]: """Assert that a response is a JSON object.""" assert response.status_code == 200, f"{context} failed: {response.status_code} {response.text}" try: data = response.json() except ValueError: pytest.fail(f"{context} did not return valid JSON: {response.text}") assert isinstance(data, dict), f"{context} did not return a JSON object: {data!r}" return data def assert_json_list(response: requests.Response, *, context: str) -> list[Any]: """Assert that a response is a JSON list.""" assert response.status_code == 200, f"{context} failed: {response.status_code} {response.text}" try: data = response.json() except ValueError: pytest.fail(f"{context} did not return valid JSON: {response.text}") assert isinstance(data, list), f"{context} did not return a JSON list: {data!r}" return data def assert_queued_download_response( response: requests.Response, *, expected_priority: int = 0, ) -> dict[str, Any]: """Assert the shared queued-download payload shape.""" data = assert_json_object(response, context="download queue") assert data == {"status": "queued", "priority": expected_priority} return data def assert_queue_order_response(response: requests.Response) -> list[dict[str, Any]]: """Assert the queue order response shape.""" data = assert_json_object(response, context="queue order") queue = data.get("queue") assert isinstance(queue, list), f"queue order payload missing queue list: {data!r}" for entry in queue: assert isinstance(entry, dict), f"queue entry is not a JSON object: {entry!r}" assert isinstance(entry.get("id"), str) assert isinstance(entry.get("priority"), int) assert isinstance(entry.get("added_time"), (int, float)) assert isinstance(entry.get("status"), str) return queue def _get_auth_state(client: APIClient) -> dict[str, object] | None: """Read the live server auth state for auth-sensitive E2E tests.""" try: response = client.get("/api/auth/check") except requests.exceptions.RequestException: return None if response.status_code != 200: return None try: payload = response.json() except ValueError: return None return payload if isinstance(payload, dict) else None def _login_with_env_credentials(client: APIClient) -> bool: """Try authenticating an E2E client with env-provided credentials.""" username = os.environ.get(E2E_USERNAME_ENV, "").strip() password = os.environ.get(E2E_PASSWORD_ENV, "") if not username or not password: return False response = client.post( "/api/auth/login", json={ "username": username, "password": password, "remember_me": False, }, ) return response.status_code == 200 def _is_explicit_e2e_run(markexpr: str, args: list[str]) -> bool: """Detect when pytest was invoked specifically to exercise E2E coverage.""" normalized_markexpr = (markexpr or "").strip() if "e2e" in normalized_markexpr and "not e2e" not in normalized_markexpr: return True target_args = [arg for arg in args if not arg.startswith("-")] if not target_args: return False for arg in target_args: base = arg.split("::", maxsplit=1)[0] parts = Path(base).parts if "tests" not in parts: continue tests_index = parts.index("tests") if len(parts) > tests_index + 1 and parts[tests_index + 1] == "e2e": return True return False def _require_authenticated_client(client: APIClient, *, strict: bool) -> APIClient: """Require an authenticated client for protected-route E2E tests.""" auth_state = _get_auth_state(client) if auth_state is None: message = ( "Unable to read auth state from the live server. " "Check the stack or run against a reachable instance." ) if strict: pytest.fail(message) pytest.skip(message) if not auth_state.get("auth_required"): return client if auth_state.get("authenticated"): return client username = os.environ.get(E2E_USERNAME_ENV, "").strip() password = os.environ.get(E2E_PASSWORD_ENV, "") if not username or not password: message = ( "Live server requires authentication for this E2E test. " f"Set {E2E_USERNAME_ENV}/{E2E_PASSWORD_ENV} or run against a no-auth instance." ) if strict: pytest.fail(message) pytest.skip(message) if not _login_with_env_credentials(client): message = ( "Failed to authenticate the E2E client with " f"{E2E_USERNAME_ENV}/{E2E_PASSWORD_ENV}. " "Check the credentials or run against a no-auth instance." ) if strict: pytest.fail(message) pytest.skip(message) refreshed_auth_state = _get_auth_state(client) if refreshed_auth_state.get("authenticated"): return client message = ( "Login request completed but the live server still reports an unauthenticated session." ) if strict: pytest.fail(message) pytest.skip(message) def _require_healthy_server(base_url: str, *, strict: bool) -> str: """Require a reachable live server before running live E2E tests.""" client = APIClient(base_url=base_url) try: if client.wait_for_health(): return base_url finally: client.close() message = "Server not available - ensure the app is running" if strict: pytest.fail(message) pytest.skip(message) @dataclass class DownloadTracker: """Tracks downloads for cleanup after tests.""" client: APIClient queued_ids: list[str] = field(default_factory=list) def track(self, book_id: str) -> str: """Track a book ID for cleanup.""" self.queued_ids.append(book_id) return book_id def cleanup(self) -> None: """Cancel all tracked downloads.""" for book_id in self.queued_ids: with suppress(requests.exceptions.RequestException): self.client.delete(f"/api/download/{book_id}/cancel") self.queued_ids.clear() def wait_for_status( self, book_id: str, target_states: list[str], timeout: int = DOWNLOAD_TIMEOUT, ) -> dict | None: """ Poll status until book reaches one of the target states. Args: book_id: The book/task ID to check target_states: List of states to wait for (e.g., ["complete", "error"]) timeout: Maximum seconds to wait Returns: Status dict if target state reached, None if timeout """ start = time.time() while time.time() - start < timeout: try: resp = self.client.get("/api/status") if resp.status_code != 200: time.sleep(POLL_INTERVAL) continue status_data = resp.json() if not isinstance(status_data, dict): time.sleep(POLL_INTERVAL) continue # Check each status category for state in target_states: state_entries = status_data.get(state) if isinstance(state_entries, dict) and book_id in state_entries: return { "state": state, "data": state_entries[book_id], } # Check for error state error_entries = status_data.get("error") if isinstance(error_entries, dict) and book_id in error_entries: return { "state": "error", "data": error_entries[book_id], } except requests.exceptions.RequestException, ValueError, TypeError: pass time.sleep(POLL_INTERVAL) return None @pytest.fixture(scope="session") def base_url() -> str: """Get the base URL for the API server.""" return os.environ.get("E2E_BASE_URL", DEFAULT_BASE_URL) @pytest.fixture(scope="session") def healthy_base_url(base_url: str, request: pytest.FixtureRequest) -> str: """Ensure the live server is reachable before creating per-test clients.""" strict = _is_explicit_e2e_run( getattr(request.config.option, "markexpr", ""), list(getattr(request.config, "args", [])), ) return _require_healthy_server(base_url, strict=strict) @pytest.fixture def api_client(healthy_base_url: str) -> Iterator[APIClient]: """Create a fresh API client for each E2E test.""" client = APIClient(base_url=healthy_base_url) yield client client.close() @pytest.fixture def protected_api_client(api_client: APIClient, request: pytest.FixtureRequest) -> APIClient: """Create an authenticated client for protected-route E2E tests.""" strict = _is_explicit_e2e_run( getattr(request.config.option, "markexpr", ""), list(getattr(request.config, "args", [])), ) return _require_authenticated_client(api_client, strict=strict) @pytest.fixture def download_tracker(request: pytest.FixtureRequest) -> Iterator[DownloadTracker]: """Create a download tracker that cleans up after each test.""" client_fixture_name = ( "protected_api_client" if "protected_api_client" in request.fixturenames else "api_client" ) client = request.getfixturevalue(client_fixture_name) tracker = DownloadTracker(client=client) yield tracker tracker.cleanup() @pytest.fixture(scope="session") def server_config(healthy_base_url: str) -> dict: """Get server configuration.""" client = APIClient(base_url=healthy_base_url) try: resp = client.get("/api/config") if resp.status_code != 200: return {} return resp.json() finally: client.close()