Aniworld/tests/unit/test_download_service.py
Lukas 028d91283e feat: implement download queue service with persistence, priority, and retry logic
- Added comprehensive download queue service (download_service.py)
  - Priority-based queue management (HIGH, NORMAL, LOW)
  - Concurrent download processing with configurable limits
  - Automatic queue persistence to JSON file
  - Retry logic for failed downloads with max retry limits
  - Real-time progress tracking and WebSocket broadcasting
  - Queue operations: add, remove, reorder, pause, resume
  - Statistics tracking: download speeds, sizes, ETA calculations

- Created comprehensive unit tests (test_download_service.py)
  - 23 tests covering all service functionality
  - Tests for queue management, persistence, retry logic
  - Broadcast callbacks, error handling, and lifecycle

- Added structlog dependency for structured logging
- Updated infrastructure.md with download service documentation
- Removed completed task from instructions.md

All tests passing (23/23)
2025-10-17 10:07:16 +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
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.utcnow().isoformat(),
"started_at": None,
"completed_at": None,
"progress": None,
"error": None,
"retry_count": 0,
"source_url": None,
}
],
"active": [],
"failed": [],
"timestamp": datetime.utcnow().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