Aniworld/tests/integration/test_download_flow.py
2025-11-15 16:56:12 +01:00

556 lines
20 KiB
Python

"""Integration tests for complete download flow.
This module tests the end-to-end download flow including:
- Adding episodes to the queue
- Queue status updates
- Download processing
- Progress tracking
- Queue control operations (pause, resume, clear)
- Error handling and retries
- WebSocket notifications
"""
import asyncio
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import AsyncMock, Mock
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
from src.server.models.download import (
DownloadPriority,
DownloadStatus,
EpisodeIdentifier,
)
from src.server.services.anime_service import AnimeService
from src.server.services.auth_service import auth_service
from src.server.services.download_service import DownloadService
from src.server.services.progress_service import get_progress_service
from src.server.services.websocket_service import get_websocket_service
@pytest.fixture(autouse=True)
def reset_auth():
"""Reset authentication state before each test."""
original_hash = auth_service._hash
auth_service._hash = None
auth_service._failed.clear()
yield
auth_service._hash = original_hash
auth_service._failed.clear()
@pytest.fixture
async def client():
"""Create an async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def authenticated_client(client):
"""Create an authenticated test client with token."""
# Setup master password
await client.post(
"/api/auth/setup",
json={"master_password": "TestPassword123!"}
)
# Login to get token
response = await client.post(
"/api/auth/login",
json={"password": "TestPassword123!"}
)
token = response.json()["access_token"]
# Add token to default headers
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
@pytest.fixture
def mock_series_app():
"""Mock SeriesApp for testing."""
app_mock = Mock()
app_mock.series_list = []
app_mock.search = Mock(return_value=[])
app_mock.ReScan = Mock()
app_mock.download = Mock(return_value=True)
return app_mock
@pytest.fixture
def mock_anime_service(mock_series_app, tmp_path):
"""Create a mock AnimeService."""
# Create a temporary directory for the service
test_dir = tmp_path / "anime"
test_dir.mkdir()
# Create AnimeService with the mocked SeriesApp
service = AnimeService(series_app=mock_series_app)
service.download = AsyncMock(return_value=True)
return service
@pytest.fixture
def temp_queue_file(tmp_path):
"""Create a temporary queue persistence file."""
return str(tmp_path / "test_queue.json")
class TestDownloadFlowEndToEnd:
"""Test complete download flow from queue addition to completion."""
async def test_add_episodes_to_queue(self, authenticated_client, mock_anime_service):
"""Test adding episodes to the download queue."""
# Add episodes to queue
response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "test-series-1",
"serie_name": "Test Anime Series",
"episodes": [
{"season": 1, "episode": 1, "title": "Episode 1"},
{"season": 1, "episode": 2, "title": "Episode 2"},
],
"priority": "normal"
}
)
assert response.status_code == 201
data = response.json()
# Verify response structure
assert data["status"] == "success"
assert "item_ids" in data
assert len(data["item_ids"]) == 2
assert "message" in data
async def test_queue_status_after_adding_items(self, authenticated_client):
"""Test retrieving queue status after adding items."""
# Add episodes to queue
await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "test-series-2",
"serie_name": "Another Series",
"episodes": [{"season": 1, "episode": 1}],
"priority": "high"
}
)
# Get queue status
response = await authenticated_client.get("/api/queue/status")
assert response.status_code in [200, 503]
if response.status_code == 200:
data = response.json()
# Verify response structure (status and statistics at top level)
assert "status" in data
assert "statistics" in data
# Verify status fields
status_data = data["status"]
assert "is_running" in status_data
assert "is_paused" in status_data
assert "pending_queue" in status_data
assert "active_downloads" in status_data
assert "completed_downloads" in status_data
assert "failed_downloads" in status_data
async def test_add_with_different_priorities(self, authenticated_client):
"""Test adding episodes with different priority levels."""
priorities = ["high", "normal", "low"]
for priority in priorities:
response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": f"series-{priority}",
"serie_name": f"Series {priority.title()}",
"episodes": [{"season": 1, "episode": 1}],
"priority": priority
}
)
assert response.status_code in [201, 503]
async def test_validation_error_for_empty_episodes(self, authenticated_client):
"""Test validation error when no episodes are specified."""
response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "test-series",
"serie_name": "Test Series",
"episodes": [],
"priority": "normal"
}
)
assert response.status_code == 400
data = response.json()
assert "detail" in data
async def test_validation_error_for_invalid_priority(self, authenticated_client):
"""Test validation error for invalid priority level."""
response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "test-series",
"serie_name": "Test Series",
"episodes": [{"season": 1, "episode": 1}],
"priority": "invalid"
}
)
assert response.status_code == 422 # Validation error
class TestQueueControlOperations:
"""Test queue control operations (start, pause, resume, clear)."""
async def test_start_queue_processing(self, authenticated_client):
"""Test starting the queue processor."""
response = await authenticated_client.post("/api/queue/start")
assert response.status_code in [200, 503]
if response.status_code == 200:
data = response.json()
assert data["status"] == "success"
async def test_clear_completed_downloads(self, authenticated_client):
"""Test clearing completed downloads from the queue."""
response = await authenticated_client.delete("/api/queue/completed")
assert response.status_code in [200, 503]
if response.status_code == 200:
data = response.json()
assert data["status"] == "success"
class TestQueueItemOperations:
"""Test operations on individual queue items."""
async def test_remove_item_from_queue(self, authenticated_client):
"""Test removing a specific item from the queue."""
# First add an item
add_response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "test-series",
"serie_name": "Test Series",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
if add_response.status_code == 201:
item_id = add_response.json()["item_ids"][0]
# Remove the item
response = await authenticated_client.delete(f"/api/queue/items/{item_id}")
assert response.status_code in [200, 404, 503]
async def test_retry_failed_item(self, authenticated_client):
"""Test retrying a failed download item."""
# This would typically require a failed item to exist
# For now, test the endpoint with a dummy ID
response = await authenticated_client.post("/api/queue/items/dummy-id/retry")
# Should return 404 if item doesn't exist, or 503 if unavailable
assert response.status_code in [200, 404, 503]
class TestDownloadProgressTracking:
"""Test progress tracking during downloads."""
async def test_queue_status_includes_progress(self, authenticated_client):
"""Test that queue status includes progress information."""
# Add an item
await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "test-series",
"serie_name": "Test Series",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
# Get status
response = await authenticated_client.get("/api/queue/status")
assert response.status_code in [200, 503]
if response.status_code == 200:
data = response.json()
# Updated for new nested response format
assert "status" in data
status_data = data["status"]
assert "active_downloads" in status_data
# Check that items can have progress
for item in status_data.get("active_downloads", []):
if "progress" in item and item["progress"]:
assert "percent" in item["progress"]
assert "downloaded_mb" in item["progress"]
assert "total_mb" in item["progress"]
async def test_queue_statistics(self, authenticated_client):
"""Test that queue statistics are calculated correctly."""
response = await authenticated_client.get("/api/queue/status")
assert response.status_code in [200, 503]
if response.status_code == 200:
data = response.json()
assert "statistics" in data
stats = data["statistics"]
assert "total_items" in stats
assert "pending_count" in stats
assert "active_count" in stats
assert "completed_count" in stats
assert "failed_count" in stats
# Note: success_rate not currently in QueueStats model
class TestErrorHandlingAndRetries:
"""Test error handling and retry mechanisms."""
async def test_handle_download_failure(self, authenticated_client):
"""Test handling of download failures."""
# This would require mocking a failure scenario
# For integration testing, we verify the error handling structure
# Add an item that might fail
response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "invalid-series",
"serie_name": "Invalid Series",
"episodes": [{"season": 99, "episode": 99}],
"priority": "normal"
}
)
# The system should handle the request gracefully
assert response.status_code in [201, 400, 503]
async def test_retry_count_increments(self, authenticated_client):
"""Test that retry count increments on failures."""
# Add a potentially failing item
add_response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "test-series",
"serie_name": "Test Series",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
if add_response.status_code == 201:
# Get queue status to check retry count
status_response = await authenticated_client.get(
"/api/queue/status"
)
if status_response.status_code == 200:
data = status_response.json()
# Verify structure includes retry_count field
# Updated to match new response structure
for item_list in [
data.get("pending_queue", []),
data.get("failed_downloads", [])
]:
for item in item_list:
assert "retry_count" in item
class TestAuthenticationRequirements:
"""Test that download endpoints require authentication."""
async def test_queue_status_requires_auth(self, client):
"""Test that queue status endpoint requires authentication."""
response = await client.get("/api/queue/status")
assert response.status_code == 401
async def test_add_to_queue_requires_auth(self, client):
"""Test that add to queue endpoint requires authentication."""
response = await client.post(
"/api/queue/add",
json={
"serie_id": "test-series",
"serie_name": "Test Series",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
assert response.status_code == 401
async def test_queue_control_requires_auth(self, client):
"""Test that queue control endpoints require authentication."""
response = await client.post("/api/queue/start")
assert response.status_code == 401
async def test_item_operations_require_auth(self, client):
"""Test that item operations require authentication."""
response = await client.delete("/api/queue/items/dummy-id")
# 404 is acceptable - endpoint exists but item doesn't
# 401 is also acceptable - auth was checked before routing
assert response.status_code in [401, 404]
class TestConcurrentOperations:
"""Test concurrent download operations."""
async def test_multiple_concurrent_downloads(self, authenticated_client):
"""Test handling multiple concurrent download requests."""
# Add multiple items concurrently
tasks = []
for i in range(5):
task = authenticated_client.post(
"/api/queue/add",
json={
"serie_id": f"series-{i}",
"serie_name": f"Series {i}",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
tasks.append(task)
# Wait for all requests to complete
responses = await asyncio.gather(*tasks, return_exceptions=True)
# Verify all requests were handled
for response in responses:
if not isinstance(response, Exception):
assert response.status_code in [201, 503]
async def test_concurrent_status_requests(self, authenticated_client):
"""Test handling concurrent status requests."""
# Make multiple concurrent status requests
tasks = [
authenticated_client.get("/api/queue/status")
for _ in range(10)
]
responses = await asyncio.gather(*tasks, return_exceptions=True)
# Verify all requests were handled
for response in responses:
if not isinstance(response, Exception):
assert response.status_code in [200, 503]
class TestQueuePersistence:
"""Test queue state persistence."""
async def test_queue_survives_restart(self, authenticated_client, temp_queue_file):
"""Test that queue state persists across service restarts."""
# This would require actually restarting the service
# For integration testing, we verify the persistence mechanism exists
# Add items to queue
response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "persistent-series",
"serie_name": "Persistent Series",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
# Verify the request was processed
assert response.status_code in [201, 503]
# In a full integration test, we would restart the service here
# and verify the queue state is restored
async def test_failed_items_are_persisted(self, authenticated_client):
"""Test that failed items are persisted."""
# Get initial queue state
initial_response = await authenticated_client.get("/api/queue/status")
assert initial_response.status_code in [200, 503]
# The persistence mechanism should handle failed items
# In a real scenario, we would trigger a failure and verify persistence
class TestWebSocketIntegrationWithDownloads:
"""Test WebSocket notifications during download operations."""
async def test_websocket_notifies_on_queue_changes(self, authenticated_client):
"""Test that WebSocket broadcasts queue changes."""
# This is a basic integration test
# Full WebSocket testing is in test_websocket.py
# Add an item to trigger potential WebSocket notification
response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "ws-series",
"serie_name": "WebSocket Series",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
# Verify the operation succeeded
assert response.status_code in [201, 503]
# In a full test, we would verify WebSocket clients received notifications
class TestCompleteDownloadWorkflow:
"""Test complete end-to-end download workflow."""
async def test_full_download_cycle(self, authenticated_client):
"""Test complete download cycle from add to completion."""
# 1. Add episode to queue
add_response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": "workflow-series",
"serie_name": "Workflow Test Series",
"episodes": [{"season": 1, "episode": 1}],
"priority": "high"
}
)
assert add_response.status_code in [201, 503]
if add_response.status_code == 201:
item_id = add_response.json()["item_ids"][0]
# 2. Verify item is in queue
status_response = await authenticated_client.get("/api/queue/status")
assert status_response.status_code in [200, 503]
# 3. Start queue processing
start_response = await authenticated_client.post("/api/queue/start")
assert start_response.status_code in [200, 503]
# 4. Check status during processing
await asyncio.sleep(0.1) # Brief delay
progress_response = await authenticated_client.get("/api/queue/status")
assert progress_response.status_code in [200, 503]
# 5. Verify final state (completed or still processing)
final_response = await authenticated_client.get(
"/api/queue/status"
)
assert final_response.status_code in [200, 503]