"""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_folder": "test-series", # Added missing field "serie_name": "Test Series", "episode": {"season": 1, "episode": 1, "title": None}, "status": "pending", "priority": "NORMAL", # Must be uppercase "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 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_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_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