""" E2E API Tests. Tests the full application flow through the HTTP API. Run with: uv run pytest tests/e2e/ -v -m e2e """ import pytest from .conftest import APIClient, DownloadTracker @pytest.mark.e2e class TestHealthEndpoint: """Tests for the health check endpoint.""" def test_health_returns_ok(self, api_client: APIClient): """Test that health endpoint returns 200.""" resp = api_client.get("/api/health") assert resp.status_code == 200 data = resp.json() assert data.get("status") == "ok" def test_health_includes_status(self, api_client: APIClient): """Test that health endpoint includes status field.""" resp = api_client.get("/api/health") data = resp.json() assert "status" in data assert data["status"] == "ok" @pytest.mark.e2e class TestConfigEndpoint: """Tests for the configuration endpoint.""" def test_config_returns_expected_fields(self, protected_api_client: APIClient): """Test that config includes expected configuration fields.""" resp = protected_api_client.get("/api/config") assert resp.status_code == 200 data = resp.json() # Config should be a dict with various settings assert isinstance(data, dict) # Should have some standard config fields assert "supported_formats" in data or "book_languages" in data def test_config_returns_supported_formats(self, protected_api_client: APIClient): """Test that config includes supported formats.""" resp = protected_api_client.get("/api/config") data = resp.json() assert "supported_formats" in data assert isinstance(data["supported_formats"], list) # Should include common ebook formats formats = data["supported_formats"] assert "epub" in formats or "EPUB" in [f.upper() for f in formats] @pytest.mark.e2e class TestReleaseSourcesEndpoint: """Tests for the release sources endpoint.""" def test_release_sources_returns_list(self, protected_api_client: APIClient): """Test that release sources endpoint returns available sources.""" resp = protected_api_client.get("/api/release-sources") assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) def test_release_sources_have_required_fields(self, protected_api_client: APIClient): """Test that each release source has required fields.""" resp = protected_api_client.get("/api/release-sources") data = resp.json() for source in data: assert "name" in source assert "display_name" in source or "label" in source @pytest.mark.e2e class TestMetadataProvidersEndpoint: """Tests for the metadata providers endpoint.""" def test_providers_returns_data(self, protected_api_client: APIClient): """Test that providers endpoint returns provider data.""" resp = protected_api_client.get("/api/metadata/providers") assert resp.status_code == 200 data = resp.json() # May be list or dict depending on implementation assert isinstance(data, (list, dict)) def test_providers_have_required_fields(self, protected_api_client: APIClient): """Test that each provider has required fields.""" resp = protected_api_client.get("/api/metadata/providers") data = resp.json() # Handle both list and dict formats if isinstance(data, dict): providers = list(data.values()) if data else [] else: providers = data for provider in providers: if isinstance(provider, dict): # Should have name or be identifiable assert "name" in provider or "id" in provider or "label" in provider @pytest.mark.e2e class TestMetadataSearch: """Tests for metadata search functionality.""" def test_search_requires_query(self, protected_api_client: APIClient): """Test that search requires a query parameter.""" resp = protected_api_client.get("/api/metadata/search") # Should return error for missing query assert resp.status_code in [400, 422] def test_search_returns_results(self, protected_api_client: APIClient): """Test that search returns results for a known book.""" resp = protected_api_client.get("/api/metadata/search", params={"query": "1984 Orwell"}) # May return 200 with results or 503 if provider unavailable if resp.status_code == 200: data = resp.json() # Response may be list directly, or dict with results key assert "results" in data or isinstance(data, list) or "query" in data def test_search_with_provider_filter(self, protected_api_client: APIClient): """Test searching with a specific provider.""" # Get available providers first providers_resp = protected_api_client.get("/api/metadata/providers") if providers_resp.status_code != 200: pytest.skip("Could not get providers") providers_data = providers_resp.json() if not providers_data: pytest.skip("No providers available") # Handle both list and dict formats if isinstance(providers_data, dict): # Dict format: get first provider name from keys or values if providers_data: first_key = list(providers_data.keys())[0] provider_info = providers_data[first_key] provider_name = ( provider_info.get("name", first_key) if isinstance(provider_info, dict) else first_key ) else: pytest.skip("No providers available") else: # List format provider_name = providers_data[0].get("name") if providers_data else None if not provider_name: pytest.skip("Could not determine provider name") resp = protected_api_client.get( "/api/metadata/search", params={"query": "Moby Dick", "provider": provider_name}, ) # Should return 200 or 503 (provider unavailable) assert resp.status_code in [200, 503] @pytest.mark.e2e class TestStatusEndpoint: """Tests for the status endpoint.""" def test_status_returns_categories(self, protected_api_client: APIClient): """Test that status endpoint returns expected categories.""" resp = protected_api_client.get("/api/status") assert resp.status_code == 200 data = resp.json() # Should have standard status categories assert isinstance(data, dict) def test_active_downloads_endpoint(self, protected_api_client: APIClient): """Test the active downloads endpoint.""" resp = protected_api_client.get("/api/downloads/active") assert resp.status_code == 200 data = resp.json() assert isinstance(data, (list, dict)) @pytest.mark.e2e class TestQueueEndpoint: """Tests for queue management endpoints.""" def test_queue_order_returns_data(self, protected_api_client: APIClient): """Test that queue order endpoint returns queue data.""" resp = protected_api_client.get("/api/queue/order") assert resp.status_code == 200 data = resp.json() # May return list directly or dict with queue key if isinstance(data, dict): assert "queue" in data assert isinstance(data["queue"], list) else: assert isinstance(data, list) @pytest.mark.e2e class TestSettingsEndpoint: """Tests for settings endpoints.""" def test_settings_returns_tabs(self, protected_api_client: APIClient): """Test that settings endpoint returns tab structure.""" resp = protected_api_client.get("/api/settings") # Settings may be disabled if config dir not writable if resp.status_code == 403: pytest.skip("Settings disabled (config dir not writable)") assert resp.status_code == 200 data = resp.json() assert isinstance(data, (list, dict)) def test_get_specific_settings_tab(self, protected_api_client: APIClient): """Test getting a specific settings tab.""" # First get available tabs resp = protected_api_client.get("/api/settings") if resp.status_code == 403: pytest.skip("Settings disabled") data = resp.json() if not data: pytest.skip("No settings tabs available") # Get the first tab if isinstance(data, list): tab_name = data[0].get("name") or data[0].get("id") else: tab_name = list(data.keys())[0] if data else None if not tab_name: pytest.skip("Could not determine tab name") resp = protected_api_client.get(f"/api/settings/{tab_name}") assert resp.status_code in [200, 404] @pytest.mark.e2e class TestDownloadFlow: """Tests for the complete download flow.""" def test_cancel_nonexistent_download(self, protected_api_client: APIClient): """Test cancelling a download that doesn't exist.""" resp = protected_api_client.delete("/api/download/nonexistent-id-xyz/cancel") # Should handle gracefully (may return 200, 204, or 404) assert resp.status_code in [200, 204, 404] @pytest.mark.e2e class TestReleaseDownloadFlow: """Tests for the release-based download flow (new API).""" def test_release_download_requires_source_id(self, protected_api_client: APIClient): """Test that release download requires source_id.""" resp = protected_api_client.post("/api/releases/download", json={}) assert resp.status_code == 400 data = resp.json() assert "error" in data def test_release_download_with_minimal_data( self, protected_api_client: APIClient, download_tracker: DownloadTracker ): """Test queueing a release with minimal valid data.""" # This will queue but likely fail during download (no real source) test_id = "e2e-test-release-minimal" resp = protected_api_client.post( "/api/releases/download", json={ "source": "test_source", "source_id": test_id, "title": "E2E Test Book", }, ) if resp.status_code == 200: download_tracker.track(test_id) data = resp.json() assert data.get("status") == "queued" def test_cancel_release_with_slash_id( self, protected_api_client: APIClient, download_tracker: DownloadTracker ): """Cancelling/clearing should work for IDs containing slashes.""" test_id = "e2e-test-release/with-slash" resp = protected_api_client.post( "/api/releases/download", json={ "source": "test_source", "source_id": test_id, "title": "E2E Test Book", }, ) if resp.status_code != 200: pytest.skip("Release download endpoint not available") download_tracker.track(test_id) cancel_resp = protected_api_client.delete(f"/api/download/{test_id}/cancel") assert cancel_resp.status_code in [200, 204] @pytest.mark.e2e class TestReleasesSearch: """Tests for searching releases.""" def test_releases_requires_params(self, protected_api_client: APIClient): """Test that releases endpoint requires provider and book_id.""" resp = protected_api_client.get("/api/releases") assert resp.status_code == 400 data = resp.json() assert "error" in data def test_releases_with_invalid_provider(self, protected_api_client: APIClient): """Test releases with invalid provider.""" resp = protected_api_client.get( "/api/releases", params={"provider": "nonexistent_provider", "book_id": "123"}, ) assert resp.status_code == 400 data = resp.json() assert "error" in data @pytest.mark.e2e class TestCoverProxy: """Tests for the cover image proxy.""" def test_cover_without_url_returns_error(self, protected_api_client: APIClient): """Test that cover endpoint without URL returns error.""" resp = protected_api_client.get("/api/covers/test-id") # Should return error for missing URL assert resp.status_code in [400, 404] @pytest.mark.e2e class TestDirectSourceQueryEndpoint: """Tests for direct-mode source query search on the shared releases API.""" def test_direct_source_query_requires_browse_context(self, protected_api_client: APIClient): """Source query mode requires a query or browse filters.""" resp = protected_api_client.get("/api/releases", params={"source": "direct_download"}) assert resp.status_code in [400, 422] def test_direct_source_query_returns_results(self, protected_api_client: APIClient): """Direct mode uses /api/releases source query mode.""" resp = protected_api_client.get( "/api/releases", params={"source": "direct_download", "query": "Pride Prejudice"}, ) # May return results or 503 if source unavailable if resp.status_code == 200: data = resp.json() assert data.get("sources_searched") == ["direct_download"] assert isinstance(data.get("releases"), list) @pytest.mark.e2e class TestSourceRecordEndpoint: """Tests for source-native record lookup on the shared source-record API.""" def test_source_record_invalid_id(self, protected_api_client: APIClient): """Unknown source records should return a not-found style response.""" resp = protected_api_client.get( "/api/release-sources/direct_download/records/invalid-id-xyz" ) # Should return 404 or error assert resp.status_code in [404, 500, 503]