"""Unit tests for the download queue service. Tests cover queue management, priority handling, persistence, concurrent downloads, and error scenarios. """ 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_concurrent_downloads=2, 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 len(service._active_downloads) == 0 assert len(service._completed_items) == 0 assert len(service._failed_items) == 0 assert service._is_running is False assert service._is_paused is False 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_add_high_priority_to_front(self, download_service): """Test that high priority items are added to front of queue.""" # Add normal priority item await download_service.add_to_queue( serie_id="series-1", serie_name="Test Series", episodes=[EpisodeIdentifier(season=1, episode=1)], priority=DownloadPriority.NORMAL, ) # Add high priority item await download_service.add_to_queue( serie_id="series-2", serie_name="Priority Series", episodes=[EpisodeIdentifier(season=1, episode=1)], priority=DownloadPriority.HIGH, ) # High priority should be at front assert download_service._pending_queue[0].serie_id == "series-2" assert download_service._pending_queue[1].serie_id == "series-1" @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_reorder_queue(self, download_service): """Test reordering items in queue.""" # Add three items await download_service.add_to_queue( serie_id="series-1", serie_name="Series 1", episodes=[EpisodeIdentifier(season=1, episode=1)], ) await download_service.add_to_queue( serie_id="series-2", serie_name="Series 2", episodes=[EpisodeIdentifier(season=1, episode=1)], ) await download_service.add_to_queue( serie_id="series-3", serie_name="Series 3", episodes=[EpisodeIdentifier(season=1, episode=1)], ) # Move last item to position 0 item_to_move = download_service._pending_queue[2].id success = await download_service.reorder_queue(item_to_move, 0) assert success is True assert download_service._pending_queue[0].id == item_to_move assert download_service._pending_queue[0].serie_id == "series-3" 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() 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_pause_queue(self, download_service): """Test pausing the queue.""" await download_service.pause_queue() assert download_service._is_paused is True @pytest.mark.asyncio async def test_resume_queue(self, download_service): """Test resuming the queue.""" await download_service.pause_queue() await download_service.resume_queue() assert download_service._is_paused is False @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.""" await download_service.start() assert download_service._is_running is True @pytest.mark.asyncio async def test_stop_service(self, download_service): """Test stopping the service.""" await download_service.start() await download_service.stop() assert download_service._is_running is False @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 assert download_service._is_running is True class TestErrorHandling: """Test error handling in download service.""" @pytest.mark.asyncio async def test_reorder_nonexistent_item(self, download_service): """Test reordering non-existent item raises error.""" with pytest.raises(DownloadServiceError): await download_service.reorder_queue("nonexistent-id", 0) @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