- Updated DownloadRequest and DownloadItem models with comprehensive docstrings explaining serie_id (key as primary identifier) vs serie_folder (filesystem metadata) - Updated add_to_queue() endpoint docstring to document request parameters - Updated all test files to include required serie_folder field: - tests/api/test_download_endpoints.py - tests/api/test_queue_features.py - tests/frontend/test_existing_ui_integration.py - tests/integration/test_download_flow.py - Updated infrastructure.md with Download Queue request/response models - All 869 tests pass This is part of the Series Identifier Standardization effort (Phase 4.2) to ensure key is used as the primary identifier throughout the codebase.
426 lines
13 KiB
Python
426 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(autouse=True)
|
|
def reset_auth_state():
|
|
"""Reset auth service state before each test."""
|
|
# Clear any rate limiting state
|
|
if hasattr(auth_service, '_failed'):
|
|
auth_service._failed.clear()
|
|
yield
|
|
# Cleanup after test
|
|
if hasattr(auth_service, '_failed'):
|
|
auth_service._failed.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
async def authenticated_client(mock_download_service):
|
|
"""Create authenticated async client."""
|
|
# Ensure auth is configured for test
|
|
if not auth_service.is_configured():
|
|
auth_service.setup_master_password("TestPass123!")
|
|
|
|
# Override the dependency with our mock
|
|
from src.server.utils.dependencies import get_download_service
|
|
app.dependency_overrides[get_download_service] = (
|
|
lambda: mock_download_service
|
|
)
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(
|
|
transport=transport, base_url="http://test"
|
|
) as client:
|
|
# Login to get token
|
|
r = await client.post(
|
|
"/api/auth/login", json={"password": "TestPass123!"}
|
|
)
|
|
assert r.status_code == 200, f"Login failed: {r.status_code} {r.text}"
|
|
token = r.json()["access_token"]
|
|
|
|
# Set authorization header for all requests
|
|
client.headers["Authorization"] = f"Bearer {token}"
|
|
|
|
yield client
|
|
|
|
# Clean up dependency override
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_download_service():
|
|
"""Mock DownloadService for testing."""
|
|
service = MagicMock()
|
|
|
|
# Mock queue status
|
|
service.get_queue_status = AsyncMock(
|
|
return_value=QueueStatus(
|
|
is_running=True,
|
|
is_paused=False,
|
|
active_downloads=[],
|
|
pending_queue=[],
|
|
completed_downloads=[],
|
|
failed_downloads=[],
|
|
)
|
|
)
|
|
|
|
# Mock queue stats
|
|
service.get_queue_stats = AsyncMock(
|
|
return_value=QueueStats(
|
|
total_items=0,
|
|
pending_count=0,
|
|
active_count=0,
|
|
completed_count=0,
|
|
failed_count=0,
|
|
total_downloaded_mb=0.0,
|
|
)
|
|
)
|
|
|
|
# Mock add_to_queue
|
|
service.add_to_queue = AsyncMock(
|
|
return_value=["item-id-1", "item-id-2"]
|
|
)
|
|
|
|
# Mock remove_from_queue
|
|
service.remove_from_queue = AsyncMock(return_value=["item-id-1"])
|
|
|
|
# Mock start/stop - start_queue_processing returns True on success
|
|
service.start_queue_processing = AsyncMock(return_value=True)
|
|
service.stop = AsyncMock()
|
|
service.stop_downloads = AsyncMock()
|
|
|
|
# Mock clear_completed and retry_failed
|
|
service.clear_completed = AsyncMock(return_value=5)
|
|
service.retry_failed = AsyncMock(return_value=["item-id-3"])
|
|
|
|
return service
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_queue_status(authenticated_client, mock_download_service):
|
|
"""Test GET /api/queue/status endpoint."""
|
|
response = await authenticated_client.get("/api/queue/status")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Updated to match new response structure with nested status
|
|
assert "status" in data
|
|
assert "statistics" in data
|
|
|
|
status_data = data["status"]
|
|
assert "is_running" in status_data
|
|
assert "is_paused" in status_data
|
|
assert "active_downloads" in status_data
|
|
assert "pending_queue" in status_data
|
|
assert "completed_downloads" in status_data
|
|
assert "failed_downloads" in status_data
|
|
assert status_data["is_running"] is True
|
|
assert status_data["is_paused"] is False
|
|
|
|
mock_download_service.get_queue_status.assert_called_once()
|
|
mock_download_service.get_queue_stats.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_queue_status_unauthorized(mock_download_service):
|
|
"""Test GET /api/queue/status without authentication."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(
|
|
transport=transport, base_url="http://test"
|
|
) as client:
|
|
response = await client.get("/api/queue/status")
|
|
# Should return 401 or 503 (503 if service not available)
|
|
assert response.status_code in (401, 503)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_to_queue(authenticated_client, mock_download_service):
|
|
"""Test POST /api/queue/add endpoint."""
|
|
request_data = {
|
|
"serie_id": "test-anime",
|
|
"serie_folder": "Test Anime (2024)",
|
|
"serie_name": "Test Anime",
|
|
"episodes": [
|
|
{"season": 1, "episode": 1},
|
|
{"season": 1, "episode": 2},
|
|
],
|
|
"priority": "normal",
|
|
}
|
|
|
|
response = await authenticated_client.post(
|
|
"/api/queue/add", json=request_data
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
|
|
assert data["status"] == "success"
|
|
assert len(data["added_items"]) == 2
|
|
assert data["added_items"] == ["item-id-1", "item-id-2"]
|
|
|
|
mock_download_service.add_to_queue.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_to_queue_with_high_priority(
|
|
authenticated_client, mock_download_service
|
|
):
|
|
"""Test adding items with HIGH priority."""
|
|
request_data = {
|
|
"serie_id": "test-anime",
|
|
"serie_folder": "Test Anime (2024)",
|
|
"serie_name": "Test Anime",
|
|
"episodes": [{"season": 1, "episode": 1}],
|
|
"priority": "high",
|
|
}
|
|
|
|
response = await authenticated_client.post(
|
|
"/api/queue/add", json=request_data
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
|
|
# Verify priority was passed correctly
|
|
call_args = mock_download_service.add_to_queue.call_args
|
|
assert call_args[1]["priority"] == DownloadPriority.HIGH
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_to_queue_empty_episodes(
|
|
authenticated_client, mock_download_service
|
|
):
|
|
"""Test adding empty episodes list returns 400."""
|
|
request_data = {
|
|
"serie_id": "test-anime",
|
|
"serie_folder": "Test Anime (2024)",
|
|
"serie_name": "Test Anime",
|
|
"episodes": [],
|
|
"priority": "normal",
|
|
}
|
|
|
|
response = await authenticated_client.post(
|
|
"/api/queue/add", json=request_data
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_to_queue_service_error(
|
|
authenticated_client, mock_download_service
|
|
):
|
|
"""Test adding to queue when service raises error."""
|
|
mock_download_service.add_to_queue.side_effect = DownloadServiceError(
|
|
"Queue full"
|
|
)
|
|
|
|
request_data = {
|
|
"serie_id": "test-anime",
|
|
"serie_folder": "Test Anime (2024)",
|
|
"serie_name": "Test Anime",
|
|
"episodes": [{"season": 1, "episode": 1}],
|
|
"priority": "normal",
|
|
}
|
|
|
|
response = await authenticated_client.post(
|
|
"/api/queue/add", json=request_data
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "Queue full" in response.json()["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_from_queue_single(
|
|
authenticated_client, mock_download_service
|
|
):
|
|
"""Test DELETE /api/queue/{item_id} endpoint."""
|
|
response = await authenticated_client.delete("/api/queue/item-id-1")
|
|
|
|
assert response.status_code == 204
|
|
|
|
mock_download_service.remove_from_queue.assert_called_once_with(
|
|
["item-id-1"]
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_from_queue_not_found(
|
|
authenticated_client, mock_download_service
|
|
):
|
|
"""Test removing non-existent item returns 404."""
|
|
mock_download_service.remove_from_queue.return_value = []
|
|
|
|
response = await authenticated_client.delete(
|
|
"/api/queue/non-existent-id"
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_download_success(
|
|
authenticated_client, mock_download_service
|
|
):
|
|
"""Test POST /api/queue/start starts queue processing."""
|
|
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_queue_processing.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_download_empty_queue(
|
|
authenticated_client, mock_download_service
|
|
):
|
|
"""Test starting download with empty queue returns 400."""
|
|
mock_download_service.start_queue_processing.return_value = None
|
|
|
|
response = await authenticated_client.post("/api/queue/start")
|
|
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
detail = data["detail"].lower()
|
|
assert "empty" in detail or "no pending" in detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_download_already_active(
|
|
authenticated_client, mock_download_service
|
|
):
|
|
"""Test starting download while one is active returns 400."""
|
|
mock_download_service.start_queue_processing.side_effect = (
|
|
DownloadServiceError("A download is already in progress")
|
|
)
|
|
|
|
response = await authenticated_client.post("/api/queue/start")
|
|
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
detail_lower = data["detail"].lower()
|
|
assert "already" in detail_lower or "progress" in detail_lower
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_downloads(authenticated_client, mock_download_service):
|
|
"""Test POST /api/queue/stop stops queue processing."""
|
|
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_downloads.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_completed(authenticated_client, mock_download_service):
|
|
"""Test DELETE /api/queue/completed endpoint."""
|
|
response = await authenticated_client.delete("/api/queue/completed")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["status"] == "success"
|
|
assert data["count"] == 5
|
|
|
|
mock_download_service.clear_completed.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_pending(authenticated_client, mock_download_service):
|
|
"""Test DELETE /api/queue/pending endpoint."""
|
|
mock_download_service.clear_pending = AsyncMock(return_value=3)
|
|
|
|
response = await authenticated_client.delete("/api/queue/pending")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["status"] == "success"
|
|
assert data["count"] == 3
|
|
|
|
mock_download_service.clear_pending.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_failed(authenticated_client, mock_download_service):
|
|
"""Test POST /api/queue/retry endpoint."""
|
|
request_data = {"item_ids": ["item-id-3"]}
|
|
|
|
response = await authenticated_client.post(
|
|
"/api/queue/retry", json=request_data
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["status"] == "success"
|
|
assert len(data["retried_ids"]) == 1
|
|
|
|
mock_download_service.retry_failed.assert_called_once_with(
|
|
["item-id-3"]
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_all_failed(authenticated_client, mock_download_service):
|
|
"""Test retrying all failed items with empty list."""
|
|
request_data = {"item_ids": []}
|
|
|
|
response = await authenticated_client.post(
|
|
"/api/queue/retry", json=request_data
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
# Should call retry_failed with None to retry all
|
|
mock_download_service.retry_failed.assert_called_once_with(None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queue_endpoints_require_auth(mock_download_service):
|
|
"""Test that all queue endpoints require authentication."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(
|
|
transport=transport, base_url="http://test"
|
|
) as client:
|
|
# Test all endpoints without auth
|
|
endpoints = [
|
|
("GET", "/api/queue/status"),
|
|
("POST", "/api/queue/add"),
|
|
("DELETE", "/api/queue/item-1"),
|
|
("POST", "/api/queue/start"),
|
|
("POST", "/api/queue/stop"),
|
|
]
|
|
|
|
for method, url in endpoints:
|
|
if method == "GET":
|
|
response = await client.get(url)
|
|
elif method == "POST":
|
|
response = await client.post(url, json={})
|
|
elif method == "DELETE":
|
|
response = await client.delete(url)
|
|
|
|
# Should return 401 or 503 (503 if service unavailable)
|
|
assert response.status_code in (401, 503), (
|
|
f"{method} {url} should require auth, "
|
|
f"got {response.status_code}"
|
|
)
|