"""Integration tests for NFO batch workflow. This module tests end-to-end batch NFO workflows including: - Creating NFO files for 10+ series simultaneously - Media file download (poster, logo, fanart) in batch - TMDB API rate limiting during batch operations - WebSocket progress notifications during batch operations """ import asyncio from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import pytest from src.core.entities.series import Serie from src.core.services.nfo_service import NFOService from src.server.api.nfo import batch_create_nfo from src.server.models.nfo import NFOBatchCreateRequest @pytest.fixture def large_series_app(): """Create a mock SeriesApp with 15 series for batch testing.""" app = Mock() series = [] for i in range(15): serie = Mock(spec=Serie) serie.key = f"anime{i:02d}" serie.folder = f"Anime {i:02d}" serie.name = f"Test Anime {i:02d}" serie.year = 2020 + (i % 5) serie.ensure_folder_with_year = Mock( return_value=f"Anime {i:02d} ({2020 + (i % 5)})" ) series.append(serie) app.list = Mock() app.list.GetList = Mock(return_value=series) return app @pytest.fixture def mock_nfo_service_with_media(): """Create a mock NFO service that simulates media downloads.""" service = Mock(spec=NFOService) service.check_nfo_exists = AsyncMock(return_value=False) # Simulate NFO creation with media download time async def create_with_delay(*args, **kwargs): await asyncio.sleep(0.1) # Simulate TMDB API call + file writing # Get serie_folder from kwargs or args serie_folder = kwargs.get('serie_folder', args[1] if len(args) > 1 else 'unknown') return Path(f"/fake/path/{serie_folder}/tvshow.nfo") service.create_tvshow_nfo = AsyncMock(side_effect=create_with_delay) return service @pytest.fixture def mock_settings(): """Create mock settings.""" with patch("src.server.api.nfo.settings") as mock: mock.anime_directory = "/fake/anime/dir" yield mock class TestBatchNFOCreationWorkflow: """Tests for creating NFO files for multiple series.""" @pytest.mark.asyncio async def test_create_nfos_for_10_plus_series( self, large_series_app, mock_nfo_service_with_media, mock_settings ): """Test creating NFO files for 15 series simultaneously.""" request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(15)], download_media=True, skip_existing=False, max_concurrent=5 ) import time start_time = time.time() with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=mock_nfo_service_with_media ) elapsed_time = time.time() - start_time # Verify all created successfully assert result.total == 15 assert result.successful == 15 assert result.failed == 0 # Verify all results present assert len(result.results) == 15 # Verify NFO paths are set for res in result.results: assert res.success assert res.nfo_path is not None assert "tvshow.nfo" in res.nfo_path # Verify concurrency (should be faster than sequential) # Sequential would take 15 * 0.1 = 1.5s # With max_concurrent=5, should take ~0.3s (3 batches) assert elapsed_time < 1.0 # Allow some overhead @pytest.mark.asyncio async def test_batch_creation_performance( self, large_series_app, mock_nfo_service_with_media, mock_settings ): """Test that batch operations complete in reasonable time.""" request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(10)], max_concurrent=3, skip_existing=False ) import time start_time = time.time() with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=mock_nfo_service_with_media ) elapsed_time = time.time() - start_time assert result.successful == 10 # Should complete in under 0.5s with max_concurrent=3 # (10 series / 3 concurrent = 4 batches * 0.1s = 0.4s + overhead) assert elapsed_time < 0.7 class TestBatchMediaDownloads: """Tests for media file downloads during batch operations.""" @pytest.mark.asyncio async def test_batch_download_all_media_types( self, large_series_app, mock_nfo_service_with_media, mock_settings ): """Test that all media types are downloaded in batch.""" request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(5)], download_media=True, skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=mock_nfo_service_with_media ) # Verify all series processed assert result.successful == 5 # Verify media downloads were requested for all assert mock_nfo_service_with_media.create_tvshow_nfo.call_count == 5 for call in mock_nfo_service_with_media.create_tvshow_nfo.call_args_list: kwargs = call[1] assert kwargs["download_poster"] is True assert kwargs["download_logo"] is True assert kwargs["download_fanart"] is True @pytest.mark.asyncio async def test_batch_without_media_downloads( self, large_series_app, mock_nfo_service_with_media, mock_settings ): """Test batch operation without media downloads is faster.""" # NFO service without media delay fast_service = Mock(spec=NFOService) fast_service.check_nfo_exists = AsyncMock(return_value=False) fast_service.create_tvshow_nfo = AsyncMock( return_value=Path("/fake/path/tvshow.nfo") ) request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(10)], download_media=False, skip_existing=False, max_concurrent=5 ) import time start_time = time.time() with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=fast_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=fast_service ) elapsed_time = time.time() - start_time assert result.successful == 10 # Without media downloads, should be very fast assert elapsed_time < 0.3 # Verify no media was requested for call in fast_service.create_tvshow_nfo.call_args_list: kwargs = call[1] assert kwargs["download_poster"] is False assert kwargs["download_logo"] is False assert kwargs["download_fanart"] is False @pytest.mark.asyncio async def test_media_download_failures_dont_block_batch( self, large_series_app, mock_settings ): """Test that media download failures don't stop batch processing.""" service = Mock(spec=NFOService) service.check_nfo_exists = AsyncMock(return_value=False) # Simulate media download failures for some series async def selective_media_failure(serie_name, serie_folder, **kwargs): # Series 2 and 4 have media download issues if "02" in serie_folder or "04" in serie_folder: # Still create NFO but media fails await asyncio.sleep(0.05) return Path(f"/fake/{serie_folder}/tvshow.nfo") await asyncio.sleep(0.05) return Path(f"/fake/{serie_folder}/tvshow.nfo") service.create_tvshow_nfo = AsyncMock(side_effect=selective_media_failure) request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(6)], download_media=True, skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=service ) # All should succeed (NFO created even if media failed) assert result.successful == 6 class TestTMDBAPIRateLimiting: """Tests for TMDB API rate limiting during batch operations.""" @pytest.mark.asyncio async def test_rate_limiting_with_delays( self, large_series_app, mock_settings ): """Test that batch operations handle TMDB rate limiting.""" service = Mock(spec=NFOService) service.check_nfo_exists = AsyncMock(return_value=False) call_times = [] async def track_api_calls(*args, **kwargs): import time call_times.append(time.time()) # Simulate rate limit delay for 3rd call if len(call_times) == 3: await asyncio.sleep(0.2) # Simulate rate limit wait else: await asyncio.sleep(0.05) return Path("/fake/path/tvshow.nfo") service.create_tvshow_nfo = AsyncMock(side_effect=track_api_calls) request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(5)], max_concurrent=2, skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=service ) # All should complete despite rate limiting assert result.successful == 5 assert len(call_times) == 5 @pytest.mark.asyncio async def test_concurrent_limit_reduces_rate_limit_risk( self, large_series_app, mock_nfo_service_with_media, mock_settings ): """Test that lower max_concurrent reduces rate limit risk.""" # Test with low concurrency request_low = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(10)], max_concurrent=2, # Low concurrency skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): result = await batch_create_nfo( request=request_low, _auth={"username": "test"}, series_app=large_series_app, nfo_service=mock_nfo_service_with_media ) assert result.successful == 10 # Test with high concurrency request_high = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(10)], max_concurrent=10, # High concurrency skip_existing=False ) # Reset mock mock_nfo_service_with_media.create_tvshow_nfo.reset_mock() with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): result = await batch_create_nfo( request=request_high, _auth={"username": "test"}, series_app=large_series_app, nfo_service=mock_nfo_service_with_media ) # Both should succeed, but high concurrency is riskier assert result.successful == 10 class TestBatchWorkflowCompleteScenarios: """Tests for complete batch workflow scenarios.""" @pytest.mark.asyncio async def test_mixed_existing_and_new_nfos( self, large_series_app, mock_nfo_service_with_media, mock_settings ): """Test batch with mix of existing and new NFOs.""" # Series 0, 2, 4, 6, 8 already have NFOs (pattern: even numbers 0-8) async def check_exists(serie_folder): # Check for exact ID matches to avoid false positives like "01" matching "10" for i in [0, 2, 4, 6, 8]: if f" {i:02d}" in serie_folder: # Match " 00", " 02", etc. return True return False mock_nfo_service_with_media.check_nfo_exists.side_effect = check_exists request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(10)], skip_existing=True, download_media=True, max_concurrent=3 ) with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=mock_nfo_service_with_media ) # 7 new, 3 skipped (but anime04 doesn't exist, so actually 5 skipped in the first 10) # Actually: 00, 02, 04, 06, 08 have NFOs = 5 skipped, 5 created assert result.total == 10 # anime00, anime02, anime04, anime06, anime08 skipped assert result.skipped == 5 assert result.successful == 5 @pytest.mark.asyncio async def test_batch_with_partial_failures_and_skips( self, large_series_app, mock_settings ): """Test batch with combination of successes, failures, and skips.""" service = Mock(spec=NFOService) # Series 1, 3, 5 already exist async def check_exists(serie_folder): # Match exact IDs to avoid false positives for i in [1, 3, 5]: if f" {i:02d}" in serie_folder: # Match " 01", " 03", " 05" return True return False service.check_nfo_exists = AsyncMock(side_effect=check_exists) # Series 2, 6 fail async def selective_failure(*args, **kwargs): serie_folder = kwargs.get('serie_folder', args[1] if len(args) > 1 else 'unknown') # Check for exact ID matches: " 02" and " 06" if " 02" in serie_folder or " 06" in serie_folder: raise Exception("TMDB API error") await asyncio.sleep(0.05) return Path(f"/fake/{serie_folder}/tvshow.nfo") service.create_tvshow_nfo = AsyncMock(side_effect=selective_failure) request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(10)], skip_existing=True, max_concurrent=3 ) with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=service ) assert result.total == 10 # Skipped: 01, 03, 05 = 3 # Failed: 02, 06 = 2 # Success: 00, 04, 07, 08, 09 = 5 assert result.skipped == 3 assert result.failed == 2 assert result.successful == 5 @pytest.mark.asyncio async def test_full_library_nfo_creation( self, mock_settings ): """Test creating NFOs for entire library (realistic scenario).""" # Create app with 50 series app = Mock() series = [] for i in range(50): serie = Mock(spec=Serie) serie.key = f"anime{i:03d}" serie.folder = f"Anime {i:03d}" serie.name = f"Test Anime {i:03d}" serie.ensure_folder_with_year = Mock(return_value=f"Anime {i:03d} (2020)") series.append(serie) app.list = Mock() app.list.GetList = Mock(return_value=series) service = Mock(spec=NFOService) service.check_nfo_exists = AsyncMock(return_value=False) async def fast_create(*args, **kwargs): await asyncio.sleep(0.01) # Very fast for testing return Path("/fake/path/tvshow.nfo") service.create_tvshow_nfo = AsyncMock(side_effect=fast_create) request = NFOBatchCreateRequest( serie_ids=[f"anime{i:03d}" for i in range(50)], download_media=False, # Faster for testing skip_existing=False, max_concurrent=10 # High concurrency for large batch ) import time start_time = time.time() with patch("src.server.api.nfo.get_series_app", return_value=app), \ patch("src.server.api.nfo.get_nfo_service", return_value=service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=app, nfo_service=service ) elapsed_time = time.time() - start_time # Verify all created assert result.total == 50 assert result.successful == 50 assert result.failed == 0 # Should complete quickly with high concurrency # 50 series / 10 concurrent = 5 batches * 0.01s = 0.05s + overhead assert elapsed_time < 0.3 @pytest.mark.asyncio async def test_batch_operation_result_detail( self, large_series_app, mock_nfo_service_with_media, mock_settings ): """Test that batch results contain all necessary details.""" request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(5)], download_media=True, skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=mock_nfo_service_with_media ) # Verify result structure assert result.total == 5 assert len(result.results) == 5 for res in result.results: # Each result should have required fields assert res.serie_id is not None assert res.serie_folder is not None assert res.success is not None assert res.message is not None if res.success: # Successful results should have NFO path assert res.nfo_path is not None assert Path(res.nfo_path).name == "tvshow.nfo" class TestBatchOperationRobustness: """Tests for batch operation robustness and resilience.""" @pytest.mark.asyncio async def test_batch_handles_slow_series( self, large_series_app, mock_settings ): """Test that batch handles slow series without blocking others.""" service = Mock(spec=NFOService) service.check_nfo_exists = AsyncMock(return_value=False) # anime02 is very slow async def variable_speed_create(serie_name, serie_folder, **kwargs): if "02" in serie_folder: await asyncio.sleep(0.5) # Very slow else: await asyncio.sleep(0.05) # Normal speed return Path(f"/fake/{serie_folder}/tvshow.nfo") service.create_tvshow_nfo = AsyncMock(side_effect=variable_speed_create) request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(6)], max_concurrent=3, skip_existing=False ) import time start_time = time.time() with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=service ) elapsed_time = time.time() - start_time # All should complete assert result.successful == 6 # Should not take as long as sequential # Sequential: 5*0.05 + 0.5 = 0.75s # Concurrent: max(0.5, 5*0.05/3) ≈ 0.5s # Allow some overhead for async scheduling assert elapsed_time < 1.2 @pytest.mark.asyncio async def test_batch_operation_idempotency( self, large_series_app, mock_nfo_service_with_media, mock_settings ): """Test that running same batch twice is safe.""" request = NFOBatchCreateRequest( serie_ids=[f"anime{i:02d}" for i in range(3)], skip_existing=False, # Overwrite download_media=False ) # First run with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): result1 = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=mock_nfo_service_with_media ) # Second run (idempotent) mock_nfo_service_with_media.create_tvshow_nfo.reset_mock() with patch("src.server.api.nfo.get_series_app", return_value=large_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service_with_media): result2 = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=large_series_app, nfo_service=mock_nfo_service_with_media ) # Both should succeed with same results assert result1.successful == result2.successful == 3 assert result1.total == result2.total == 3