- 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.
444 lines
13 KiB
Python
444 lines
13 KiB
Python
"""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"
|
|
)
|