"""Unit tests for the download queue service. Tests cover queue management, manual download control, database persistence, and error scenarios for the simplified download service. """ from __future__ import annotations import asyncio from datetime import datetime, timezone from typing import Dict, List, Optional 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 class MockQueueRepository: """Mock implementation of QueueRepository for testing. This provides an in-memory storage that mimics the database repository behavior without requiring actual database connections. """ def __init__(self): """Initialize mock repository with in-memory storage.""" self._items: Dict[str, DownloadItem] = {} async def save_item(self, item: DownloadItem) -> DownloadItem: """Save item to in-memory storage.""" self._items[item.id] = item return item async def get_item(self, item_id: str) -> Optional[DownloadItem]: """Get item by ID from in-memory storage.""" return self._items.get(item_id) async def get_pending_items(self) -> List[DownloadItem]: """Get all pending items.""" return [ item for item in self._items.values() if item.status == DownloadStatus.PENDING ] async def get_active_item(self) -> Optional[DownloadItem]: """Get the currently active item.""" for item in self._items.values(): if item.status == DownloadStatus.DOWNLOADING: return item return None async def get_completed_items( self, limit: int = 100 ) -> List[DownloadItem]: """Get completed items.""" completed = [ item for item in self._items.values() if item.status == DownloadStatus.COMPLETED ] return completed[:limit] async def get_failed_items(self, limit: int = 50) -> List[DownloadItem]: """Get failed items.""" failed = [ item for item in self._items.values() if item.status == DownloadStatus.FAILED ] return failed[:limit] async def update_status( self, item_id: str, status: DownloadStatus, error: Optional[str] = None ) -> bool: """Update item status.""" if item_id not in self._items: return False self._items[item_id].status = status if error: self._items[item_id].error = error if status == DownloadStatus.COMPLETED: self._items[item_id].completed_at = datetime.now(timezone.utc) elif status == DownloadStatus.DOWNLOADING: self._items[item_id].started_at = datetime.now(timezone.utc) return True async def update_progress( self, item_id: str, progress: float, downloaded: int, total: int, speed: float ) -> bool: """Update download progress.""" if item_id not in self._items: return False item = self._items[item_id] if item.progress is None: from src.server.models.download import DownloadProgress item.progress = DownloadProgress( percent=progress, downloaded_bytes=downloaded, total_bytes=total, speed_bps=speed ) else: item.progress.percent = progress item.progress.downloaded_bytes = downloaded item.progress.total_bytes = total item.progress.speed_bps = speed return True async def delete_item(self, item_id: str) -> bool: """Delete item from storage.""" if item_id in self._items: del self._items[item_id] return True return False async def clear_completed(self) -> int: """Clear all completed items.""" completed_ids = [ item_id for item_id, item in self._items.items() if item.status == DownloadStatus.COMPLETED ] for item_id in completed_ids: del self._items[item_id] return len(completed_ids) @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 mock_queue_repository(): """Create a mock QueueRepository for testing.""" return MockQueueRepository() @pytest.fixture def download_service(mock_anime_service, mock_queue_repository): """Create a DownloadService instance for testing.""" return DownloadService( anime_service=mock_anime_service, queue_repository=mock_queue_repository, max_retries=3, ) class TestDownloadServiceInitialization: """Test download service initialization.""" def test_initialization_creates_queues( self, mock_anime_service, mock_queue_repository ): """Test that initialization creates empty queues.""" service = DownloadService( anime_service=mock_anime_service, queue_repository=mock_queue_repository, ) 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 @pytest.mark.asyncio async def test_initialization_loads_persisted_queue( self, mock_anime_service, mock_queue_repository ): """Test that initialization loads persisted queue from database.""" # Pre-populate the mock repository with a pending item test_item = DownloadItem( id="test-id-1", serie_id="series-1", serie_folder="test-series", serie_name="Test Series", episode=EpisodeIdentifier(season=1, episode=1), status=DownloadStatus.PENDING, priority=DownloadPriority.NORMAL, added_at=datetime.now(timezone.utc), ) await mock_queue_repository.save_item(test_item) # Create service and initialize from database service = DownloadService( anime_service=mock_anime_service, queue_repository=mock_queue_repository, ) await service.initialize() 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 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 == "queue_started" # Service returns this string # Queue processing starts in background, wait a moment await asyncio.sleep(0.2) # First item should be processing or completed assert len(download_service._pending_queue) <= 2 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 (fake - no real download) async def fake_slow_download(**kwargs): await asyncio.sleep(0.5) # Reduced from 10s to speed up test return True # Fake success mock_anime_service.download = AsyncMock(side_effect=fake_slow_download) # Start first download (will block for 0.5s 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 active"): 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.""" # Ensure mock returns success (fake download - no real download) mock_anime_service.download = AsyncMock(return_value=True) # 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 (fake download failure - no real download) 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_folder="Test Series (2023)", 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 with database backend.""" @pytest.mark.asyncio async def test_queue_persistence( self, download_service, mock_queue_repository ): """Test that queue state is persisted to database.""" await download_service.add_to_queue( serie_id="series-1", serie_folder="series", serie_name="Test Series", episodes=[EpisodeIdentifier(season=1, episode=1)], ) # Item should be saved in mock repository pending_items = await mock_queue_repository.get_pending_items() assert len(pending_items) == 1 assert pending_items[0].serie_id == "series-1" @pytest.mark.asyncio async def test_queue_recovery_after_restart( self, mock_anime_service, mock_queue_repository ): """Test that queue is recovered after service restart.""" # Create and populate first service service1 = DownloadService( anime_service=mock_anime_service, queue_repository=mock_queue_repository, ) 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 repository (simulating restart) service2 = DownloadService( anime_service=mock_anime_service, queue_repository=mock_queue_repository, ) # Initialize to load from database to recover state await service2.initialize() # 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_folder="Test Series (2023)", 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_folder="Test Series (2023)", 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_broadcast_on_queue_update(self, download_service): """Test that queue updates work correctly (no broadcast callbacks).""" # Note: The service no longer has set_broadcast_callback method # It uses the progress service internally for websocket updates await download_service.add_to_queue( serie_id="series-1", serie_folder="series", serie_name="Test Series", episodes=[EpisodeIdentifier(season=1, episode=1)], ) # Verify item was added successfully assert len(download_service._pending_queue) == 1 @pytest.mark.asyncio async def test_progress_callback_format(self, download_service): """Test that download completes successfully with mocked service.""" # Note: Progress updates are handled by SeriesApp events and # ProgressService, not via direct callbacks to the download service. # This test verifies that downloads complete without errors. # Mock successful download (fake download - no real download) download_service._anime_service.download = AsyncMock(return_value=True) # Add and process a download await download_service.add_to_queue( serie_id="series-1", serie_folder="series", serie_name="Test Series", episodes=[EpisodeIdentifier(season=1, episode=1)], ) # Start download and wait for completion await download_service.start_next_download() await asyncio.sleep(0.5) # Wait for processing # Verify download completed successfully assert len(download_service._completed_items) == 1 assert download_service._completed_items[0].status == ( DownloadStatus.COMPLETED ) 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 with exception (fake - no real download) download_service._anime_service.download = AsyncMock( side_effect=Exception("Fake 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