648 lines
22 KiB
Python
648 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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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_folder="series",
|
|
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
|