"""Unit tests for ScanService. This module contains comprehensive tests for the scan service, including scan lifecycle, progress events, and key-based identification. """ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock import pytest from src.server.services.scan_service import ( ScanProgress, ScanService, ScanServiceError, get_scan_service, reset_scan_service, ) class TestScanProgress: """Test ScanProgress class.""" def test_scan_progress_creation(self): """Test creating a scan progress instance.""" progress = ScanProgress("scan-123") assert progress.scan_id == "scan-123" assert progress.status == "started" assert progress.current == 0 assert progress.total == 0 assert progress.percentage == 0.0 assert progress.message == "Initializing scan..." assert progress.key is None assert progress.folder is None assert progress.series_found == 0 assert progress.errors == [] assert isinstance(progress.started_at, datetime) assert isinstance(progress.updated_at, datetime) def test_scan_progress_to_dict_basic(self): """Test converting scan progress to dictionary without key/folder.""" progress = ScanProgress("scan-123") progress.current = 5 progress.total = 10 progress.percentage = 50.0 progress.status = "in_progress" progress.message = "Scanning..." result = progress.to_dict() assert result["scan_id"] == "scan-123" assert result["status"] == "in_progress" assert result["current"] == 5 assert result["total"] == 10 assert result["percentage"] == 50.0 assert result["message"] == "Scanning..." assert result["series_found"] == 0 assert result["errors"] == [] assert "started_at" in result assert "updated_at" in result # key and folder should not be present when None assert "key" not in result assert "folder" not in result def test_scan_progress_to_dict_with_key_and_folder(self): """Test converting scan progress to dictionary with key and folder.""" progress = ScanProgress("scan-123") progress.key = "attack-on-titan" progress.folder = "Attack on Titan (2013)" progress.series_found = 5 result = progress.to_dict() assert result["key"] == "attack-on-titan" assert result["folder"] == "Attack on Titan (2013)" assert result["series_found"] == 5 def test_scan_progress_to_dict_with_errors(self): """Test scan progress with error messages.""" progress = ScanProgress("scan-123") progress.errors = ["Error 1", "Error 2"] result = progress.to_dict() assert result["errors"] == ["Error 1", "Error 2"] class TestScanServiceProgressCallback: """Test ScanServiceProgressCallback class.""" @pytest.fixture def mock_service(self): """Create a mock ScanService.""" service = MagicMock(spec=ScanService) service._handle_progress_update = AsyncMock() return service @pytest.fixture def scan_progress(self): """Create a scan progress instance.""" return ScanProgress("scan-123") def test_on_progress_updates_progress(self, mock_service, scan_progress): """Test that on_progress updates scan progress correctly.""" callback = ScanServiceProgressCallback(mock_service, scan_progress) context = ProgressContext( operation_type=OperationType.SCAN, operation_id="scan-123", phase=ProgressPhase.IN_PROGRESS, current=5, total=10, percentage=50.0, message="Scanning: Test Folder", key="test-series", folder="Test Folder", ) # Call directly - no event loop needed since we handle RuntimeError callback.on_progress(context) assert scan_progress.current == 5 assert scan_progress.total == 10 assert scan_progress.percentage == 50.0 assert scan_progress.message == "Scanning: Test Folder" assert scan_progress.key == "test-series" assert scan_progress.folder == "Test Folder" assert scan_progress.status == "in_progress" def test_on_progress_starting_phase(self, mock_service, scan_progress): """Test progress callback with STARTING phase.""" callback = ScanServiceProgressCallback(mock_service, scan_progress) context = ProgressContext( operation_type=OperationType.SCAN, operation_id="scan-123", phase=ProgressPhase.STARTING, current=0, total=0, percentage=0.0, message="Initializing...", ) callback.on_progress(context) assert scan_progress.status == "started" def test_on_progress_completed_phase(self, mock_service, scan_progress): """Test progress callback with COMPLETED phase.""" callback = ScanServiceProgressCallback(mock_service, scan_progress) context = ProgressContext( operation_type=OperationType.SCAN, operation_id="scan-123", phase=ProgressPhase.COMPLETED, current=10, total=10, percentage=100.0, message="Scan completed", ) callback.on_progress(context) assert scan_progress.status == "completed" class TestScanServiceErrorCallback: """Test ScanServiceErrorCallback class.""" @pytest.fixture def mock_service(self): """Create a mock ScanService.""" service = MagicMock(spec=ScanService) service._handle_scan_error = AsyncMock() return service @pytest.fixture def scan_progress(self): """Create a scan progress instance.""" return ScanProgress("scan-123") def test_on_error_adds_error_message(self, mock_service, scan_progress): """Test that on_error adds error to scan progress.""" callback = ScanServiceErrorCallback(mock_service, scan_progress) error = ValueError("Test error") context = ErrorContext( operation_type=OperationType.SCAN, operation_id="scan-123", error=error, message="Failed to process folder", recoverable=True, key="test-series", folder="Test Folder", ) callback.on_error(context) assert len(scan_progress.errors) == 1 assert "[Test Folder]" in scan_progress.errors[0] assert "Failed to process folder" in scan_progress.errors[0] def test_on_error_without_folder(self, mock_service, scan_progress): """Test error callback without folder information.""" callback = ScanServiceErrorCallback(mock_service, scan_progress) error = ValueError("Test error") context = ErrorContext( operation_type=OperationType.SCAN, operation_id="scan-123", error=error, message="Generic error", recoverable=False, ) callback.on_error(context) assert len(scan_progress.errors) == 1 assert scan_progress.errors[0] == "Generic error" class TestScanServiceCompletionCallback: """Test ScanServiceCompletionCallback class.""" @pytest.fixture def mock_service(self): """Create a mock ScanService.""" service = MagicMock(spec=ScanService) service._handle_scan_completion = AsyncMock() return service @pytest.fixture def scan_progress(self): """Create a scan progress instance.""" return ScanProgress("scan-123") def test_on_completion_success(self, mock_service, scan_progress): """Test completion callback with success.""" callback = ScanServiceCompletionCallback(mock_service, scan_progress) context = CompletionContext( operation_type=OperationType.SCAN, operation_id="scan-123", success=True, message="Scan completed successfully", statistics={"series_found": 10, "total_folders": 15}, ) callback.on_completion(context) assert scan_progress.status == "completed" assert scan_progress.message == "Scan completed successfully" assert scan_progress.series_found == 10 def test_on_completion_failure(self, mock_service, scan_progress): """Test completion callback with failure.""" callback = ScanServiceCompletionCallback(mock_service, scan_progress) context = CompletionContext( operation_type=OperationType.SCAN, operation_id="scan-123", success=False, message="Scan failed: critical error", ) callback.on_completion(context) assert scan_progress.status == "failed" assert scan_progress.message == "Scan failed: critical error" class TestScanService: """Test ScanService class.""" @pytest.fixture def mock_progress_service(self): """Create a mock progress service.""" service = MagicMock() service.start_progress = AsyncMock() service.update_progress = AsyncMock() service.complete_progress = AsyncMock() service.fail_progress = AsyncMock() return service @pytest.fixture def service(self, mock_progress_service): """Create a ScanService instance for each test.""" return ScanService(progress_service=mock_progress_service) @pytest.mark.asyncio async def test_service_initialization(self, service): """Test ScanService initialization.""" assert service.is_scanning is False assert service.current_scan is None assert service._scan_history == [] @pytest.mark.asyncio async def test_start_scan(self, service): """Test starting a new scan.""" scanner_factory = MagicMock() scan_id = await service.start_scan(scanner_factory) assert scan_id is not None assert len(scan_id) > 0 assert service.is_scanning is True assert service.current_scan is not None assert service.current_scan.scan_id == scan_id @pytest.mark.asyncio async def test_start_scan_while_scanning(self, service): """Test starting scan while another is in progress raises error.""" scanner_factory = MagicMock() await service.start_scan(scanner_factory) with pytest.raises(ScanServiceError, match="already in progress"): await service.start_scan(scanner_factory) @pytest.mark.asyncio async def test_cancel_scan(self, service): """Test cancelling a scan in progress.""" scanner_factory = MagicMock() scan_id = await service.start_scan(scanner_factory) result = await service.cancel_scan() assert result is True assert service.is_scanning is False assert service.current_scan.status == "cancelled" assert len(service._scan_history) == 1 @pytest.mark.asyncio async def test_cancel_scan_no_scan_in_progress(self, service): """Test cancelling when no scan is in progress.""" result = await service.cancel_scan() assert result is False @pytest.mark.asyncio async def test_get_scan_status(self, service): """Test getting scan status.""" status = await service.get_scan_status() assert status["is_scanning"] is False assert status["current_scan"] is None @pytest.mark.asyncio async def test_get_scan_status_while_scanning(self, service): """Test getting scan status while scanning.""" scanner_factory = MagicMock() scan_id = await service.start_scan(scanner_factory) status = await service.get_scan_status() assert status["is_scanning"] is True assert status["current_scan"] is not None assert status["current_scan"]["scan_id"] == scan_id @pytest.mark.asyncio async def test_get_scan_history_empty(self, service): """Test getting scan history when empty.""" history = await service.get_scan_history() assert history == [] @pytest.mark.asyncio async def test_get_scan_history_with_entries(self, service): """Test getting scan history with entries.""" # Start and cancel multiple scans to populate history scanner_factory = MagicMock() await service.start_scan(scanner_factory) await service.cancel_scan() await service.start_scan(scanner_factory) await service.cancel_scan() history = await service.get_scan_history() assert len(history) == 2 # Should be newest first assert history[0]["status"] == "cancelled" @pytest.mark.asyncio async def test_get_scan_history_limit(self, service): """Test scan history respects limit.""" scanner_factory = MagicMock() # Create 3 history entries for _ in range(3): await service.start_scan(scanner_factory) await service.cancel_scan() history = await service.get_scan_history(limit=2) assert len(history) == 2 @pytest.mark.asyncio async def test_subscribe_to_scan_events(self, service): """Test subscribing to scan events.""" handler = MagicMock() service.subscribe_to_scan_events(handler) assert handler in service._scan_event_handlers @pytest.mark.asyncio async def test_unsubscribe_from_scan_events(self, service): """Test unsubscribing from scan events.""" handler = MagicMock() service.subscribe_to_scan_events(handler) service.unsubscribe_from_scan_events(handler) assert handler not in service._scan_event_handlers @pytest.mark.asyncio async def test_emit_scan_event(self, service): """Test emitting scan events to handlers.""" handler = AsyncMock() service.subscribe_to_scan_events(handler) await service._emit_scan_event({ "type": "scan_progress", "key": "test-series", "folder": "Test Folder", }) handler.assert_called_once_with({ "type": "scan_progress", "key": "test-series", "folder": "Test Folder", }) @pytest.mark.asyncio async def test_emit_scan_event_sync_handler(self, service): """Test emitting scan events to sync handlers.""" handler = MagicMock() service.subscribe_to_scan_events(handler) await service._emit_scan_event({ "type": "scan_progress", "data": {"key": "test-series"}, }) handler.assert_called_once() @pytest.mark.asyncio async def test_create_callback_manager(self, service): """Test creating a callback manager.""" scanner_factory = MagicMock() await service.start_scan(scanner_factory) callback_manager = service.create_callback_manager() assert callback_manager is not None assert isinstance(callback_manager, CallbackManager) @pytest.mark.asyncio async def test_create_callback_manager_no_current_scan(self, service): """Test creating callback manager without current scan.""" callback_manager = service.create_callback_manager() assert callback_manager is not None assert service.current_scan is not None @pytest.mark.asyncio async def test_handle_progress_update( self, service, mock_progress_service ): """Test handling progress update.""" scanner_factory = MagicMock() await service.start_scan(scanner_factory) scan_progress = service.current_scan scan_progress.current = 5 scan_progress.total = 10 scan_progress.percentage = 50.0 scan_progress.message = "Processing..." scan_progress.key = "test-series" scan_progress.folder = "Test Folder" await service._handle_progress_update(scan_progress) mock_progress_service.update_progress.assert_called_once() call_kwargs = mock_progress_service.update_progress.call_args.kwargs assert call_kwargs["key"] == "test-series" assert call_kwargs["folder"] == "Test Folder" @pytest.mark.asyncio async def test_handle_scan_error(self, service): """Test handling scan error.""" handler = AsyncMock() service.subscribe_to_scan_events(handler) scanner_factory = MagicMock() await service.start_scan(scanner_factory) scan_progress = service.current_scan error_context = ErrorContext( operation_type=OperationType.SCAN, operation_id=scan_progress.scan_id, error=ValueError("Test error"), message="Test error message", recoverable=True, key="test-series", folder="Test Folder", ) await service._handle_scan_error(scan_progress, error_context) # Handler is called twice: once for start, once for error assert handler.call_count == 2 # Get the error event (second call) error_event = handler.call_args_list[1][0][0] assert error_event["type"] == "scan_error" assert error_event["key"] == "test-series" assert error_event["folder"] == "Test Folder" @pytest.mark.asyncio async def test_handle_scan_completion_success( self, service, mock_progress_service ): """Test handling successful scan completion.""" handler = AsyncMock() service.subscribe_to_scan_events(handler) scanner_factory = MagicMock() scan_id = await service.start_scan(scanner_factory) scan_progress = service.current_scan completion_context = CompletionContext( operation_type=OperationType.SCAN, operation_id=scan_id, success=True, message="Scan completed", statistics={"series_found": 5, "total_folders": 10}, ) await service._handle_scan_completion( scan_progress, completion_context ) assert service.is_scanning is False assert len(service._scan_history) == 1 mock_progress_service.complete_progress.assert_called_once() # Handler is called twice: once for start, once for completion assert handler.call_count == 2 # Get the completion event (second call) completion_event = handler.call_args_list[1][0][0] assert completion_event["type"] == "scan_completed" assert completion_event["success"] is True @pytest.mark.asyncio async def test_handle_scan_completion_failure( self, service, mock_progress_service ): """Test handling failed scan completion.""" handler = AsyncMock() service.subscribe_to_scan_events(handler) scanner_factory = MagicMock() scan_id = await service.start_scan(scanner_factory) scan_progress = service.current_scan completion_context = CompletionContext( operation_type=OperationType.SCAN, operation_id=scan_id, success=False, message="Scan failed: critical error", ) await service._handle_scan_completion( scan_progress, completion_context ) assert service.is_scanning is False mock_progress_service.fail_progress.assert_called_once() # Handler is called twice: once for start, once for failure assert handler.call_count == 2 # Get the failure event (second call) failure_event = handler.call_args_list[1][0][0] assert failure_event["type"] == "scan_failed" assert failure_event["success"] is False class TestScanServiceSingleton: """Test ScanService singleton functions.""" def test_get_scan_service_returns_singleton(self): """Test that get_scan_service returns a singleton.""" reset_scan_service() service1 = get_scan_service() service2 = get_scan_service() assert service1 is service2 def test_reset_scan_service(self): """Test that reset_scan_service clears the singleton.""" reset_scan_service() service1 = get_scan_service() reset_scan_service() service2 = get_scan_service() assert service1 is not service2 class TestScanServiceKeyIdentification: """Test that ScanService uses key as primary identifier.""" @pytest.fixture def mock_progress_service(self): """Create a mock progress service.""" service = MagicMock() service.start_progress = AsyncMock() service.update_progress = AsyncMock() service.complete_progress = AsyncMock() service.fail_progress = AsyncMock() return service @pytest.fixture def service(self, mock_progress_service): """Create a ScanService instance.""" return ScanService(progress_service=mock_progress_service) @pytest.mark.asyncio async def test_progress_update_includes_key( self, service, mock_progress_service ): """Test that progress updates include key as primary identifier.""" scanner_factory = MagicMock() await service.start_scan(scanner_factory) scan_progress = service.current_scan scan_progress.key = "attack-on-titan" scan_progress.folder = "Attack on Titan (2013)" await service._handle_progress_update(scan_progress) call_kwargs = mock_progress_service.update_progress.call_args.kwargs assert call_kwargs["key"] == "attack-on-titan" assert call_kwargs["folder"] == "Attack on Titan (2013)" @pytest.mark.asyncio async def test_scan_event_includes_key(self, service): """Test that scan events include key as primary identifier.""" events_received = [] async def capture_event(event): events_received.append(event) service.subscribe_to_scan_events(capture_event) await service._emit_scan_event({ "type": "scan_progress", "key": "my-hero-academia", "folder": "My Hero Academia (2016)", "data": {"status": "in_progress"}, }) assert len(events_received) == 1 assert events_received[0]["key"] == "my-hero-academia" assert events_received[0]["folder"] == "My Hero Academia (2016)" @pytest.mark.asyncio async def test_error_event_includes_key(self, service): """Test that error events include key as primary identifier.""" events_received = [] async def capture_event(event): events_received.append(event) service.subscribe_to_scan_events(capture_event) scanner_factory = MagicMock() await service.start_scan(scanner_factory) scan_progress = service.current_scan error_context = ErrorContext( operation_type=OperationType.SCAN, operation_id=scan_progress.scan_id, error=ValueError("Test"), message="Error message", key="demon-slayer", folder="Demon Slayer (2019)", ) await service._handle_scan_error(scan_progress, error_context) assert len(events_received) == 2 # Started + error error_event = events_received[1] assert error_event["type"] == "scan_error" assert error_event["key"] == "demon-slayer" assert error_event["folder"] == "Demon Slayer (2019)" @pytest.mark.asyncio async def test_scan_status_includes_key(self, service): """Test that scan status includes key in current scan.""" scanner_factory = MagicMock() await service.start_scan(scanner_factory) service.current_scan.key = "one-piece" service.current_scan.folder = "One Piece (1999)" status = await service.get_scan_status() assert status["current_scan"]["key"] == "one-piece" assert status["current_scan"]["folder"] == "One Piece (1999)" @pytest.mark.asyncio async def test_scan_history_includes_key(self, service): """Test that scan history includes key in entries.""" scanner_factory = MagicMock() await service.start_scan(scanner_factory) service.current_scan.key = "naruto" service.current_scan.folder = "Naruto (2002)" await service.cancel_scan() history = await service.get_scan_history() assert len(history) == 1 assert history[0]["key"] == "naruto" assert history[0]["folder"] == "Naruto (2002)"