"""Unit tests for concurrent scan operation handling. This module tests that the system properly handles multiple simultaneous scan requests, prevents race conditions, and maintains consistent state. """ import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest from src.server.services.scan_service import ( ScanService, ScanServiceError, get_scan_service, reset_scan_service, ) class TestConcurrentScanPrevention: """Test that concurrent scans are properly prevented.""" def setup_method(self): """Reset scan service before each test.""" reset_scan_service() @pytest.mark.asyncio async def test_second_scan_blocked_while_first_running(self): """Test that second scan request is blocked while first is running.""" scan_service = get_scan_service() # Mock scanner scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() scanner.scan = Mock() # Start first scan but keep it running scan1_started = False scan1_can_finish = asyncio.Event() async def slow_scan_simulation(): nonlocal scan1_started scan1_started = True # Don't finish until signaled await scan1_can_finish.wait() # Override progress service methods with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock): # Start first scan in background task1 = asyncio.create_task(scan_service.start_scan(scanner)) # Wait for first scan to acquire lock await asyncio.sleep(0.1) assert scan1_started or scan_service._is_scanning # Try to start second scan - should raise error with pytest.raises(ScanServiceError, match="already in progress"): await scan_service.start_scan(scanner) # Allow first scan to finish scan1_can_finish.set() # Wait for completion await task1 @pytest.mark.asyncio async def test_multiple_scan_attempts_queued(self): """Test that multiple scan attempts properly queue.""" scan_service = get_scan_service() scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() scan_count = 0 lock = asyncio.Lock() async def attempt_scan(): nonlocal scan_count try: async with lock: # Only one scan at a time with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock): await scan_service.start_scan(scanner) scan_count += 1 except ScanServiceError: # Expected for attempts while locked pass # Start 5 concurrent attempts tasks = [asyncio.create_task(attempt_scan()) for _ in range(5)] # Wait for all to complete await asyncio.gather(*tasks, return_exceptions=True) # Only one should have succeeded assert scan_count <= 1, "More than one scan executed concurrently" @pytest.mark.asyncio async def test_scan_lock_released_after_completion(self): """Test that scan lock is properly released after completion.""" scan_service = get_scan_service() scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock): # First scan scan1_id = await scan_service.start_scan(scanner) assert scan1_id is not None # Manually mark as complete async with scan_service._lock: scan_service._is_scanning = False # Second scan should now succeed scan2_id = await scan_service.start_scan(scanner) assert scan2_id is not None assert scan2_id != scan1_id @pytest.mark.asyncio async def test_scan_lock_released_after_error(self): """Test that scan lock is released even after scan completion.""" scan_service = get_scan_service() scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock): # First scan await scan_service.start_scan(scanner) # Manually complete it async with scan_service._lock: scan_service._is_scanning = False # Lock should be released assert not scan_service._is_scanning # Second scan should succeed scan_id = await scan_service.start_scan(scanner) assert scan_id is not None class TestScanCancellation: """Test scan cancellation and interruption handling.""" def setup_method(self): """Reset scan service before each test.""" reset_scan_service() @pytest.mark.asyncio async def test_cancel_active_scan(self): """Test cancelling an active scan.""" scan_service = get_scan_service() scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'fail_progress', new_callable=AsyncMock): # Start scan await scan_service.start_scan(scanner) # Scan should be active assert scan_service.is_scanning # Cancel it result = await scan_service.cancel_scan() # Should return True (cancelled) assert result is True # Scan should no longer be active assert not scan_service.is_scanning # Current scan status should be "cancelled" assert scan_service.current_scan.status == "cancelled" @pytest.mark.asyncio async def test_cancel_when_no_scan_active(self): """Test cancelling when no scan is active.""" scan_service = get_scan_service() # No scan running assert not scan_service.is_scanning # Try to cancel result = await scan_service.cancel_scan() # Should return False (nothing to cancel) assert result is False @pytest.mark.asyncio async def test_cancellation_adds_to_history(self): """Test that cancelled scans are added to history.""" scan_service = get_scan_service() scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'fail_progress', new_callable=AsyncMock): # Start and cancel scan await scan_service.start_scan(scanner) await scan_service.cancel_scan() # Check history history = await scan_service.get_scan_history() assert len(history) > 0 assert history[0]["status"] == "cancelled" @pytest.mark.asyncio async def test_new_scan_after_cancellation(self): """Test that new scan can start after cancellation.""" scan_service = get_scan_service() scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'fail_progress', new_callable=AsyncMock): # First scan scan1_id = await scan_service.start_scan(scanner) await scan_service.cancel_scan() # Should be able to start new scan scan2_id = await scan_service.start_scan(scanner) assert scan2_id != scan1_id class TestDatabaseRaceConditions: """Test prevention of database race conditions during scans.""" def setup_method(self): """Reset scan service before each test.""" reset_scan_service() @pytest.mark.asyncio async def test_scan_service_lock_prevents_race_conditions(self): """Test that asyncio lock prevents race conditions.""" scan_service = get_scan_service() # Verify lock exists assert hasattr(scan_service, '_lock') assert isinstance(scan_service._lock, asyncio.Lock) # Test acquiring lock prevents concurrent access async with scan_service._lock: # Lock is held assert scan_service._lock.locked() # Try to acquire in another context (should timeout) try: await asyncio.wait_for( scan_service._lock.acquire(), timeout=0.1 ) # Should not reach here assert False, "Lock was acquired when it should be held" except asyncio.TimeoutError: # Expected - lock is properly held pass @pytest.mark.asyncio async def test_scan_state_consistency_with_concurrent_reads(self): """Test scan state remains consistent with concurrent reads.""" scan_service = get_scan_service() # Initial state assert not scan_service.is_scanning assert scan_service.current_scan is None # Simulate concurrent state reads async def read_state(): status = await scan_service.get_scan_status() return status["is_scanning"] # Run multiple concurrent reads results = await asyncio.gather(*[read_state() for _ in range(10)]) # All should be consistent assert all(r == False for r in results) @pytest.mark.asyncio async def test_scan_history_thread_safe_updates(self): """Test that scan history updates are thread-safe.""" scan_service = get_scan_service() scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'fail_progress', new_callable=AsyncMock): # Start multiple scans sequentially for i in range(5): await scan_service.start_scan(scanner) await scan_service.cancel_scan() # Read history concurrently async def read_history(): return await scan_service.get_scan_history() histories = await asyncio.gather(*[read_history() for _ in range(10)]) # All reads should return same length lengths = [len(h) for h in histories] assert all(length == lengths[0] for length in lengths) class TestScanStateConsistency: """Test scan state consistency with concurrent requests.""" def setup_method(self): """Reset scan service before each test.""" reset_scan_service() @pytest.mark.asyncio async def test_is_scanning_flag_consistency(self): """Test that is_scanning flag remains consistent.""" scan_service = get_scan_service() scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() # Initial state assert not scan_service.is_scanning with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock): # Start scan await scan_service.start_scan(scanner) assert scan_service.is_scanning # Manually complete it async with scan_service._lock: scan_service._is_scanning = False # State should be consistent assert not scan_service.is_scanning @pytest.mark.asyncio async def test_current_scan_object_consistency(self): """Test that current_scan object remains consistent.""" scan_service = get_scan_service() scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock): # No scan assert scan_service.current_scan is None # Start scan await scan_service.start_scan(scanner) scan_obj = scan_service.current_scan assert scan_obj is not None # Read multiple times - should be same object assert scan_service.current_scan is scan_obj assert scan_service.current_scan is scan_obj @pytest.mark.asyncio async def test_scan_status_api_consistency(self): """Test that scan status API returns consistent data.""" scan_service = get_scan_service() # No scan running status1 = await scan_service.get_scan_status() status2 = await scan_service.get_scan_status() assert status1["is_scanning"] == status2["is_scanning"] assert status1["current_scan"] == status2["current_scan"] @pytest.mark.asyncio async def test_concurrent_status_checks_during_scan(self): """Test concurrent status checks while scan is running.""" scan_service = get_scan_service() scanner = Mock() scanner.subscribe_on_progress = Mock() scanner.subscribe_on_error = Mock() scanner.subscribe_on_completion = Mock() scanner.unsubscribe_on_progress = Mock() scanner.unsubscribe_on_error = Mock() scanner.unsubscribe_on_completion = Mock() with patch.object(scan_service._progress_service, 'start_progress', new_callable=AsyncMock), \ patch.object(scan_service._progress_service, 'update_progress', new_callable=AsyncMock): # Start scan await scan_service.start_scan(scanner) # Check status concurrently async def check_status(): status = await scan_service.get_scan_status() return status["is_scanning"] results = await asyncio.gather(*[check_status() for _ in range(20)]) # All should report scanning assert all(r is True for r in results) class TestSchedulerConcurrentScanPrevention: """Test that scheduler properly prevents concurrent scans.""" @pytest.mark.asyncio async def test_scheduler_skips_rescan_if_already_running(self): """Test scheduler skips scheduled rescan if one is already running.""" from src.server.services.scheduler_service import SchedulerService scheduler = SchedulerService() # Simulate scan in progress scheduler._scan_in_progress = True # Try to perform rescan # Should return immediately without starting new scan with patch('src.server.utils.dependencies.get_anime_service') as mock_get_anime: mock_anime_service = AsyncMock() mock_get_anime.return_value = mock_anime_service await scheduler._perform_rescan() # anime_service.rescan should NOT have been called mock_anime_service.rescan.assert_not_called() @pytest.mark.asyncio async def test_scheduler_sets_flag_during_rescan(self): """Test that scheduler properly sets scan_in_progress flag.""" from src.server.services.scheduler_service import SchedulerService scheduler = SchedulerService() # Initial state assert not scheduler._scan_in_progress # Mock anime service with patch('src.server.utils.dependencies.get_anime_service') as mock_get_anime, \ patch('src.server.services.websocket_service.get_websocket_service') as mock_get_ws: mock_anime_service = AsyncMock() mock_anime_service.rescan = AsyncMock() mock_get_anime.return_value = mock_anime_service mock_ws_service = Mock() mock_ws_service.manager = Mock() mock_ws_service.manager.broadcast = AsyncMock() mock_get_ws.return_value = mock_ws_service # Start rescan rescan_task = asyncio.create_task(scheduler._perform_rescan()) # Wait a bit await asyncio.sleep(0.05) # Flag should be set # (or rescan should be called, indicating flag was properly managed) await rescan_task # After completion, flag should be cleared assert not scheduler._scan_in_progress class TestAnimeServiceScanLock: """Test AnimeService scan lock behavior.""" @pytest.mark.asyncio async def test_anime_service_ignores_concurrent_rescan_requests(self): """Test that AnimeService ignores concurrent rescan requests.""" from src.core.SeriesApp import SeriesApp from src.server.services.anime_service import AnimeService # Mock database mock_db = AsyncMock() # Create service with mocked app mock_app = Mock(spec=SeriesApp) mock_app.directory_to_search = "/test/anime" mock_app.rescan = AsyncMock(return_value=[]) service = AnimeService(mock_app, mock_db) # First rescan - will acquire lock async def long_rescan(): async with service._scan_lock: await asyncio.sleep(0.2) # Start first rescan task1 = asyncio.create_task(long_rescan()) # Wait for lock to be acquired await asyncio.sleep(0.05) # Second rescan attempt - should return immediately # (check that it completes quickly) start_time = asyncio.get_event_loop().time() await service.rescan() # Should return immediately elapsed = asyncio.get_event_loop().time() - start_time # Should have returned almost immediately (< 0.1s) assert elapsed < 0.1, f"Second rescan took {elapsed}s, should return immediately" # Wait for first to finish await task1 # rescan should not have been called on mock_app # (because lock was held) assert mock_app.rescan.call_count == 0