Aniworld/tests/unit/test_download_service.py

634 lines
22 KiB
Python

"""Unit tests for the download queue service.
Tests cover queue management, manual download control, persistence,
and error scenarios for the simplified download service.
"""
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_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 service._active_download is None
assert len(service._completed_items) == 0
assert len(service._failed_items) == 0
assert service._is_stopped is True
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_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_start_next_download(self, download_service):
"""Test starting the next download from queue."""
# Add items to queue
item_ids = 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),
],
)
# Start next download
started_id = await download_service.start_next_download()
assert started_id is not None
assert started_id == item_ids[0]
assert len(download_service._pending_queue) == 1
assert download_service._is_stopped is False
@pytest.mark.asyncio
async def test_start_next_download_empty_queue(self, download_service):
"""Test starting download with empty queue returns None."""
result = await download_service.start_next_download()
assert result is None
@pytest.mark.asyncio
async def test_start_next_download_already_active(
self, download_service, mock_anime_service
):
"""Test that starting download while one is active raises error."""
# Add items and start one
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),
],
)
# Make download slow so it stays active
async def slow_download(**kwargs):
await asyncio.sleep(10)
mock_anime_service.download = AsyncMock(side_effect=slow_download)
# Start first download (will block for 10s in background)
item_id = await download_service.start_next_download()
assert item_id is not None
await asyncio.sleep(0.1) # Let it start processing
# Try to start another - should fail because one is active
with pytest.raises(DownloadServiceError, match="already in progress"):
await download_service.start_next_download()
@pytest.mark.asyncio
async def test_stop_downloads(self, download_service):
"""Test stopping queue processing."""
await download_service.stop_downloads()
assert download_service._is_stopped is True
@pytest.mark.asyncio
async def test_download_completion_moves_to_list(
self, download_service, mock_anime_service
):
"""Test successful download moves item to completed list."""
# Add item
await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Start and wait for completion
await download_service.start_next_download()
await asyncio.sleep(0.2) # Wait for download to complete
assert len(download_service._completed_items) == 1
assert download_service._active_download is None
@pytest.mark.asyncio
async def test_download_failure_moves_to_list(
self, download_service, mock_anime_service
):
"""Test failed download moves item to failed list."""
# Make download fail
mock_anime_service.download = AsyncMock(return_value=False)
# Add item
await download_service.add_to_queue(
serie_id="series-1",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Start and wait for failure
await download_service.start_next_download()
await asyncio.sleep(0.2) # Wait for download to fail
assert len(download_service._failed_items) == 1
assert download_service._active_download is None
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()
# Queue is stopped until start_next_download() is called
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_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
@pytest.mark.asyncio
async def test_clear_pending(self, download_service):
"""Test clearing all pending downloads from the queue."""
# Add multiple items to the queue
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="test-series-1",
serie_name="Test Series 1",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_service.add_to_queue(
serie_id="series-2",
serie_folder="test-series-2",
serie_name="Test Series 2",
episodes=[
EpisodeIdentifier(season=1, episode=2),
EpisodeIdentifier(season=1, episode=3),
],
)
# Verify items were added
assert len(download_service._pending_queue) == 3
# Clear pending queue
count = await download_service.clear_pending()
# Verify all pending items were cleared
assert count == 3
assert len(download_service._pending_queue) == 0
assert len(download_service._pending_items_by_id) == 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()
@pytest.mark.asyncio
async def test_progress_callback_format(self, download_service):
"""Test that progress callback receives correct data format."""
# Set up a mock callback to capture progress updates
progress_updates = []
def capture_progress(progress_data: dict):
progress_updates.append(progress_data)
# Mock download to simulate progress
async def mock_download_with_progress(*args, **kwargs):
# Get the callback from kwargs
callback = kwargs.get('callback')
if callback:
# Simulate progress updates with the expected format
callback({
'percent': 50.0,
'downloaded_mb': 250.5,
'total_mb': 501.0,
'speed_mbps': 5.2,
'eta_seconds': 48,
})
return True
download_service._anime_service.download = mock_download_with_progress
# Add an item to the queue
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()
del download_service._pending_items_by_id[item.id]
# Replace the progress callback with our capture function
original_callback = download_service._create_progress_callback
def wrapper(item):
callback = original_callback(item)
def wrapped_callback(data):
capture_progress(data)
callback(data)
return wrapped_callback
download_service._create_progress_callback = wrapper
await download_service._process_download(item)
# Verify progress callback was called with correct format
assert len(progress_updates) > 0
progress_data = progress_updates[0]
# Check all expected keys are present
assert 'percent' in progress_data
assert 'downloaded_mb' in progress_data
assert 'total_mb' in progress_data
assert 'speed_mbps' in progress_data
assert 'eta_seconds' in progress_data
# Verify values are of correct type
assert isinstance(progress_data['percent'], (int, float))
assert isinstance(progress_data['downloaded_mb'], (int, float))
assert (
progress_data['total_mb'] is None
or isinstance(progress_data['total_mb'], (int, float))
)
assert (
progress_data['speed_mbps'] is None
or isinstance(progress_data['speed_mbps'], (int, float))
)
class TestServiceLifecycle:
"""Test service start and stop operations."""
@pytest.mark.asyncio
async def test_start_service(self, download_service):
"""Test starting the service."""
# start() is now just for initialization/compatibility
await download_service.start()
# No _is_running attribute - simplified service doesn't track this
@pytest.mark.asyncio
async def test_stop_service(self, download_service):
"""Test stopping the service."""
await download_service.start()
await download_service.stop()
# Verifies service can be stopped without errors
# No _is_running attribute in simplified service
@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
# No _is_running attribute in simplified service
class TestErrorHandling:
"""Test error handling in download service."""
@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