"""Unit tests for ProgressService. This module contains comprehensive tests for the progress tracking service, including progress lifecycle, broadcasting, error handling, and concurrency. """ import asyncio from datetime import datetime from unittest.mock import AsyncMock, MagicMock import pytest from src.server.services.progress_service import ( ProgressService, ProgressServiceError, ProgressStatus, ProgressType, ProgressUpdate, ) class TestProgressUpdate: """Test ProgressUpdate dataclass.""" def test_progress_update_creation(self): """Test creating a progress update.""" update = ProgressUpdate( id="test-1", type=ProgressType.DOWNLOAD, status=ProgressStatus.STARTED, title="Test Download", message="Starting download", total=100, ) assert update.id == "test-1" assert update.type == ProgressType.DOWNLOAD assert update.status == ProgressStatus.STARTED assert update.title == "Test Download" assert update.message == "Starting download" assert update.total == 100 assert update.current == 0 assert update.percent == 0.0 def test_progress_update_to_dict(self): """Test converting progress update to dictionary.""" update = ProgressUpdate( id="test-1", type=ProgressType.SCAN, status=ProgressStatus.IN_PROGRESS, title="Test Scan", message="Scanning files", current=50, total=100, metadata={"test_key": "test_value"}, ) result = update.to_dict() assert result["id"] == "test-1" assert result["type"] == "scan" assert result["status"] == "in_progress" assert result["title"] == "Test Scan" assert result["message"] == "Scanning files" assert result["current"] == 50 assert result["total"] == 100 assert result["percent"] == 0.0 assert result["metadata"]["test_key"] == "test_value" assert "started_at" in result assert "updated_at" in result class TestProgressService: """Test ProgressService class.""" @pytest.fixture def service(self): """Create a fresh ProgressService instance for each test.""" return ProgressService() @pytest.fixture def mock_broadcast(self): """Create a mock broadcast callback.""" return AsyncMock() @pytest.mark.asyncio async def test_start_progress(self, service): """Test starting a new progress operation.""" update = await service.start_progress( progress_id="download-1", progress_type=ProgressType.DOWNLOAD, title="Downloading episode", total=1000, message="Starting...", metadata={"episode": "S01E01"}, ) assert update.id == "download-1" assert update.type == ProgressType.DOWNLOAD assert update.status == ProgressStatus.STARTED assert update.title == "Downloading episode" assert update.total == 1000 assert update.message == "Starting..." assert update.metadata["episode"] == "S01E01" @pytest.mark.asyncio async def test_start_progress_duplicate_id(self, service): """Test starting progress with duplicate ID raises error.""" await service.start_progress( progress_id="test-1", progress_type=ProgressType.DOWNLOAD, title="Test", ) with pytest.raises(ProgressServiceError, match="already exists"): await service.start_progress( progress_id="test-1", progress_type=ProgressType.DOWNLOAD, title="Test Duplicate", ) @pytest.mark.asyncio async def test_update_progress(self, service): """Test updating an existing progress operation.""" await service.start_progress( progress_id="test-1", progress_type=ProgressType.DOWNLOAD, title="Test", total=100, ) update = await service.update_progress( progress_id="test-1", current=50, message="Half way", ) assert update.current == 50 assert update.total == 100 assert update.percent == 50.0 assert update.message == "Half way" assert update.status == ProgressStatus.IN_PROGRESS @pytest.mark.asyncio async def test_update_progress_not_found(self, service): """Test updating non-existent progress raises error.""" with pytest.raises(ProgressServiceError, match="not found"): await service.update_progress( progress_id="nonexistent", current=50, ) @pytest.mark.asyncio async def test_update_progress_percentage_calculation(self, service): """Test progress percentage is calculated correctly.""" await service.start_progress( progress_id="test-1", progress_type=ProgressType.DOWNLOAD, title="Test", total=200, ) await service.update_progress(progress_id="test-1", current=50) update = await service.get_progress("test-1") assert update.percent == 25.0 await service.update_progress(progress_id="test-1", current=100) update = await service.get_progress("test-1") assert update.percent == 50.0 await service.update_progress(progress_id="test-1", current=200) update = await service.get_progress("test-1") assert update.percent == 100.0 @pytest.mark.asyncio async def test_complete_progress(self, service): """Test completing a progress operation.""" await service.start_progress( progress_id="test-1", progress_type=ProgressType.SCAN, title="Test Scan", total=100, ) await service.update_progress(progress_id="test-1", current=50) update = await service.complete_progress( progress_id="test-1", message="Scan completed successfully", metadata={"items_found": 42}, ) assert update.status == ProgressStatus.COMPLETED assert update.percent == 100.0 assert update.current == update.total assert update.message == "Scan completed successfully" assert update.metadata["items_found"] == 42 # Should be moved to history active_progress = await service.get_all_active_progress() assert "test-1" not in active_progress @pytest.mark.asyncio async def test_fail_progress(self, service): """Test failing a progress operation.""" await service.start_progress( progress_id="test-1", progress_type=ProgressType.DOWNLOAD, title="Test Download", ) update = await service.fail_progress( progress_id="test-1", error_message="Network timeout", metadata={"retry_count": 3}, ) assert update.status == ProgressStatus.FAILED assert update.message == "Network timeout" assert update.metadata["retry_count"] == 3 # Should be moved to history active_progress = await service.get_all_active_progress() assert "test-1" not in active_progress @pytest.mark.asyncio async def test_cancel_progress(self, service): """Test cancelling a progress operation.""" await service.start_progress( progress_id="test-1", progress_type=ProgressType.DOWNLOAD, title="Test Download", ) update = await service.cancel_progress( progress_id="test-1", message="Cancelled by user", ) assert update.status == ProgressStatus.CANCELLED assert update.message == "Cancelled by user" # Should be moved to history active_progress = await service.get_all_active_progress() assert "test-1" not in active_progress @pytest.mark.asyncio async def test_get_progress(self, service): """Test retrieving progress by ID.""" await service.start_progress( progress_id="test-1", progress_type=ProgressType.SCAN, title="Test", ) progress = await service.get_progress("test-1") assert progress is not None assert progress.id == "test-1" # Test non-existent progress progress = await service.get_progress("nonexistent") assert progress is None @pytest.mark.asyncio async def test_get_all_active_progress(self, service): """Test retrieving all active progress operations.""" await service.start_progress( progress_id="download-1", progress_type=ProgressType.DOWNLOAD, title="Download 1", ) await service.start_progress( progress_id="download-2", progress_type=ProgressType.DOWNLOAD, title="Download 2", ) await service.start_progress( progress_id="scan-1", progress_type=ProgressType.SCAN, title="Scan 1", ) all_progress = await service.get_all_active_progress() assert len(all_progress) == 3 assert "download-1" in all_progress assert "download-2" in all_progress assert "scan-1" in all_progress @pytest.mark.asyncio async def test_get_all_active_progress_filtered(self, service): """Test retrieving active progress filtered by type.""" await service.start_progress( progress_id="download-1", progress_type=ProgressType.DOWNLOAD, title="Download 1", ) await service.start_progress( progress_id="download-2", progress_type=ProgressType.DOWNLOAD, title="Download 2", ) await service.start_progress( progress_id="scan-1", progress_type=ProgressType.SCAN, title="Scan 1", ) download_progress = await service.get_all_active_progress( progress_type=ProgressType.DOWNLOAD ) assert len(download_progress) == 2 assert "download-1" in download_progress assert "download-2" in download_progress assert "scan-1" not in download_progress @pytest.mark.asyncio async def test_history_management(self, service): """Test progress history is maintained with size limit.""" # Start and complete multiple progress operations for i in range(60): # More than max_history_size (50) await service.start_progress( progress_id=f"test-{i}", progress_type=ProgressType.DOWNLOAD, title=f"Test {i}", ) await service.complete_progress( progress_id=f"test-{i}", message="Completed", ) # Check that oldest entries were removed history = service._history assert len(history) <= 50 # Most recent should be in history recent_progress = await service.get_progress("test-59") assert recent_progress is not None @pytest.mark.asyncio async def test_broadcast_callback(self, service, mock_broadcast): """Test broadcast callback is invoked correctly.""" service.set_broadcast_callback(mock_broadcast) await service.start_progress( progress_id="test-1", progress_type=ProgressType.DOWNLOAD, title="Test", ) # Verify callback was called for start mock_broadcast.assert_called_once() call_args = mock_broadcast.call_args assert call_args[1]["message_type"] == "download_progress" assert call_args[1]["room"] == "download_progress" assert "test-1" in str(call_args[1]["data"]) @pytest.mark.asyncio async def test_broadcast_on_update(self, service, mock_broadcast): """Test broadcast on progress update.""" service.set_broadcast_callback(mock_broadcast) await service.start_progress( progress_id="test-1", progress_type=ProgressType.DOWNLOAD, title="Test", total=100, ) mock_broadcast.reset_mock() # Update with significant change (>1%) await service.update_progress( progress_id="test-1", current=50, force_broadcast=True, ) # Should have been called assert mock_broadcast.call_count >= 1 @pytest.mark.asyncio async def test_broadcast_on_complete(self, service, mock_broadcast): """Test broadcast on progress completion.""" service.set_broadcast_callback(mock_broadcast) await service.start_progress( progress_id="test-1", progress_type=ProgressType.SCAN, title="Test", ) mock_broadcast.reset_mock() await service.complete_progress( progress_id="test-1", message="Done", ) # Should have been called mock_broadcast.assert_called_once() call_args = mock_broadcast.call_args assert "completed" in str(call_args[1]["data"]).lower() @pytest.mark.asyncio async def test_broadcast_on_failure(self, service, mock_broadcast): """Test broadcast on progress failure.""" service.set_broadcast_callback(mock_broadcast) await service.start_progress( progress_id="test-1", progress_type=ProgressType.DOWNLOAD, title="Test", ) mock_broadcast.reset_mock() await service.fail_progress( progress_id="test-1", error_message="Test error", ) # Should have been called mock_broadcast.assert_called_once() call_args = mock_broadcast.call_args assert "failed" in str(call_args[1]["data"]).lower() @pytest.mark.asyncio async def test_clear_history(self, service): """Test clearing progress history.""" # Create and complete some progress for i in range(5): await service.start_progress( progress_id=f"test-{i}", progress_type=ProgressType.DOWNLOAD, title=f"Test {i}", ) await service.complete_progress( progress_id=f"test-{i}", message="Done", ) # History should not be empty assert len(service._history) > 0 # Clear history await service.clear_history() # History should now be empty assert len(service._history) == 0 @pytest.mark.asyncio async def test_concurrent_progress_operations(self, service): """Test handling multiple concurrent progress operations.""" async def create_and_complete_progress(id_num: int): """Helper to create and complete a progress.""" await service.start_progress( progress_id=f"test-{id_num}", progress_type=ProgressType.DOWNLOAD, title=f"Test {id_num}", total=100, ) for i in range(0, 101, 10): await service.update_progress( progress_id=f"test-{id_num}", current=i, ) await asyncio.sleep(0.01) await service.complete_progress( progress_id=f"test-{id_num}", message="Done", ) # Run multiple concurrent operations tasks = [create_and_complete_progress(i) for i in range(10)] await asyncio.gather(*tasks) # All should be in history for i in range(10): progress = await service.get_progress(f"test-{i}") assert progress is not None assert progress.status == ProgressStatus.COMPLETED @pytest.mark.asyncio async def test_update_with_metadata(self, service): """Test updating progress with metadata.""" await service.start_progress( progress_id="test-1", progress_type=ProgressType.DOWNLOAD, title="Test", metadata={"initial": "value"}, ) await service.update_progress( progress_id="test-1", current=50, metadata={"additional": "data", "speed": 1.5}, ) progress = await service.get_progress("test-1") assert progress.metadata["initial"] == "value" assert progress.metadata["additional"] == "data" assert progress.metadata["speed"] == 1.5