Aniworld/tests/unit/test_download_service.py
Lukas 17e5a551e1 feat: migrate to Pydantic V2 and implement rate limiting middleware
- Migrate settings.py to Pydantic V2 (SettingsConfigDict, validation_alias)
- Update config models to use @field_validator with @classmethod
- Replace deprecated datetime.utcnow() with datetime.now(timezone.utc)
- Migrate FastAPI app from @app.on_event to lifespan context manager
- Implement comprehensive rate limiting middleware with:
  * Endpoint-specific rate limits (login: 5/min, register: 3/min)
  * IP-based and user-based tracking
  * Authenticated user multiplier (2x limits)
  * Bypass paths for health, docs, static, websocket endpoints
  * Rate limit headers in responses
- Add 13 comprehensive tests for rate limiting (all passing)
- Update instructions.md to mark completed tasks
- Fix asyncio.create_task usage in anime_service.py

All 714 tests passing. No deprecation warnings.
2025-10-23 22:03:15 +02:00

492 lines
16 KiB
Python

"""Unit tests for the download queue service.
Tests cover queue management, priority handling, persistence,
concurrent downloads, and error scenarios.
"""
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.server.models.download import (
DownloadItem,
DownloadPriority,
DownloadStatus,
EpisodeIdentifier,
)
from src.server.services.anime_service import AnimeService
from src.server.services.download_service import DownloadService, DownloadServiceError
@pytest.fixture
def mock_anime_service():
"""Create a mock AnimeService."""
service = MagicMock(spec=AnimeService)
service.download = AsyncMock(return_value=True)
return service
@pytest.fixture
def temp_persistence_path(tmp_path):
"""Create a temporary persistence path."""
return str(tmp_path / "test_queue.json")
@pytest.fixture
def download_service(mock_anime_service, temp_persistence_path):
"""Create a DownloadService instance for testing."""
return DownloadService(
anime_service=mock_anime_service,
max_concurrent_downloads=2,
max_retries=3,
persistence_path=temp_persistence_path,
)
class TestDownloadServiceInitialization:
"""Test download service initialization."""
def test_initialization_creates_queues(
self, mock_anime_service, temp_persistence_path
):
"""Test that initialization creates empty queues."""
service = DownloadService(
anime_service=mock_anime_service,
persistence_path=temp_persistence_path,
)
assert len(service._pending_queue) == 0
assert len(service._active_downloads) == 0
assert len(service._completed_items) == 0
assert len(service._failed_items) == 0
assert service._is_running is False
assert service._is_paused is False
def test_initialization_loads_persisted_queue(
self, mock_anime_service, temp_persistence_path
):
"""Test that initialization loads persisted queue state."""
# Create a persisted queue file
persistence_file = Path(temp_persistence_path)
persistence_file.parent.mkdir(parents=True, exist_ok=True)
test_data = {
"pending": [
{
"id": "test-id-1",
"serie_id": "series-1",
"serie_name": "Test Series",
"episode": {"season": 1, "episode": 1, "title": None},
"status": "pending",
"priority": "normal",
"added_at": datetime.now(timezone.utc).isoformat(),
"started_at": None,
"completed_at": None,
"progress": None,
"error": None,
"retry_count": 0,
"source_url": None,
}
],
"active": [],
"failed": [],
"timestamp": datetime.now(timezone.utc).isoformat(),
}
with open(persistence_file, "w", encoding="utf-8") as f:
json.dump(test_data, f)
service = DownloadService(
anime_service=mock_anime_service,
persistence_path=temp_persistence_path,
)
assert len(service._pending_queue) == 1
assert service._pending_queue[0].id == "test-id-1"
class TestQueueManagement:
"""Test queue management operations."""
@pytest.mark.asyncio
async def test_add_to_queue_single_episode(self, download_service):
"""Test adding a single episode to queue."""
episodes = [EpisodeIdentifier(season=1, episode=1)]
item_ids = await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=episodes,
priority=DownloadPriority.NORMAL,
)
assert len(item_ids) == 1
assert len(download_service._pending_queue) == 1
assert download_service._pending_queue[0].serie_id == "series-1"
assert (
download_service._pending_queue[0].status
== DownloadStatus.PENDING
)
@pytest.mark.asyncio
async def test_add_to_queue_multiple_episodes(self, download_service):
"""Test adding multiple episodes to queue."""
episodes = [
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
EpisodeIdentifier(season=1, episode=3),
]
item_ids = await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=episodes,
priority=DownloadPriority.NORMAL,
)
assert len(item_ids) == 3
assert len(download_service._pending_queue) == 3
@pytest.mark.asyncio
async def test_add_high_priority_to_front(self, download_service):
"""Test that high priority items are added to front of queue."""
# Add normal priority item
await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)
# Add high priority item
await download_service.add_to_queue(
serie_id="series-2",
serie_name="Priority Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.HIGH,
)
# High priority should be at front
assert download_service._pending_queue[0].serie_id == "series-2"
assert download_service._pending_queue[1].serie_id == "series-1"
@pytest.mark.asyncio
async def test_remove_from_pending_queue(self, download_service):
"""Test removing items from pending queue."""
item_ids = await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
removed_ids = await download_service.remove_from_queue(item_ids)
assert len(removed_ids) == 1
assert removed_ids[0] == item_ids[0]
assert len(download_service._pending_queue) == 0
@pytest.mark.asyncio
async def test_reorder_queue(self, download_service):
"""Test reordering items in queue."""
# Add three items
await download_service.add_to_queue(
serie_id="series-1",
serie_name="Series 1",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_service.add_to_queue(
serie_id="series-2",
serie_name="Series 2",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_service.add_to_queue(
serie_id="series-3",
serie_name="Series 3",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Move last item to position 0
item_to_move = download_service._pending_queue[2].id
success = await download_service.reorder_queue(item_to_move, 0)
assert success is True
assert download_service._pending_queue[0].id == item_to_move
assert download_service._pending_queue[0].serie_id == "series-3"
class TestQueueStatus:
"""Test queue status reporting."""
@pytest.mark.asyncio
async def test_get_queue_status(self, download_service):
"""Test getting queue status."""
# Add items to queue
await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
],
)
status = await download_service.get_queue_status()
assert status.is_running is False
assert status.is_paused is False
assert len(status.pending_queue) == 2
assert len(status.active_downloads) == 0
assert len(status.completed_downloads) == 0
assert len(status.failed_downloads) == 0
@pytest.mark.asyncio
async def test_get_queue_stats(self, download_service):
"""Test getting queue statistics."""
# Add items
await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
],
)
stats = await download_service.get_queue_stats()
assert stats.total_items == 2
assert stats.pending_count == 2
assert stats.active_count == 0
assert stats.completed_count == 0
assert stats.failed_count == 0
assert stats.total_downloaded_mb == 0.0
class TestQueueControl:
"""Test queue control operations."""
@pytest.mark.asyncio
async def test_pause_queue(self, download_service):
"""Test pausing the queue."""
await download_service.pause_queue()
assert download_service._is_paused is True
@pytest.mark.asyncio
async def test_resume_queue(self, download_service):
"""Test resuming the queue."""
await download_service.pause_queue()
await download_service.resume_queue()
assert download_service._is_paused is False
@pytest.mark.asyncio
async def test_clear_completed(self, download_service):
"""Test clearing completed downloads."""
# Manually add completed item
completed_item = DownloadItem(
id="completed-1",
serie_id="series-1",
serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.COMPLETED,
)
download_service._completed_items.append(completed_item)
count = await download_service.clear_completed()
assert count == 1
assert len(download_service._completed_items) == 0
class TestPersistence:
"""Test queue persistence functionality."""
@pytest.mark.asyncio
async def test_queue_persistence(self, download_service):
"""Test that queue state is persisted to disk."""
await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Persistence file should exist
persistence_path = Path(download_service._persistence_path)
assert persistence_path.exists()
# Check file contents
with open(persistence_path, "r") as f:
data = json.load(f)
assert len(data["pending"]) == 1
assert data["pending"][0]["serie_id"] == "series-1"
@pytest.mark.asyncio
async def test_queue_recovery_after_restart(
self, mock_anime_service, temp_persistence_path
):
"""Test that queue is recovered after service restart."""
# Create and populate first service
service1 = DownloadService(
anime_service=mock_anime_service,
persistence_path=temp_persistence_path,
)
await service1.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
],
)
# Create new service with same persistence path
service2 = DownloadService(
anime_service=mock_anime_service,
persistence_path=temp_persistence_path,
)
# Should recover pending items
assert len(service2._pending_queue) == 2
class TestRetryLogic:
"""Test retry logic for failed downloads."""
@pytest.mark.asyncio
async def test_retry_failed_items(self, download_service):
"""Test retrying failed downloads."""
# Manually add failed item
failed_item = DownloadItem(
id="failed-1",
serie_id="series-1",
serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.FAILED,
retry_count=0,
error="Test error",
)
download_service._failed_items.append(failed_item)
retried_ids = await download_service.retry_failed()
assert len(retried_ids) == 1
assert len(download_service._failed_items) == 0
assert len(download_service._pending_queue) == 1
assert download_service._pending_queue[0].retry_count == 1
@pytest.mark.asyncio
async def test_max_retries_not_exceeded(self, download_service):
"""Test that items with max retries are not retried."""
# Create item with max retries
failed_item = DownloadItem(
id="failed-1",
serie_id="series-1",
serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.FAILED,
retry_count=3, # Max retries
error="Test error",
)
download_service._failed_items.append(failed_item)
retried_ids = await download_service.retry_failed()
assert len(retried_ids) == 0
assert len(download_service._failed_items) == 1
assert len(download_service._pending_queue) == 0
class TestBroadcastCallbacks:
"""Test WebSocket broadcast functionality."""
@pytest.mark.asyncio
async def test_set_broadcast_callback(self, download_service):
"""Test setting broadcast callback."""
mock_callback = AsyncMock()
download_service.set_broadcast_callback(mock_callback)
assert download_service._broadcast_callback == mock_callback
@pytest.mark.asyncio
async def test_broadcast_on_queue_update(self, download_service):
"""Test that broadcasts are sent on queue updates."""
mock_callback = AsyncMock()
download_service.set_broadcast_callback(mock_callback)
await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Allow async callback to execute
await asyncio.sleep(0.1)
# Verify callback was called
mock_callback.assert_called()
class TestServiceLifecycle:
"""Test service start and stop operations."""
@pytest.mark.asyncio
async def test_start_service(self, download_service):
"""Test starting the service."""
await download_service.start()
assert download_service._is_running is True
@pytest.mark.asyncio
async def test_stop_service(self, download_service):
"""Test stopping the service."""
await download_service.start()
await download_service.stop()
assert download_service._is_running is False
@pytest.mark.asyncio
async def test_start_already_running(self, download_service):
"""Test starting service when already running."""
await download_service.start()
await download_service.start() # Should not raise error
assert download_service._is_running is True
class TestErrorHandling:
"""Test error handling in download service."""
@pytest.mark.asyncio
async def test_reorder_nonexistent_item(self, download_service):
"""Test reordering non-existent item raises error."""
with pytest.raises(DownloadServiceError):
await download_service.reorder_queue("nonexistent-id", 0)
@pytest.mark.asyncio
async def test_download_failure_moves_to_failed(self, download_service):
"""Test that download failures are handled correctly."""
# Mock download to fail
download_service._anime_service.download = AsyncMock(
side_effect=Exception("Download failed")
)
await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Process the download
item = download_service._pending_queue.popleft()
await download_service._process_download(item)
# Item should be in failed queue
assert len(download_service._failed_items) == 1
assert (
download_service._failed_items[0].status == DownloadStatus.FAILED
)
assert download_service._failed_items[0].error is not None