"""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.""" # Subscribe to progress_updated events service.subscribe("progress_updated", 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() # First positional arg is ProgressEvent call_args = mock_broadcast.call_args[0][0] assert call_args.event_type == "download_progress" assert call_args.room == "downloads" # Room name for DOWNLOAD type assert call_args.progress_id == "test-1" assert call_args.progress.id == "test-1" @pytest.mark.asyncio async def test_broadcast_on_update(self, service, mock_broadcast): """Test broadcast on progress update.""" # Subscribe to progress_updated events service.subscribe("progress_updated", 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 # First positional arg is ProgressEvent call_args = mock_broadcast.call_args[0][0] assert call_args.progress.current == 50 @pytest.mark.asyncio async def test_broadcast_on_complete(self, service, mock_broadcast): """Test broadcast on progress completion.""" # Subscribe to progress_updated events service.subscribe("progress_updated", 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() # First positional arg is ProgressEvent call_args = mock_broadcast.call_args[0][0] assert call_args.progress.status.value == "completed" @pytest.mark.asyncio async def test_broadcast_on_failure(self, service, mock_broadcast): """Test broadcast on progress failure.""" # Subscribe to progress_updated events service.subscribe("progress_updated", 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() # First positional arg is ProgressEvent call_args = mock_broadcast.call_args[0][0] assert call_args.progress.status.value == "failed" @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 @pytest.mark.asyncio async def test_progress_with_key_and_folder(self, service): """Test progress tracking with series key and folder.""" # Start progress with key and folder update = await service.start_progress( progress_id="download-series-1", progress_type=ProgressType.DOWNLOAD, title="Downloading Attack on Titan", key="attack-on-titan", folder="Attack on Titan (2013)", total=100, ) assert update.key == "attack-on-titan" assert update.folder == "Attack on Titan (2013)" # Verify to_dict includes key and folder dict_repr = update.to_dict() assert dict_repr["key"] == "attack-on-titan" assert dict_repr["folder"] == "Attack on Titan (2013)" # Update progress and verify key/folder are preserved updated = await service.update_progress( progress_id="download-series-1", current=50, ) assert updated.key == "attack-on-titan" assert updated.folder == "Attack on Titan (2013)" @pytest.mark.asyncio async def test_progress_update_key_and_folder(self, service): """Test updating key and folder in existing progress.""" # Start without key/folder await service.start_progress( progress_id="test-1", progress_type=ProgressType.SCAN, title="Test Scan", ) # Update with key and folder updated = await service.update_progress( progress_id="test-1", key="one-piece", folder="One Piece (1999)", current=10, ) assert updated.key == "one-piece" assert updated.folder == "One Piece (1999)" # Verify to_dict includes the fields dict_repr = updated.to_dict() assert dict_repr["key"] == "one-piece" assert dict_repr["folder"] == "One Piece (1999)" def test_progress_update_to_dict_without_key_folder(self): """Test to_dict doesn't include key/folder if not set.""" update = ProgressUpdate( id="test-1", type=ProgressType.SYSTEM, status=ProgressStatus.STARTED, title="System Task", ) result = update.to_dict() # key and folder should not be in dict if not set assert "key" not in result assert "folder" not in result def test_progress_update_creation_with_key_folder(self): """Test creating progress update with key and folder.""" update = ProgressUpdate( id="test-1", type=ProgressType.DOWNLOAD, status=ProgressStatus.STARTED, title="Test Download", key="naruto", folder="Naruto (2002)", total=100, ) assert update.key == "naruto" assert update.folder == "Naruto (2002)" # Verify to_dict includes them result = update.to_dict() assert result["key"] == "naruto" assert result["folder"] == "Naruto (2002)"