"""Tests for download queue API endpoints.""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from httpx import ASGITransport, AsyncClient from src.server.fastapi_app import app from src.server.models.download import DownloadPriority, QueueStats, QueueStatus from src.server.services.auth_service import auth_service from src.server.services.download_service import DownloadServiceError @pytest.fixture(autouse=True) def reset_auth_state(): """Reset auth service state before each test.""" # Clear any rate limiting state if hasattr(auth_service, '_failed'): auth_service._failed.clear() yield # Cleanup after test if hasattr(auth_service, '_failed'): auth_service._failed.clear() @pytest.fixture async def authenticated_client(mock_download_service): """Create authenticated async client.""" # Ensure auth is configured for test if not auth_service.is_configured(): auth_service.setup_master_password("TestPass123!") # Override the dependency with our mock from src.server.utils.dependencies import get_download_service app.dependency_overrides[get_download_service] = ( lambda: mock_download_service ) transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test" ) as client: # Login to get token r = await client.post( "/api/auth/login", json={"password": "TestPass123!"} ) assert r.status_code == 200, f"Login failed: {r.status_code} {r.text}" token = r.json()["access_token"] # Set authorization header for all requests client.headers["Authorization"] = f"Bearer {token}" yield client # Clean up dependency override app.dependency_overrides.clear() @pytest.fixture def mock_download_service(): """Mock DownloadService for testing.""" service = MagicMock() # Mock queue status service.get_queue_status = AsyncMock( return_value=QueueStatus( is_running=True, is_paused=False, active_downloads=[], pending_queue=[], completed_downloads=[], failed_downloads=[], ) ) # Mock queue stats service.get_queue_stats = AsyncMock( return_value=QueueStats( total_items=0, pending_count=0, active_count=0, completed_count=0, failed_count=0, total_downloaded_mb=0.0, ) ) # Mock add_to_queue service.add_to_queue = AsyncMock( return_value=["item-id-1", "item-id-2"] ) # Mock remove_from_queue service.remove_from_queue = AsyncMock(return_value=["item-id-1"]) # Mock reorder_queue service.reorder_queue = AsyncMock(return_value=True) # Mock start/stop/pause/resume service.start = AsyncMock() service.stop = AsyncMock() service.pause_queue = AsyncMock() service.resume_queue = AsyncMock() # Mock clear_completed and retry_failed service.clear_completed = AsyncMock(return_value=5) service.retry_failed = AsyncMock(return_value=["item-id-3"]) return service @pytest.mark.asyncio async def test_get_queue_status(authenticated_client, mock_download_service): """Test GET /api/queue/status endpoint.""" response = await authenticated_client.get("/api/queue/status") assert response.status_code == 200 data = response.json() assert "status" in data assert "statistics" in data assert data["status"]["is_running"] is True assert data["status"]["is_paused"] is False mock_download_service.get_queue_status.assert_called_once() mock_download_service.get_queue_stats.assert_called_once() @pytest.mark.asyncio async def test_get_queue_status_unauthorized(mock_download_service): """Test GET /api/queue/status without authentication.""" transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test" ) as client: response = await client.get("/api/queue/status") # Should return 401 or 503 (503 if service not available) assert response.status_code in (401, 503) @pytest.mark.asyncio async def test_add_to_queue(authenticated_client, mock_download_service): """Test POST /api/queue/add endpoint.""" request_data = { "serie_id": "series-1", "serie_name": "Test Anime", "episodes": [ {"season": 1, "episode": 1}, {"season": 1, "episode": 2}, ], "priority": "normal", } response = await authenticated_client.post( "/api/queue/add", json=request_data ) assert response.status_code == 201 data = response.json() assert data["status"] == "success" assert len(data["added_items"]) == 2 assert data["added_items"] == ["item-id-1", "item-id-2"] mock_download_service.add_to_queue.assert_called_once() @pytest.mark.asyncio async def test_add_to_queue_with_high_priority( authenticated_client, mock_download_service ): """Test adding items with HIGH priority.""" request_data = { "serie_id": "series-1", "serie_name": "Test Anime", "episodes": [{"season": 1, "episode": 1}], "priority": "high", } response = await authenticated_client.post( "/api/queue/add", json=request_data ) assert response.status_code == 201 # Verify priority was passed correctly call_args = mock_download_service.add_to_queue.call_args assert call_args[1]["priority"] == DownloadPriority.HIGH @pytest.mark.asyncio async def test_add_to_queue_empty_episodes( authenticated_client, mock_download_service ): """Test adding empty episodes list returns 400.""" request_data = { "serie_id": "series-1", "serie_name": "Test Anime", "episodes": [], "priority": "normal", } response = await authenticated_client.post( "/api/queue/add", json=request_data ) assert response.status_code == 400 @pytest.mark.asyncio async def test_add_to_queue_service_error( authenticated_client, mock_download_service ): """Test adding to queue when service raises error.""" mock_download_service.add_to_queue.side_effect = DownloadServiceError( "Queue full" ) request_data = { "serie_id": "series-1", "serie_name": "Test Anime", "episodes": [{"season": 1, "episode": 1}], "priority": "normal", } response = await authenticated_client.post( "/api/queue/add", json=request_data ) assert response.status_code == 400 assert "Queue full" in response.json()["detail"] @pytest.mark.asyncio async def test_remove_from_queue_single( authenticated_client, mock_download_service ): """Test DELETE /api/queue/{item_id} endpoint.""" response = await authenticated_client.delete("/api/queue/item-id-1") assert response.status_code == 204 mock_download_service.remove_from_queue.assert_called_once_with( ["item-id-1"] ) @pytest.mark.asyncio async def test_remove_from_queue_not_found( authenticated_client, mock_download_service ): """Test removing non-existent item returns 404.""" mock_download_service.remove_from_queue.return_value = [] response = await authenticated_client.delete( "/api/queue/non-existent-id" ) assert response.status_code == 404 @pytest.mark.asyncio async def test_remove_multiple_from_queue( authenticated_client, mock_download_service ): """Test DELETE /api/queue/ with multiple items.""" request_data = {"item_ids": ["item-id-1", "item-id-2"]} response = await authenticated_client.request( "DELETE", "/api/queue/", json=request_data ) assert response.status_code == 204 mock_download_service.remove_from_queue.assert_called_once_with( ["item-id-1", "item-id-2"] ) @pytest.mark.asyncio async def test_remove_multiple_empty_list( authenticated_client, mock_download_service ): """Test removing with empty item list returns 400.""" request_data = {"item_ids": []} response = await authenticated_client.request( "DELETE", "/api/queue/", json=request_data ) assert response.status_code == 400 @pytest.mark.asyncio async def test_start_queue(authenticated_client, mock_download_service): """Test POST /api/queue/start endpoint.""" response = await authenticated_client.post("/api/queue/start") assert response.status_code == 200 data = response.json() assert data["status"] == "success" assert "started" in data["message"].lower() mock_download_service.start.assert_called_once() @pytest.mark.asyncio async def test_stop_queue(authenticated_client, mock_download_service): """Test POST /api/queue/stop endpoint.""" response = await authenticated_client.post("/api/queue/stop") assert response.status_code == 200 data = response.json() assert data["status"] == "success" assert "stopped" in data["message"].lower() mock_download_service.stop.assert_called_once() @pytest.mark.asyncio async def test_pause_queue(authenticated_client, mock_download_service): """Test POST /api/queue/pause endpoint.""" response = await authenticated_client.post("/api/queue/pause") assert response.status_code == 200 data = response.json() assert data["status"] == "success" assert "paused" in data["message"].lower() mock_download_service.pause_queue.assert_called_once() @pytest.mark.asyncio async def test_resume_queue(authenticated_client, mock_download_service): """Test POST /api/queue/resume endpoint.""" response = await authenticated_client.post("/api/queue/resume") assert response.status_code == 200 data = response.json() assert data["status"] == "success" assert "resumed" in data["message"].lower() mock_download_service.resume_queue.assert_called_once() @pytest.mark.asyncio async def test_reorder_queue(authenticated_client, mock_download_service): """Test POST /api/queue/reorder endpoint.""" request_data = {"item_id": "item-id-1", "new_position": 0} response = await authenticated_client.post( "/api/queue/reorder", json=request_data ) assert response.status_code == 200 data = response.json() assert data["status"] == "success" mock_download_service.reorder_queue.assert_called_once_with( item_id="item-id-1", new_position=0 ) @pytest.mark.asyncio async def test_reorder_queue_not_found( authenticated_client, mock_download_service ): """Test reordering non-existent item returns 404.""" mock_download_service.reorder_queue.return_value = False request_data = {"item_id": "non-existent", "new_position": 0} response = await authenticated_client.post( "/api/queue/reorder", json=request_data ) assert response.status_code == 404 @pytest.mark.asyncio async def test_clear_completed(authenticated_client, mock_download_service): """Test DELETE /api/queue/completed endpoint.""" response = await authenticated_client.delete("/api/queue/completed") assert response.status_code == 200 data = response.json() assert data["status"] == "success" assert data["count"] == 5 mock_download_service.clear_completed.assert_called_once() @pytest.mark.asyncio async def test_retry_failed(authenticated_client, mock_download_service): """Test POST /api/queue/retry endpoint.""" request_data = {"item_ids": ["item-id-3"]} response = await authenticated_client.post( "/api/queue/retry", json=request_data ) assert response.status_code == 200 data = response.json() assert data["status"] == "success" assert len(data["retried_ids"]) == 1 mock_download_service.retry_failed.assert_called_once_with( ["item-id-3"] ) @pytest.mark.asyncio async def test_retry_all_failed(authenticated_client, mock_download_service): """Test retrying all failed items with empty list.""" request_data = {"item_ids": []} response = await authenticated_client.post( "/api/queue/retry", json=request_data ) assert response.status_code == 200 # Should call retry_failed with None to retry all mock_download_service.retry_failed.assert_called_once_with(None) @pytest.mark.asyncio async def test_queue_endpoints_require_auth(mock_download_service): """Test that all queue endpoints require authentication.""" transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test" ) as client: # Test all endpoints without auth endpoints = [ ("GET", "/api/queue/status"), ("POST", "/api/queue/add"), ("DELETE", "/api/queue/item-1"), ("POST", "/api/queue/start"), ("POST", "/api/queue/stop"), ("POST", "/api/queue/pause"), ("POST", "/api/queue/resume"), ] for method, url in endpoints: if method == "GET": response = await client.get(url) elif method == "POST": response = await client.post(url, json={}) elif method == "DELETE": response = await client.delete(url) # Should return 401 or 503 (503 if service not available) assert response.status_code in (401, 503), ( f"{method} {url} should require auth, got {response.status_code}" )