diff --git a/infrastructure.md b/infrastructure.md index 539609b..0e0edc3 100644 --- a/infrastructure.md +++ b/infrastructure.md @@ -146,9 +146,17 @@ initialization. ### Download Management -- `GET /api/downloads` - Get download queue status -- `DELETE /api/downloads/{id}` - Remove from queue -- `POST /api/downloads/priority` - Change download priority +- `GET /api/queue/status` - Get download queue status and statistics +- `POST /api/queue/add` - Add episodes to download queue +- `DELETE /api/queue/{id}` - Remove item from queue +- `DELETE /api/queue/` - Remove multiple items from queue +- `POST /api/queue/start` - Start download queue processing +- `POST /api/queue/stop` - Stop download queue processing +- `POST /api/queue/pause` - Pause queue processing +- `POST /api/queue/resume` - Resume queue processing +- `POST /api/queue/reorder` - Reorder pending queue items +- `DELETE /api/queue/completed` - Clear completed downloads +- `POST /api/queue/retry` - Retry failed downloads ### Search @@ -378,3 +386,96 @@ Notes: - Singleton instance pattern used via `get_download_service()` factory - Testing: Comprehensive unit tests in `tests/unit/test_download_service.py` cover queue operations, persistence, retry logic, and error handling + +### Download Queue API Endpoints (October 2025) + +Implemented comprehensive REST API endpoints for download queue management: + +- **File**: `src/server/api/download.py` +- **Router Prefix**: `/api/queue` +- **Authentication**: All endpoints require JWT authentication via `require_auth` dependency + +#### Implemented Endpoints + +1. **GET /api/queue/status** - Retrieve complete queue status + + - Returns: `QueueStatusResponse` with status and statistics + - Includes: active downloads, pending items, completed/failed items, queue stats + +2. **POST /api/queue/add** - Add episodes to download queue + + - Request: `DownloadRequest` with serie info, episodes, and priority + - Returns: `DownloadResponse` with added item IDs + - Validates episode list is non-empty + - Supports HIGH, NORMAL, and LOW priority levels + +3. **DELETE /api/queue/{item_id}** - Remove single item from queue + + - Returns: 204 No Content on success, 404 if item not found + - Cancels active downloads if necessary + +4. **DELETE /api/queue/** - Remove multiple items (batch operation) + + - Request: `QueueOperationRequest` with list of item IDs + - Returns: 204 No Content (partial success acceptable) + +5. **POST /api/queue/start** - Start queue processor + + - Idempotent operation (safe to call multiple times) + +6. **POST /api/queue/stop** - Stop queue processor + + - Waits for active downloads to complete (with timeout) + +7. **POST /api/queue/pause** - Pause queue processing + + - Active downloads continue, no new downloads start + +8. **POST /api/queue/resume** - Resume queue processing + +9. **POST /api/queue/reorder** - Reorder pending queue item + + - Request: `QueueReorderRequest` with item_id and new_position + - Returns: 404 if item not in pending queue + +10. **DELETE /api/queue/completed** - Clear completed items from history + + - Returns count of cleared items + +11. **POST /api/queue/retry** - Retry failed downloads + - Request: `QueueOperationRequest` with item IDs (empty for all) + - Only retries items under max retry limit + +#### Dependencies + +- **get_download_service**: Factory function providing singleton DownloadService instance + - Automatically initializes AnimeService as dependency + - Raises 503 if anime directory not configured +- **get_anime_service**: Factory function providing singleton AnimeService instance + - Required by DownloadService for anime operations +- Both dependencies added to `src/server/utils/dependencies.py` + +#### Error Handling + +- All endpoints return structured JSON error responses +- HTTP status codes follow REST conventions (200, 201, 204, 400, 401, 404, 500, 503) +- Service-level exceptions (DownloadServiceError) mapped to 400 Bad Request +- Generic exceptions mapped to 500 Internal Server Error +- Authentication errors return 401 Unauthorized + +#### Testing + +- Comprehensive test suite in `tests/api/test_download_endpoints.py` +- Tests cover: + - Successful operations for all endpoints + - Authentication requirements + - Error conditions (empty lists, not found, service errors) + - Priority handling + - Batch operations +- Uses pytest fixtures for authenticated client and mocked download service + +#### Integration + +- Router registered in `src/server/fastapi_app.py` via `app.include_router(download_router)` +- Follows same patterns as other API routers (auth, anime, config) +- Full OpenAPI documentation available at `/api/docs` diff --git a/instructions.md b/instructions.md index 3615626..23abbea 100644 --- a/instructions.md +++ b/instructions.md @@ -43,17 +43,6 @@ The tasks should be completed in the following order to ensure proper dependenci ## Core Tasks -### 5. Download Queue Management - -#### [] Implement download API endpoints - -- []Create `src/server/api/download.py` -- []Add GET `/api/queue/status` - get queue status -- []Add POST `/api/queue/add` - add to queue -- []Add DELETE `/api/queue/{id}` - remove from queue -- []Add POST `/api/queue/start` - start downloads -- []Add POST `/api/queue/stop` - stop downloads - ### 6. WebSocket Real-time Updates #### [] Implement WebSocket manager diff --git a/src/server/api/download.py b/src/server/api/download.py new file mode 100644 index 0000000..a54b4fe --- /dev/null +++ b/src/server/api/download.py @@ -0,0 +1,474 @@ +"""Download queue API endpoints for Aniworld web application. + +This module provides REST API endpoints for managing the anime download queue, +including adding episodes, removing items, controlling queue processing, and +retrieving queue status and statistics. +""" +from fastapi import APIRouter, Depends, HTTPException, Path, status + +from src.server.models.download import ( + DownloadRequest, + DownloadResponse, + QueueOperationRequest, + QueueReorderRequest, + QueueStatusResponse, +) +from src.server.services.download_service import DownloadService, DownloadServiceError +from src.server.utils.dependencies import get_download_service, require_auth + +router = APIRouter(prefix="/api/queue", tags=["download"]) + + +@router.get("/status", response_model=QueueStatusResponse) +async def get_queue_status( + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Get current download queue status and statistics. + + Returns comprehensive information about all queue items including: + - Active downloads with progress + - Pending items waiting to be processed + - Recently completed downloads + - Failed downloads + + Requires authentication. + + Returns: + QueueStatusResponse: Complete queue status and statistics + + Raises: + HTTPException: 401 if not authenticated, 500 on service error + """ + try: + queue_status = await download_service.get_queue_status() + queue_stats = await download_service.get_queue_stats() + + return QueueStatusResponse(status=queue_status, statistics=queue_stats) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve queue status: {str(e)}", + ) + + +@router.post( + "/add", + response_model=DownloadResponse, + status_code=status.HTTP_201_CREATED, +) +async def add_to_queue( + request: DownloadRequest, + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Add episodes to the download queue. + + Adds one or more episodes to the download queue with specified priority. + Episodes are validated and queued for processing based on priority level: + - HIGH priority items are processed first + - NORMAL and LOW priority items follow FIFO order + + Requires authentication. + + Args: + request: Download request with serie info, episodes, and priority + + Returns: + DownloadResponse: Status and list of created download item IDs + + Raises: + HTTPException: 401 if not authenticated, 400 for invalid request, + 500 on service error + """ + try: + # Validate request + if not request.episodes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="At least one episode must be specified", + ) + + # Add to queue + added_ids = await download_service.add_to_queue( + serie_id=request.serie_id, + serie_name=request.serie_name, + episodes=request.episodes, + priority=request.priority, + ) + + return DownloadResponse( + status="success", + message=f"Added {len(added_ids)} episode(s) to download queue", + added_items=added_ids, + failed_items=[], + ) + + except DownloadServiceError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to add episodes to queue: {str(e)}", + ) + + +@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_from_queue( + item_id: str = Path(..., description="Download item ID to remove"), + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Remove a specific item from the download queue. + + Removes a download item from the queue. If the item is currently + downloading, it will be cancelled and marked as cancelled. If it's + pending, it will simply be removed from the queue. + + Requires authentication. + + Args: + item_id: Unique identifier of the download item to remove + + Raises: + HTTPException: 401 if not authenticated, 404 if item not found, + 500 on service error + """ + try: + removed_ids = await download_service.remove_from_queue([item_id]) + + if not removed_ids: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Download item {item_id} not found in queue", + ) + + except DownloadServiceError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to remove item from queue: {str(e)}", + ) + + +@router.delete("/", status_code=status.HTTP_204_NO_CONTENT) +async def remove_multiple_from_queue( + request: QueueOperationRequest, + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Remove multiple items from the download queue. + + Batch removal of multiple download items. Each item is processed + individually, and the operation continues even if some items are not + found. + + Requires authentication. + + Args: + request: List of download item IDs to remove + + Raises: + HTTPException: 401 if not authenticated, 400 for invalid request, + 500 on service error + """ + try: + if not request.item_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="At least one item ID must be specified", + ) + + await download_service.remove_from_queue(request.item_ids) + + # Note: We don't raise 404 if some items weren't found, as this is + # a batch operation and partial success is acceptable + + except DownloadServiceError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to remove items from queue: {str(e)}", + ) + + +@router.post("/start", status_code=status.HTTP_200_OK) +async def start_queue( + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Start the download queue processor. + + Starts processing the download queue. Downloads will be processed according + to priority and concurrency limits. If the queue is already running, this + operation is idempotent. + + Requires authentication. + + Returns: + dict: Status message indicating queue has been started + + Raises: + HTTPException: 401 if not authenticated, 500 on service error + """ + try: + await download_service.start() + + return { + "status": "success", + "message": "Download queue processing started", + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to start download queue: {str(e)}", + ) + + +@router.post("/stop", status_code=status.HTTP_200_OK) +async def stop_queue( + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Stop the download queue processor. + + Stops processing the download queue. Active downloads will be allowed to + complete (with a timeout), then the queue processor will shut down. + Queue state is persisted before shutdown. + + Requires authentication. + + Returns: + dict: Status message indicating queue has been stopped + + Raises: + HTTPException: 401 if not authenticated, 500 on service error + """ + try: + await download_service.stop() + + return { + "status": "success", + "message": "Download queue processing stopped", + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to stop download queue: {str(e)}", + ) + + +@router.post("/pause", status_code=status.HTTP_200_OK) +async def pause_queue( + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Pause the download queue processor. + + Pauses download processing. Active downloads will continue, but no new + downloads will be started until the queue is resumed. + + Requires authentication. + + Returns: + dict: Status message indicating queue has been paused + + Raises: + HTTPException: 401 if not authenticated, 500 on service error + """ + try: + await download_service.pause_queue() + + return { + "status": "success", + "message": "Download queue paused", + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to pause download queue: {str(e)}", + ) + + +@router.post("/resume", status_code=status.HTTP_200_OK) +async def resume_queue( + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Resume the download queue processor. + + Resumes download processing after being paused. The queue will continue + processing pending items according to priority. + + Requires authentication. + + Returns: + dict: Status message indicating queue has been resumed + + Raises: + HTTPException: 401 if not authenticated, 500 on service error + """ + try: + await download_service.resume_queue() + + return { + "status": "success", + "message": "Download queue resumed", + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to resume download queue: {str(e)}", + ) + + +@router.post("/reorder", status_code=status.HTTP_200_OK) +async def reorder_queue( + request: QueueReorderRequest, + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Reorder an item in the pending queue. + + Changes the position of a pending download item in the queue. This only + affects items that haven't started downloading yet. The position is + 0-based. + + Requires authentication. + + Args: + request: Item ID and new position in queue + + Returns: + dict: Status message indicating item has been reordered + + Raises: + HTTPException: 401 if not authenticated, 404 if item not found, + 400 for invalid request, 500 on service error + """ + try: + success = await download_service.reorder_queue( + item_id=request.item_id, + new_position=request.new_position, + ) + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item {request.item_id} not found in pending queue", + ) + + return { + "status": "success", + "message": "Queue item reordered successfully", + } + + except DownloadServiceError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to reorder queue item: {str(e)}", + ) + + +@router.delete("/completed", status_code=status.HTTP_200_OK) +async def clear_completed( + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Clear completed downloads from history. + + Removes all completed download items from the queue history. This helps + keep the queue display clean and manageable. + + Requires authentication. + + Returns: + dict: Status message with count of cleared items + + Raises: + HTTPException: 401 if not authenticated, 500 on service error + """ + try: + cleared_count = await download_service.clear_completed() + + return { + "status": "success", + "message": f"Cleared {cleared_count} completed item(s)", + "count": cleared_count, + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to clear completed items: {str(e)}", + ) + + +@router.post("/retry", status_code=status.HTTP_200_OK) +async def retry_failed( + request: QueueOperationRequest, + download_service: DownloadService = Depends(get_download_service), + _: dict = Depends(require_auth), +): + """Retry failed downloads. + + Moves failed download items back to the pending queue for retry. Only items + that haven't exceeded the maximum retry count will be retried. + + Requires authentication. + + Args: + request: List of download item IDs to retry (empty list retries all) + + Returns: + dict: Status message with count of retried items + + Raises: + HTTPException: 401 if not authenticated, 500 on service error + """ + try: + # If no specific IDs provided, retry all failed items + item_ids = request.item_ids if request.item_ids else None + + retried_ids = await download_service.retry_failed(item_ids) + + return { + "status": "success", + "message": f"Retrying {len(retried_ids)} failed item(s)", + "retried_ids": retried_ids, + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retry downloads: {str(e)}", + ) diff --git a/src/server/fastapi_app.py b/src/server/fastapi_app.py index ba46675..8e111d5 100644 --- a/src/server/fastapi_app.py +++ b/src/server/fastapi_app.py @@ -18,6 +18,7 @@ from src.config.settings import settings # Import core functionality from src.core.SeriesApp import SeriesApp from src.server.api.auth import router as auth_router +from src.server.api.download import router as download_router from src.server.controllers.error_controller import ( not_found_handler, server_error_handler, @@ -57,6 +58,7 @@ app.add_middleware(AuthMiddleware, rate_limit_per_minute=5) app.include_router(health_router) app.include_router(page_router) app.include_router(auth_router) +app.include_router(download_router) # Global variables for application state series_app: Optional[SeriesApp] = None diff --git a/src/server/utils/dependencies.py b/src/server/utils/dependencies.py index e20f3c5..939e1d3 100644 --- a/src/server/utils/dependencies.py +++ b/src/server/utils/dependencies.py @@ -2,8 +2,8 @@ Dependency injection utilities for FastAPI. This module provides dependency injection functions for the FastAPI -application, including SeriesApp instances, database sessions, and -authentication dependencies. +application, including SeriesApp instances, AnimeService, DownloadService, +database sessions, and authentication dependencies. """ from typing import AsyncGenerator, Optional @@ -26,6 +26,10 @@ security = HTTPBearer() # Global SeriesApp instance _series_app: Optional[SeriesApp] = None +# Global service instances +_anime_service: Optional[object] = None +_download_service: Optional[object] = None + def get_series_app() -> SeriesApp: """ @@ -193,3 +197,79 @@ async def log_request_dependency(): TODO: Implement request logging logic """ pass + + +def get_anime_service() -> object: + """ + Dependency to get AnimeService instance. + + Returns: + AnimeService: The anime service for async operations + + Raises: + HTTPException: If anime directory is not configured or + AnimeService initialization fails + """ + global _anime_service + + if not settings.anime_directory: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Anime directory not configured. Please complete setup.", + ) + + if _anime_service is None: + try: + from src.server.services.anime_service import AnimeService + _anime_service = AnimeService(settings.anime_directory) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to initialize AnimeService: {str(e)}", + ) from e + + return _anime_service + + +def get_download_service() -> object: + """ + Dependency to get DownloadService instance. + + Returns: + DownloadService: The download queue service + + Raises: + HTTPException: If DownloadService initialization fails + """ + global _download_service + + if _download_service is None: + try: + from src.server.services.download_service import DownloadService + + # Get anime service first (required dependency) + anime_service = get_anime_service() + + # Initialize download service with anime service + _download_service = DownloadService(anime_service) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to initialize DownloadService: {str(e)}", + ) from e + + return _download_service + + +def reset_anime_service() -> None: + """Reset global AnimeService instance (for testing/config changes).""" + global _anime_service + _anime_service = None + + +def reset_download_service() -> None: + """Reset global DownloadService instance (for testing/config changes).""" + global _download_service + _download_service = None diff --git a/tests/api/test_download_endpoints.py b/tests/api/test_download_endpoints.py new file mode 100644 index 0000000..ad833dd --- /dev/null +++ b/tests/api/test_download_endpoints.py @@ -0,0 +1,443 @@ +"""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 +async def authenticated_client(): + """Create authenticated async client.""" + # Ensure auth is configured for test + if not auth_service.is_configured(): + auth_service.setup_master_password("TestPass123!") + + 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 + token = r.json()["access_token"] + + # Set authorization header for all requests + client.headers["Authorization"] = f"Bearer {token}" + + yield client + + +@pytest.fixture +def mock_download_service(): + """Mock DownloadService for testing.""" + with patch( + "src.server.utils.dependencies.get_download_service" + ) as mock: + 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"]) + + mock.return_value = service + yield service + + +@pytest.mark.anyio +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.anyio +async def test_get_queue_status_unauthorized(): + """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") + assert response.status_code == 401 + + +@pytest.mark.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +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.anyio +async def test_queue_endpoints_require_auth(): + """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) + + assert response.status_code == 401, ( + f"{method} {url} should require auth" + )