feat: Implement download queue API endpoints

- Add comprehensive REST API for download queue management
- Implement GET /api/queue/status endpoint with queue status and statistics
- Implement POST /api/queue/add for adding episodes to queue with priority support
- Implement DELETE /api/queue/{id} and DELETE /api/queue/ for removing items
- Implement POST /api/queue/start and /api/queue/stop for queue control
- Implement POST /api/queue/pause and /api/queue/resume for pause/resume
- Implement POST /api/queue/reorder for queue item reordering
- Implement DELETE /api/queue/completed for clearing completed items
- Implement POST /api/queue/retry for retrying failed downloads
- Add get_download_service and get_anime_service dependencies
- Register download router in FastAPI application
- Add comprehensive test suite for all endpoints
- All endpoints require JWT authentication
- Update infrastructure documentation
- Remove completed task from instructions.md

Follows REST conventions with proper error handling and status codes.
Tests cover success cases, error conditions, and authentication requirements.
This commit is contained in:
Lukas 2025-10-17 10:29:03 +02:00
parent 028d91283e
commit 577c55f32a
6 changed files with 1105 additions and 16 deletions

View File

@ -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`

View File

@ -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

474
src/server/api/download.py Normal file
View File

@ -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)}",
)

View File

@ -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

View File

@ -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

View File

@ -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"
)