"""Unit tests for download progress WebSocket updates. This module tests the integration between download service progress tracking and WebSocket broadcasting to ensure real-time updates are properly sent to connected clients. """ import asyncio from typing import Any, Dict, List from unittest.mock import Mock, patch import pytest from src.server.models.download import ( DownloadPriority, DownloadProgress, EpisodeIdentifier, ) from src.server.services.anime_service import AnimeService from src.server.services.download_service import DownloadService from src.server.services.progress_service import ProgressService @pytest.fixture def mock_series_app(): """Mock SeriesApp for testing.""" app = Mock() app.series_list = [] app.search = Mock(return_value=[]) app.ReScan = Mock() # Mock download with progress callback def mock_download( serie_folder, season, episode, key, callback=None, **kwargs ): """Simulate download with progress updates.""" if callback: # Simulate progress updates callback({ 'percent': 25.0, 'downloaded_mb': 25.0, 'total_mb': 100.0, 'speed_mbps': 2.5, 'eta_seconds': 30, }) callback({ 'percent': 50.0, 'downloaded_mb': 50.0, 'total_mb': 100.0, 'speed_mbps': 2.5, 'eta_seconds': 20, }) callback({ 'percent': 100.0, 'downloaded_mb': 100.0, 'total_mb': 100.0, 'speed_mbps': 2.5, 'eta_seconds': 0, }) # Return success result result = Mock() result.success = True result.message = "Download completed" return result app.download = Mock(side_effect=mock_download) return app @pytest.fixture def progress_service(): """Create a ProgressService instance for testing.""" return ProgressService() @pytest.fixture async def anime_service(mock_series_app, progress_service): """Create an AnimeService with mocked dependencies.""" service = AnimeService( series_app=mock_series_app, progress_service=progress_service, ) yield service @pytest.fixture async def download_service(anime_service, progress_service): """Create a DownloadService with dependencies.""" service = DownloadService( anime_service=anime_service, progress_service=progress_service, persistence_path="/tmp/test_download_progress_queue.json", ) yield service await service.stop() class TestDownloadProgressWebSocket: """Test download progress WebSocket broadcasting.""" @pytest.mark.asyncio async def test_progress_callback_broadcasts_updates( self, download_service ): """Test that progress callback broadcasts updates via WebSocket.""" broadcasts: List[Dict[str, Any]] = [] async def mock_broadcast(update_type: str, data: dict): """Capture broadcast calls.""" broadcasts.append({"type": update_type, "data": data}) download_service.set_broadcast_callback(mock_broadcast) # Add item to queue item_ids = await download_service.add_to_queue( serie_id="test_serie_1", serie_folder="test_serie_1", serie_name="Test Anime", episodes=[EpisodeIdentifier(season=1, episode=1)], priority=DownloadPriority.NORMAL, ) assert len(item_ids) == 1 # Start processing - this should trigger download with progress result = await download_service.start_queue_processing() assert result is not None # Wait for download to process await asyncio.sleep(0.5) # Filter progress broadcasts progress_broadcasts = [ b for b in broadcasts if b["type"] == "download_progress" ] # Should have received multiple progress updates assert len(progress_broadcasts) >= 2 # Verify progress data structure for broadcast in progress_broadcasts: data = broadcast["data"] assert "download_id" in data or "item_id" in data assert "progress" in data progress = data["progress"] assert "percent" in progress assert "downloaded_mb" in progress assert "total_mb" in progress assert 0 <= progress["percent"] <= 100 @pytest.mark.asyncio async def test_progress_updates_include_episode_info( self, download_service ): """Test that progress updates include episode information.""" broadcasts: List[Dict[str, Any]] = [] async def mock_broadcast(update_type: str, data: dict): broadcasts.append({"type": update_type, "data": data}) download_service.set_broadcast_callback(mock_broadcast) # Add item with specific episode info await download_service.add_to_queue( serie_id="test_serie_2", serie_folder="test_serie_2", serie_name="My Test Anime", episodes=[EpisodeIdentifier(season=2, episode=5)], priority=DownloadPriority.HIGH, ) # Start processing await download_service.start_queue_processing() await asyncio.sleep(0.5) # Find progress broadcasts progress_broadcasts = [ b for b in broadcasts if b["type"] == "download_progress" ] assert len(progress_broadcasts) > 0 # Verify episode info is included data = progress_broadcasts[0]["data"] assert data["serie_name"] == "My Test Anime" assert data["season"] == 2 assert data["episode"] == 5 @pytest.mark.asyncio async def test_progress_percent_increases(self, download_service): """Test that progress percentage increases over time.""" broadcasts: List[Dict[str, Any]] = [] async def mock_broadcast(update_type: str, data: dict): broadcasts.append({"type": update_type, "data": data}) download_service.set_broadcast_callback(mock_broadcast) await download_service.add_to_queue( serie_id="test_serie_3", serie_folder="test_serie_3", serie_name="Progress Test", episodes=[EpisodeIdentifier(season=1, episode=1)], ) await download_service.start_queue_processing() await asyncio.sleep(0.5) # Get progress broadcasts in order progress_broadcasts = [ b for b in broadcasts if b["type"] == "download_progress" ] # Verify we have multiple updates assert len(progress_broadcasts) >= 2 # Verify progress increases percentages = [ b["data"]["progress"]["percent"] for b in progress_broadcasts ] # Each percentage should be >= the previous one for i in range(1, len(percentages)): assert percentages[i] >= percentages[i - 1] @pytest.mark.asyncio async def test_progress_includes_speed_and_eta(self, download_service): """Test that progress updates include speed and ETA.""" broadcasts: List[Dict[str, Any]] = [] async def mock_broadcast(update_type: str, data: dict): broadcasts.append({"type": update_type, "data": data}) download_service.set_broadcast_callback(mock_broadcast) await download_service.add_to_queue( serie_id="test_serie_4", serie_folder="test_serie_4", serie_name="Speed Test", episodes=[EpisodeIdentifier(season=1, episode=1)], ) await download_service.start_queue_processing() await asyncio.sleep(0.5) progress_broadcasts = [ b for b in broadcasts if b["type"] == "download_progress" ] assert len(progress_broadcasts) > 0 # Check that speed and ETA are present progress = progress_broadcasts[0]["data"]["progress"] assert "speed_mbps" in progress assert "eta_seconds" in progress # Speed and ETA should be numeric (or None) if progress["speed_mbps"] is not None: assert isinstance(progress["speed_mbps"], (int, float)) if progress["eta_seconds"] is not None: assert isinstance(progress["eta_seconds"], (int, float)) @pytest.mark.asyncio async def test_no_broadcast_without_callback(self, download_service): """Test that no errors occur when broadcast callback is not set.""" # Don't set broadcast callback await download_service.add_to_queue( serie_id="test_serie_5", serie_folder="test_serie_5", serie_name="No Broadcast Test", episodes=[EpisodeIdentifier(season=1, episode=1)], ) # Should complete without errors await download_service.start_queue_processing() await asyncio.sleep(0.5) # Verify download completed successfully status = await download_service.get_queue_status() assert len(status.completed_downloads) == 1 @pytest.mark.asyncio async def test_broadcast_error_handling(self, download_service): """Test that broadcast errors don't break download process.""" error_count = 0 async def failing_broadcast(update_type: str, data: dict): """Broadcast that always fails.""" nonlocal error_count error_count += 1 raise RuntimeError("Broadcast failed") download_service.set_broadcast_callback(failing_broadcast) await download_service.add_to_queue( serie_id="test_serie_6", serie_folder="test_serie_6", serie_name="Error Handling Test", episodes=[EpisodeIdentifier(season=1, episode=1)], ) # Should complete despite broadcast errors await download_service.start_queue_processing() await asyncio.sleep(0.5) # Verify download still completed status = await download_service.get_queue_status() assert len(status.completed_downloads) == 1 # Verify broadcast was attempted assert error_count > 0 @pytest.mark.asyncio async def test_multiple_downloads_broadcast_separately( self, download_service ): """Test that multiple downloads broadcast their progress separately.""" broadcasts: List[Dict[str, Any]] = [] async def mock_broadcast(update_type: str, data: dict): broadcasts.append({"type": update_type, "data": data}) download_service.set_broadcast_callback(mock_broadcast) # Add multiple episodes item_ids = await download_service.add_to_queue( serie_id="test_serie_7", serie_folder="test_serie_7", serie_name="Multi Episode Test", episodes=[ EpisodeIdentifier(season=1, episode=1), EpisodeIdentifier(season=1, episode=2), ], ) assert len(item_ids) == 2 # Start processing await download_service.start_queue_processing() await asyncio.sleep(1.0) # Give time for both downloads # Get progress broadcasts progress_broadcasts = [ b for b in broadcasts if b["type"] == "download_progress" ] # Should have progress for both episodes assert len(progress_broadcasts) >= 4 # At least 2 updates per episode # Verify different download IDs download_ids = set() for broadcast in progress_broadcasts: download_id = ( broadcast["data"].get("download_id") or broadcast["data"].get("item_id") ) if download_id: download_ids.add(download_id) # Should have at least 2 unique download IDs assert len(download_ids) >= 2 @pytest.mark.asyncio async def test_progress_data_format_matches_model(self, download_service): """Test that broadcast data matches DownloadProgress model.""" broadcasts: List[Dict[str, Any]] = [] async def mock_broadcast(update_type: str, data: dict): broadcasts.append({"type": update_type, "data": data}) download_service.set_broadcast_callback(mock_broadcast) await download_service.add_to_queue( serie_id="test_serie_8", serie_folder="test_serie_8", serie_name="Model Test", episodes=[EpisodeIdentifier(season=1, episode=1)], ) await download_service.start_queue_processing() await asyncio.sleep(0.5) progress_broadcasts = [ b for b in broadcasts if b["type"] == "download_progress" ] assert len(progress_broadcasts) > 0 # Verify progress can be parsed as DownloadProgress progress_data = progress_broadcasts[0]["data"]["progress"] progress = DownloadProgress(**progress_data) # Verify required fields assert isinstance(progress.percent, float) assert isinstance(progress.downloaded_mb, float) assert 0 <= progress.percent <= 100 assert progress.downloaded_mb >= 0