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:
parent
028d91283e
commit
577c55f32a
@ -146,9 +146,17 @@ initialization.
|
|||||||
|
|
||||||
### Download Management
|
### Download Management
|
||||||
|
|
||||||
- `GET /api/downloads` - Get download queue status
|
- `GET /api/queue/status` - Get download queue status and statistics
|
||||||
- `DELETE /api/downloads/{id}` - Remove from queue
|
- `POST /api/queue/add` - Add episodes to download queue
|
||||||
- `POST /api/downloads/priority` - Change download priority
|
- `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
|
### Search
|
||||||
|
|
||||||
@ -378,3 +386,96 @@ Notes:
|
|||||||
- Singleton instance pattern used via `get_download_service()` factory
|
- Singleton instance pattern used via `get_download_service()` factory
|
||||||
- Testing: Comprehensive unit tests in `tests/unit/test_download_service.py`
|
- Testing: Comprehensive unit tests in `tests/unit/test_download_service.py`
|
||||||
cover queue operations, persistence, retry logic, and error handling
|
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`
|
||||||
|
|||||||
@ -43,17 +43,6 @@ The tasks should be completed in the following order to ensure proper dependenci
|
|||||||
|
|
||||||
## Core Tasks
|
## 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
|
### 6. WebSocket Real-time Updates
|
||||||
|
|
||||||
#### [] Implement WebSocket manager
|
#### [] Implement WebSocket manager
|
||||||
|
|||||||
474
src/server/api/download.py
Normal file
474
src/server/api/download.py
Normal 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)}",
|
||||||
|
)
|
||||||
@ -18,6 +18,7 @@ from src.config.settings import settings
|
|||||||
# Import core functionality
|
# Import core functionality
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.core.SeriesApp import SeriesApp
|
||||||
from src.server.api.auth import router as auth_router
|
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 (
|
from src.server.controllers.error_controller import (
|
||||||
not_found_handler,
|
not_found_handler,
|
||||||
server_error_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(health_router)
|
||||||
app.include_router(page_router)
|
app.include_router(page_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
app.include_router(download_router)
|
||||||
|
|
||||||
# Global variables for application state
|
# Global variables for application state
|
||||||
series_app: Optional[SeriesApp] = None
|
series_app: Optional[SeriesApp] = None
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
Dependency injection utilities for FastAPI.
|
Dependency injection utilities for FastAPI.
|
||||||
|
|
||||||
This module provides dependency injection functions for the FastAPI
|
This module provides dependency injection functions for the FastAPI
|
||||||
application, including SeriesApp instances, database sessions, and
|
application, including SeriesApp instances, AnimeService, DownloadService,
|
||||||
authentication dependencies.
|
database sessions, and authentication dependencies.
|
||||||
"""
|
"""
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
@ -26,6 +26,10 @@ security = HTTPBearer()
|
|||||||
# Global SeriesApp instance
|
# Global SeriesApp instance
|
||||||
_series_app: Optional[SeriesApp] = None
|
_series_app: Optional[SeriesApp] = None
|
||||||
|
|
||||||
|
# Global service instances
|
||||||
|
_anime_service: Optional[object] = None
|
||||||
|
_download_service: Optional[object] = None
|
||||||
|
|
||||||
|
|
||||||
def get_series_app() -> SeriesApp:
|
def get_series_app() -> SeriesApp:
|
||||||
"""
|
"""
|
||||||
@ -193,3 +197,79 @@ async def log_request_dependency():
|
|||||||
TODO: Implement request logging logic
|
TODO: Implement request logging logic
|
||||||
"""
|
"""
|
||||||
pass
|
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
|
||||||
|
|||||||
443
tests/api/test_download_endpoints.py
Normal file
443
tests/api/test_download_endpoints.py
Normal 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"
|
||||||
|
)
|
||||||
Loading…
x
Reference in New Issue
Block a user