"""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 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.""" # 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