"""Unit tests for NFO batch operations. This module tests NFO batch operation logic including: - Concurrent NFO creation with max_concurrent limits - Batch operation error handling (partial failures) - Batch operation progress tracking - Batch operation cancellation """ 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 mock_series_app(): """Create a mock SeriesApp with test series.""" app = Mock() # Create test series series = [] for i in range(5): serie = Mock(spec=Serie) serie.key = f"serie{i}" serie.folder = f"Serie {i}" serie.name = f"Serie {i}" serie.year = 2020 + i serie.ensure_folder_with_year = Mock(return_value=f"Serie {i} (202{i})") series.append(serie) app.list = Mock() app.list.GetList = Mock(return_value=series) return app @pytest.fixture def mock_nfo_service(): """Create a mock NFO service.""" service = Mock(spec=NFOService) service.check_nfo_exists = AsyncMock(return_value=False) service.create_tvshow_nfo = AsyncMock(return_value=Path("/fake/path/tvshow.nfo")) 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 TestBatchOperationConcurrency: """Tests for concurrent NFO creation with limits.""" @pytest.mark.asyncio async def test_respects_max_concurrent_limit( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that batch operations respect max_concurrent limit.""" # Track concurrent executions concurrent_count = {"current": 0, "max": 0} async def track_concurrent(*args, **kwargs): concurrent_count["current"] += 1 concurrent_count["max"] = max( concurrent_count["max"], concurrent_count["current"] ) await asyncio.sleep(0.1) # Simulate work concurrent_count["current"] -= 1 return Path("/fake/path/tvshow.nfo") mock_nfo_service.create_tvshow_nfo.side_effect = track_concurrent # Create request with max_concurrent=2 request = NFOBatchCreateRequest( serie_ids=[f"serie{i}" for i in range(5)], max_concurrent=2, download_media=False, skip_existing=False ) # Execute batch operation with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Verify max concurrent operations didn't exceed limit assert concurrent_count["max"] <= 2 assert result.total == 5 assert result.successful == 5 @pytest.mark.asyncio async def test_max_concurrent_default_value( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that default max_concurrent value is applied.""" request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1"], # max_concurrent not specified, should default to 3 ) assert request.max_concurrent == 3 @pytest.mark.asyncio async def test_max_concurrent_validation(self): """Test that max_concurrent is validated within range.""" # Test minimum with pytest.raises(ValueError): NFOBatchCreateRequest( serie_ids=["serie0"], max_concurrent=0 # Below minimum ) # Test maximum with pytest.raises(ValueError): NFOBatchCreateRequest( serie_ids=["serie0"], max_concurrent=11 # Above maximum ) # Test valid values for value in [1, 3, 5, 10]: request = NFOBatchCreateRequest( serie_ids=["serie0"], max_concurrent=value ) assert request.max_concurrent == value @pytest.mark.asyncio async def test_concurrent_operations_complete_correctly( self, mock_series_app, mock_nfo_service, mock_settings ): """Test all concurrent operations complete successfully.""" call_order = [] async def track_order(serie_name, serie_folder, **kwargs): call_order.append(serie_name) await asyncio.sleep(0.05) # Simulate work return Path(f"/fake/{serie_folder}/tvshow.nfo") mock_nfo_service.create_tvshow_nfo.side_effect = track_order request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1", "serie2", "serie3"], max_concurrent=2 ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # All operations should complete assert len(call_order) == 4 assert result.successful == 4 assert result.failed == 0 class TestBatchOperationErrorHandling: """Tests for batch operation error handling.""" @pytest.mark.asyncio async def test_partial_failure_continues_processing( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that partial failures don't stop batch processing.""" # Make serie1 and serie3 fail async def selective_failure(serie_name, **kwargs): if serie_name in ["Serie 1", "Serie 3"]: raise Exception("TMDB API error") return Path(f"/fake/{serie_name}/tvshow.nfo") mock_nfo_service.create_tvshow_nfo.side_effect = selective_failure request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1", "serie2", "serie3", "serie4"], skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Verify partial success assert result.total == 5 assert result.successful == 3 # serie0, serie2, serie4 assert result.failed == 2 # serie1, serie3 # Check failed results have error messages failed_results = [r for r in result.results if not r.success] assert len(failed_results) == 2 for failed in failed_results: assert "Error:" in failed.message @pytest.mark.asyncio async def test_series_not_found_error( self, mock_series_app, mock_nfo_service, mock_settings ): """Test handling of non-existent series.""" request = NFOBatchCreateRequest( serie_ids=["serie0", "nonexistent", "serie1"], skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Verify error handling assert result.total == 3 assert result.successful == 2 assert result.failed == 1 # Find the failed result failed = next(r for r in result.results if r.serie_id == "nonexistent") assert not failed.success assert "not found" in failed.message.lower() @pytest.mark.asyncio async def test_all_operations_fail( self, mock_series_app, mock_nfo_service, mock_settings ): """Test batch operation when all operations fail.""" mock_nfo_service.create_tvshow_nfo.side_effect = Exception("Network error") request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1", "serie2"], skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) assert result.total == 3 assert result.successful == 0 assert result.failed == 3 @pytest.mark.asyncio async def test_error_messages_are_informative( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that error messages contain useful information.""" async def specific_errors(serie_name, **kwargs): errors = { "Serie 0": "TMDB API rate limit exceeded", "Serie 1": "File permission denied", "Serie 2": "Network timeout", } if serie_name in errors: raise Exception(errors[serie_name]) return Path("/fake/path/tvshow.nfo") mock_nfo_service.create_tvshow_nfo.side_effect = specific_errors request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1", "serie2"], skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Verify error messages are preserved for res in result.results: assert not res.success assert "Error:" in res.message # Verify specific error is mentioned if res.serie_id == "serie0": assert "rate limit" in res.message.lower() elif res.serie_id == "serie1": assert "permission" in res.message.lower() elif res.serie_id == "serie2": assert "timeout" in res.message.lower() class TestBatchOperationSkipping: """Tests for skip_existing functionality.""" @pytest.mark.asyncio async def test_skip_existing_nfo_files( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that existing NFO files are skipped when requested.""" # Serie 1 and 3 have existing NFOs async def check_exists(serie_folder): return serie_folder in ["Serie 1 (2021)", "Serie 3 (2023)"] mock_nfo_service.check_nfo_exists.side_effect = check_exists request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1", "serie2", "serie3", "serie4"], skip_existing=True ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Verify skipped series assert result.total == 5 assert result.successful == 3 # serie0, serie2, serie4 assert result.skipped == 2 # serie1, serie3 # Verify create was only called for non-existing assert mock_nfo_service.create_tvshow_nfo.call_count == 3 @pytest.mark.asyncio async def test_skip_existing_false_overwrites( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that existing NFO files are overwritten when skip_existing=False.""" mock_nfo_service.check_nfo_exists.return_value = True request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1"], skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # All should be created despite existing assert result.successful == 2 assert result.skipped == 0 assert mock_nfo_service.create_tvshow_nfo.call_count == 2 class TestBatchOperationMediaDownloads: """Tests for media download functionality in batch operations.""" @pytest.mark.asyncio async def test_download_media_enabled( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that media downloads are requested when enabled.""" request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1"], download_media=True, skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Verify media downloads were requested for call in mock_nfo_service.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_download_media_disabled( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that media downloads are skipped when disabled.""" request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1"], download_media=False, skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Verify media downloads were not requested for call in mock_nfo_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 class TestBatchOperationResults: """Tests for batch operation result structure.""" @pytest.mark.asyncio async def test_result_includes_all_series( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that result includes entry for every series.""" request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1", "serie2"], skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Verify all series in results assert len(result.results) == 3 result_ids = {r.serie_id for r in result.results} assert result_ids == {"serie0", "serie1", "serie2"} @pytest.mark.asyncio async def test_result_includes_nfo_paths( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that successful results include NFO file paths.""" request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1"], skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Verify NFO paths are included for res in result.results: if res.success: assert res.nfo_path is not None assert "tvshow.nfo" in res.nfo_path @pytest.mark.asyncio async def test_result_counts_are_accurate( self, mock_series_app, mock_nfo_service, mock_settings ): """Test that result counts match actual outcomes.""" # Setup: 2 success, 1 skip, 1 fail, 1 not found async def mixed_results(serie_name, **kwargs): if serie_name == "Serie 2": raise Exception("TMDB error") return Path(f"/fake/{serie_name}/tvshow.nfo") mock_nfo_service.create_tvshow_nfo.side_effect = mixed_results mock_nfo_service.check_nfo_exists.side_effect = lambda f: f == "Serie 1 (2021)" request = NFOBatchCreateRequest( serie_ids=["serie0", "serie1", "serie2", "nonexistent"], skip_existing=True ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Verify counts assert result.total == 4 assert result.successful == 1 # serie0 assert result.skipped == 1 # serie1 assert result.failed == 2 # serie2 (error), nonexistent (not found) # Verify sum adds up assert result.successful + result.skipped + result.failed == result.total class TestBatchOperationEdgeCases: """Tests for edge cases in batch operations.""" @pytest.mark.asyncio async def test_empty_series_list( self, mock_series_app, mock_nfo_service, mock_settings ): """Test batch operation with empty series list.""" request = NFOBatchCreateRequest( serie_ids=[], skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) assert result.total == 0 assert result.successful == 0 assert result.failed == 0 assert len(result.results) == 0 @pytest.mark.asyncio async def test_single_series( self, mock_series_app, mock_nfo_service, mock_settings ): """Test batch operation with single series.""" request = NFOBatchCreateRequest( serie_ids=["serie0"], skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) assert result.total == 1 assert result.successful == 1 assert len(result.results) == 1 @pytest.mark.asyncio async def test_large_batch_operation( self, mock_nfo_service, mock_settings ): """Test batch operation with many series.""" # Create app with 20 series app = Mock() series = [] for i in range(20): serie = Mock(spec=Serie) serie.key = f"serie{i}" serie.folder = f"Serie {i}" serie.name = f"Serie {i}" serie.ensure_folder_with_year = Mock(return_value=f"Serie {i} (2020)") series.append(serie) app.list = Mock() app.list.GetList = Mock(return_value=series) request = NFOBatchCreateRequest( serie_ids=[f"serie{i}" for i in range(20)], max_concurrent=5, skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=app, nfo_service=mock_nfo_service ) assert result.total == 20 assert result.successful == 20 @pytest.mark.asyncio async def test_duplicate_serie_ids( self, mock_series_app, mock_nfo_service, mock_settings ): """Test batch operation handles duplicate serie IDs.""" request = NFOBatchCreateRequest( serie_ids=["serie0", "serie0", "serie1", "serie1"], skip_existing=False ) with patch("src.server.api.nfo.get_series_app", return_value=mock_series_app), \ patch("src.server.api.nfo.get_nfo_service", return_value=mock_nfo_service): result = await batch_create_nfo( request=request, _auth={"username": "test"}, series_app=mock_series_app, nfo_service=mock_nfo_service ) # Should process all (including duplicates) assert result.total == 4 assert result.successful == 4