556 lines
20 KiB
Python
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]
|