"""Integration tests for complete download flow. This module tests the end-to-end download flow including: - Adding episodes to the queue - Queue status updates - Download processing - Progress tracking - Queue control operations (pause, resume, clear) - Error handling and retries - WebSocket notifications """ import asyncio from datetime import datetime from pathlib import Path from typing import Any, Dict, List from unittest.mock import AsyncMock, Mock, patch import pytest from httpx import ASGITransport, AsyncClient from src.server.fastapi_app import app from src.server.models.download import ( DownloadPriority, DownloadStatus, EpisodeIdentifier, ) from src.server.services.anime_service import AnimeService from src.server.services.auth_service import auth_service from src.server.services.download_service import DownloadService from src.server.services.progress_service import get_progress_service from src.server.services.websocket_service import get_websocket_service @pytest.fixture(autouse=True) def reset_auth(): """Reset authentication state before each test.""" original_hash = auth_service._hash auth_service._hash = None auth_service._failed.clear() yield auth_service._hash = original_hash auth_service._failed.clear() @pytest.fixture async def client(): """Create an async test client.""" transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac @pytest.fixture async def authenticated_client(client): """Create an authenticated test client with token.""" # Setup master password await client.post( "/api/auth/setup", json={"master_password": "TestPassword123!"} ) # Login to get token response = await client.post( "/api/auth/login", json={"password": "TestPassword123!"} ) token = response.json()["access_token"] # Add token to default headers client.headers.update({"Authorization": f"Bearer {token}"}) yield client @pytest.fixture def mock_series_app(): """Mock SeriesApp for testing.""" app_mock = Mock() app_mock.series_list = [] app_mock.search = Mock(return_value=[]) app_mock.ReScan = Mock() app_mock.download = Mock(return_value=True) return app_mock @pytest.fixture def mock_anime_service(mock_series_app, tmp_path): """Create a mock AnimeService.""" # Create a temporary directory for the service test_dir = tmp_path / "anime" test_dir.mkdir() with patch( "src.server.services.anime_service.SeriesApp", return_value=mock_series_app ): service = AnimeService(directory=str(test_dir)) service.download = AsyncMock(return_value=True) yield service @pytest.fixture def temp_queue_file(tmp_path): """Create a temporary queue persistence file.""" return str(tmp_path / "test_queue.json") class TestDownloadFlowEndToEnd: """Test complete download flow from queue addition to completion.""" async def test_add_episodes_to_queue(self, authenticated_client, mock_anime_service): """Test adding episodes to the download queue.""" # Add episodes to queue response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": "test-series-1", "serie_name": "Test Anime Series", "episodes": [ {"season": 1, "episode": 1, "title": "Episode 1"}, {"season": 1, "episode": 2, "title": "Episode 2"}, ], "priority": "normal" } ) assert response.status_code == 201 data = response.json() # Verify response structure assert data["status"] == "success" assert "item_ids" in data assert len(data["item_ids"]) == 2 assert "message" in data async def test_queue_status_after_adding_items(self, authenticated_client): """Test retrieving queue status after adding items.""" # Add episodes to queue await authenticated_client.post( "/api/queue/add", json={ "serie_id": "test-series-2", "serie_name": "Another Series", "episodes": [{"season": 1, "episode": 1}], "priority": "high" } ) # Get queue status response = await authenticated_client.get("/api/queue/status") assert response.status_code in [200, 503] if response.status_code == 200: data = response.json() # Verify status structure (updated for new response format) assert "is_running" in data assert "is_paused" in data assert "pending_queue" in data assert "active_downloads" in data assert "completed_downloads" in data assert "failed_downloads" in data assert "statistics" in data async def test_add_with_different_priorities(self, authenticated_client): """Test adding episodes with different priority levels.""" priorities = ["high", "normal", "low"] for priority in priorities: response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": f"series-{priority}", "serie_name": f"Series {priority.title()}", "episodes": [{"season": 1, "episode": 1}], "priority": priority } ) assert response.status_code in [201, 503] async def test_validation_error_for_empty_episodes(self, authenticated_client): """Test validation error when no episodes are specified.""" response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": "test-series", "serie_name": "Test Series", "episodes": [], "priority": "normal" } ) assert response.status_code == 400 data = response.json() assert "detail" in data async def test_validation_error_for_invalid_priority(self, authenticated_client): """Test validation error for invalid priority level.""" response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": "test-series", "serie_name": "Test Series", "episodes": [{"season": 1, "episode": 1}], "priority": "invalid" } ) assert response.status_code == 422 # Validation error class TestQueueControlOperations: """Test queue control operations (start, pause, resume, clear).""" async def test_start_queue_processing(self, authenticated_client): """Test starting the queue processor.""" response = await authenticated_client.post("/api/queue/start") assert response.status_code in [200, 503] if response.status_code == 200: data = response.json() assert data["status"] == "success" async def test_clear_completed_downloads(self, authenticated_client): """Test clearing completed downloads from the queue.""" response = await authenticated_client.delete("/api/queue/completed") assert response.status_code in [200, 503] if response.status_code == 200: data = response.json() assert data["status"] == "success" class TestQueueItemOperations: """Test operations on individual queue items.""" async def test_remove_item_from_queue(self, authenticated_client): """Test removing a specific item from the queue.""" # First add an item add_response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": "test-series", "serie_name": "Test Series", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) if add_response.status_code == 201: item_id = add_response.json()["item_ids"][0] # Remove the item response = await authenticated_client.delete(f"/api/queue/items/{item_id}") assert response.status_code in [200, 404, 503] async def test_retry_failed_item(self, authenticated_client): """Test retrying a failed download item.""" # This would typically require a failed item to exist # For now, test the endpoint with a dummy ID response = await authenticated_client.post("/api/queue/items/dummy-id/retry") # Should return 404 if item doesn't exist, or 503 if unavailable assert response.status_code in [200, 404, 503] class TestDownloadProgressTracking: """Test progress tracking during downloads.""" async def test_queue_status_includes_progress(self, authenticated_client): """Test that queue status includes progress information.""" # Add an item await authenticated_client.post( "/api/queue/add", json={ "serie_id": "test-series", "serie_name": "Test Series", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) # Get status response = await authenticated_client.get("/api/queue/status") assert response.status_code in [200, 503] if response.status_code == 200: data = response.json() # Updated for new response format assert "active_downloads" in data # Check that items can have progress for item in data.get("active_downloads", []): if "progress" in item and item["progress"]: assert "percentage" in item["progress"] assert "current_mb" in item["progress"] assert "total_mb" in item["progress"] async def test_queue_statistics(self, authenticated_client): """Test that queue statistics are calculated correctly.""" response = await authenticated_client.get("/api/queue/status") assert response.status_code in [200, 503] if response.status_code == 200: data = response.json() assert "statistics" in data stats = data["statistics"] assert "total_items" in stats assert "pending_count" in stats assert "active_count" in stats assert "completed_count" in stats assert "failed_count" in stats assert "success_rate" in stats class TestErrorHandlingAndRetries: """Test error handling and retry mechanisms.""" async def test_handle_download_failure(self, authenticated_client): """Test handling of download failures.""" # This would require mocking a failure scenario # For integration testing, we verify the error handling structure # Add an item that might fail response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": "invalid-series", "serie_name": "Invalid Series", "episodes": [{"season": 99, "episode": 99}], "priority": "normal" } ) # The system should handle the request gracefully assert response.status_code in [201, 400, 503] async def test_retry_count_increments(self, authenticated_client): """Test that retry count increments on failures.""" # Add a potentially failing item add_response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": "test-series", "serie_name": "Test Series", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) if add_response.status_code == 201: # Get queue status to check retry count status_response = await authenticated_client.get( "/api/queue/status" ) if status_response.status_code == 200: data = status_response.json() # Verify structure includes retry_count field # Updated to match new response structure for item_list in [ data.get("pending_queue", []), data.get("failed_downloads", []) ]: for item in item_list: assert "retry_count" in item class TestAuthenticationRequirements: """Test that download endpoints require authentication.""" async def test_queue_status_requires_auth(self, client): """Test that queue status endpoint requires authentication.""" response = await client.get("/api/queue/status") assert response.status_code == 401 async def test_add_to_queue_requires_auth(self, client): """Test that add to queue endpoint requires authentication.""" response = await client.post( "/api/queue/add", json={ "serie_id": "test-series", "serie_name": "Test Series", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) assert response.status_code == 401 async def test_queue_control_requires_auth(self, client): """Test that queue control endpoints require authentication.""" response = await client.post("/api/queue/start") assert response.status_code == 401 async def test_item_operations_require_auth(self, client): """Test that item operations require authentication.""" response = await client.delete("/api/queue/items/dummy-id") # 404 is acceptable - endpoint exists but item doesn't # 401 is also acceptable - auth was checked before routing assert response.status_code in [401, 404] class TestConcurrentOperations: """Test concurrent download operations.""" async def test_multiple_concurrent_downloads(self, authenticated_client): """Test handling multiple concurrent download requests.""" # Add multiple items concurrently tasks = [] for i in range(5): task = authenticated_client.post( "/api/queue/add", json={ "serie_id": f"series-{i}", "serie_name": f"Series {i}", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) tasks.append(task) # Wait for all requests to complete responses = await asyncio.gather(*tasks, return_exceptions=True) # Verify all requests were handled for response in responses: if not isinstance(response, Exception): assert response.status_code in [201, 503] async def test_concurrent_status_requests(self, authenticated_client): """Test handling concurrent status requests.""" # Make multiple concurrent status requests tasks = [ authenticated_client.get("/api/queue/status") for _ in range(10) ] responses = await asyncio.gather(*tasks, return_exceptions=True) # Verify all requests were handled for response in responses: if not isinstance(response, Exception): assert response.status_code in [200, 503] class TestQueuePersistence: """Test queue state persistence.""" async def test_queue_survives_restart(self, authenticated_client, temp_queue_file): """Test that queue state persists across service restarts.""" # This would require actually restarting the service # For integration testing, we verify the persistence mechanism exists # Add items to queue response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": "persistent-series", "serie_name": "Persistent Series", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) # Verify the request was processed assert response.status_code in [201, 503] # In a full integration test, we would restart the service here # and verify the queue state is restored async def test_failed_items_are_persisted(self, authenticated_client): """Test that failed items are persisted.""" # Get initial queue state initial_response = await authenticated_client.get("/api/queue/status") assert initial_response.status_code in [200, 503] # The persistence mechanism should handle failed items # In a real scenario, we would trigger a failure and verify persistence class TestWebSocketIntegrationWithDownloads: """Test WebSocket notifications during download operations.""" async def test_websocket_notifies_on_queue_changes(self, authenticated_client): """Test that WebSocket broadcasts queue changes.""" # This is a basic integration test # Full WebSocket testing is in test_websocket.py # Add an item to trigger potential WebSocket notification response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": "ws-series", "serie_name": "WebSocket Series", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) # Verify the operation succeeded assert response.status_code in [201, 503] # In a full test, we would verify WebSocket clients received notifications class TestCompleteDownloadWorkflow: """Test complete end-to-end download workflow.""" async def test_full_download_cycle(self, authenticated_client): """Test complete download cycle from add to completion.""" # 1. Add episode to queue add_response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": "workflow-series", "serie_name": "Workflow Test Series", "episodes": [{"season": 1, "episode": 1}], "priority": "high" } ) assert add_response.status_code in [201, 503] if add_response.status_code == 201: item_id = add_response.json()["item_ids"][0] # 2. Verify item is in queue status_response = await authenticated_client.get("/api/queue/status") assert status_response.status_code in [200, 503] # 3. Start queue processing start_response = await authenticated_client.post("/api/queue/control/start") assert start_response.status_code in [200, 503] # 4. Check status during processing await asyncio.sleep(0.1) # Brief delay progress_response = await authenticated_client.get("/api/queue/status") assert progress_response.status_code in [200, 503] # 5. Verify final state (completed or still processing) final_response = await authenticated_client.get( "/api/queue/status" ) assert final_response.status_code in [200, 503]