"""Unit tests for background loader service optimization (no full rescans).""" import asyncio from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import pytest from src.server.services.background_loader_service import ( BackgroundLoaderService, LoadingStatus, SeriesLoadingTask, ) @pytest.fixture def mock_websocket_service(): """Mock WebSocket service.""" service = Mock() service.broadcast = AsyncMock() return service @pytest.fixture def mock_anime_service(): """Mock anime service.""" service = Mock() service.rescan = AsyncMock() return service @pytest.fixture def mock_series_app(tmp_path): """Mock SeriesApp.""" app = Mock() app.directory_to_search = str(tmp_path) app.nfo_service = Mock() app.nfo_service.has_nfo = Mock(return_value=False) app.nfo_service.create_tvshow_nfo = AsyncMock() return app @pytest.fixture async def background_loader(mock_websocket_service, mock_anime_service, mock_series_app): """Create BackgroundLoaderService instance.""" service = BackgroundLoaderService( websocket_service=mock_websocket_service, anime_service=mock_anime_service, series_app=mock_series_app ) yield service await service.stop() class TestFindSeriesDirectory: """Test finding series directory without full rescan.""" @pytest.mark.asyncio async def test_find_existing_directory(self, background_loader, tmp_path): """Test finding a series directory that exists.""" # Create series directory series_dir = tmp_path / "Test Series" series_dir.mkdir() task = SeriesLoadingTask( key="test-series", folder="Test Series", name="Test Series" ) # Find directory result = await background_loader._find_series_directory(task) # Verify assert result is not None assert result == series_dir assert result.exists() @pytest.mark.asyncio async def test_find_nonexistent_directory(self, background_loader, tmp_path): """Test finding a series directory that doesn't exist.""" task = SeriesLoadingTask( key="nonexistent", folder="Nonexistent Series", name="Nonexistent Series" ) # Find directory result = await background_loader._find_series_directory(task) # Verify assert result is None @pytest.mark.asyncio async def test_find_directory_with_special_characters(self, background_loader, tmp_path): """Test finding directory with special characters in name.""" # Create series directory with special characters series_dir = tmp_path / "Series (2023) - Special!" series_dir.mkdir() task = SeriesLoadingTask( key="special-series", folder="Series (2023) - Special!", name="Series (2023) - Special!" ) # Find directory result = await background_loader._find_series_directory(task) # Verify assert result is not None assert result == series_dir class TestScanSeriesEpisodes: """Test scanning episodes for a specific series.""" @pytest.mark.asyncio async def test_scan_single_season(self, background_loader, tmp_path): """Test scanning a series with one season.""" # Create series structure series_dir = tmp_path / "Test Series" season_dir = series_dir / "Season 1" season_dir.mkdir(parents=True) (season_dir / "episode1.mp4").touch() (season_dir / "episode2.mp4").touch() (season_dir / "episode3.mp4").touch() task = SeriesLoadingTask( key="test-series", folder="Test Series", name="Test Series" ) # Scan episodes episodes = await background_loader._scan_series_episodes(series_dir, task) # Verify assert "Season 1" in episodes assert len(episodes["Season 1"]) == 3 assert "episode1.mp4" in episodes["Season 1"] assert "episode2.mp4" in episodes["Season 1"] assert "episode3.mp4" in episodes["Season 1"] @pytest.mark.asyncio async def test_scan_multiple_seasons(self, background_loader, tmp_path): """Test scanning a series with multiple seasons.""" # Create series structure series_dir = tmp_path / "Multi Season Series" for season_num in range(1, 4): season_dir = series_dir / f"Season {season_num}" season_dir.mkdir(parents=True) for episode_num in range(1, 6): (season_dir / f"episode{episode_num}.mp4").touch() task = SeriesLoadingTask( key="multi-season", folder="Multi Season Series", name="Multi Season Series" ) # Scan episodes episodes = await background_loader._scan_series_episodes(series_dir, task) # Verify assert len(episodes) == 3 assert "Season 1" in episodes assert "Season 2" in episodes assert "Season 3" in episodes assert all(len(eps) == 5 for eps in episodes.values()) @pytest.mark.asyncio async def test_scan_ignores_non_mp4_files(self, background_loader, tmp_path): """Test that only .mp4 files are counted as episodes.""" # Create series structure series_dir = tmp_path / "Test Series" season_dir = series_dir / "Season 1" season_dir.mkdir(parents=True) (season_dir / "episode1.mp4").touch() (season_dir / "episode2.mkv").touch() # Should be ignored (season_dir / "subtitle.srt").touch() # Should be ignored (season_dir / "readme.txt").touch() # Should be ignored task = SeriesLoadingTask( key="test-series", folder="Test Series", name="Test Series" ) # Scan episodes episodes = await background_loader._scan_series_episodes(series_dir, task) # Verify - only .mp4 file should be counted assert "Season 1" in episodes assert len(episodes["Season 1"]) == 1 assert episodes["Season 1"][0] == "episode1.mp4" @pytest.mark.asyncio async def test_scan_empty_seasons_ignored(self, background_loader, tmp_path): """Test that seasons with no episodes are ignored.""" # Create series structure series_dir = tmp_path / "Test Series" season1_dir = series_dir / "Season 1" season2_dir = series_dir / "Season 2" season1_dir.mkdir(parents=True) season2_dir.mkdir(parents=True) # Only add episodes to Season 1 (season1_dir / "episode1.mp4").touch() # Season 2 is empty task = SeriesLoadingTask( key="test-series", folder="Test Series", name="Test Series" ) # Scan episodes episodes = await background_loader._scan_series_episodes(series_dir, task) # Verify - Season 2 should not be included assert len(episodes) == 1 assert "Season 1" in episodes assert "Season 2" not in episodes @pytest.mark.asyncio async def test_scan_ignores_files_in_series_root(self, background_loader, tmp_path): """Test that files directly in series root are ignored.""" # Create series structure series_dir = tmp_path / "Test Series" series_dir.mkdir() season_dir = series_dir / "Season 1" season_dir.mkdir() # Add episode in season folder (season_dir / "episode1.mp4").touch() # Add file in series root (should be ignored) (series_dir / "random.mp4").touch() (series_dir / "info.txt").touch() task = SeriesLoadingTask( key="test-series", folder="Test Series", name="Test Series" ) # Scan episodes episodes = await background_loader._scan_series_episodes(series_dir, task) # Verify - only episode in season folder should be counted assert len(episodes) == 1 assert "Season 1" in episodes assert len(episodes["Season 1"]) == 1 class TestLoadEpisodesOptimization: """Test that loading episodes doesn't trigger full rescans.""" @pytest.mark.asyncio async def test_load_episodes_no_full_rescan( self, background_loader, mock_anime_service, tmp_path ): """Test that loading episodes doesn't call anime_service.rescan().""" # Create series structure series_dir = tmp_path / "Test Series" season_dir = series_dir / "Season 1" season_dir.mkdir(parents=True) (season_dir / "episode1.mp4").touch() task = SeriesLoadingTask( key="test-series", folder="Test Series", name="Test Series" ) # Mock database mock_db = AsyncMock() mock_series_db = Mock() with patch('src.server.database.service.AnimeSeriesService.get_by_key') as mock_get: mock_get.return_value = mock_series_db # Load episodes await background_loader._load_episodes(task, mock_db) # Verify rescan was NOT called mock_anime_service.rescan.assert_not_called() # Verify progress was updated assert task.progress["episodes"] is True # Verify database was updated assert mock_series_db.episodes_loaded is True mock_db.commit.assert_called_once() @pytest.mark.asyncio async def test_load_episodes_handles_missing_directory( self, background_loader, tmp_path ): """Test that loading episodes handles missing directory gracefully.""" task = SeriesLoadingTask( key="nonexistent", folder="Nonexistent Series", name="Nonexistent Series" ) mock_db = AsyncMock() # Load episodes await background_loader._load_episodes(task, mock_db) # Verify progress was marked as failed assert task.progress["episodes"] is False @pytest.mark.asyncio async def test_load_episodes_handles_empty_directory( self, background_loader, tmp_path ): """Test that loading episodes handles directory with no episodes.""" # Create empty series directory series_dir = tmp_path / "Empty Series" series_dir.mkdir() task = SeriesLoadingTask( key="empty-series", folder="Empty Series", name="Empty Series" ) mock_db = AsyncMock() # Load episodes await background_loader._load_episodes(task, mock_db) # Verify progress was marked as failed assert task.progress["episodes"] is False @pytest.mark.asyncio async def test_load_episodes_updates_database_correctly( self, background_loader, tmp_path ): """Test that loading episodes updates database with correct information.""" # Create series structure series_dir = tmp_path / "Test Series" season_dir = series_dir / "Season 1" season_dir.mkdir(parents=True) (season_dir / "episode1.mp4").touch() (season_dir / "episode2.mp4").touch() task = SeriesLoadingTask( key="test-series", folder="Test Series", name="Test Series" ) # Mock database mock_db = AsyncMock() mock_series_db = Mock() mock_series_db.episodes_loaded = False mock_series_db.loading_status = None with patch('src.server.database.service.AnimeSeriesService.get_by_key') as mock_get: mock_get.return_value = mock_series_db # Load episodes await background_loader._load_episodes(task, mock_db) # Verify database fields were updated assert mock_series_db.episodes_loaded is True assert mock_series_db.loading_status == "loading_episodes" mock_db.commit.assert_called_once() class TestIntegrationNoFullRescan: """Integration tests verifying no full rescans occur.""" @pytest.mark.asyncio async def test_full_loading_workflow_no_rescan( self, background_loader, mock_anime_service, tmp_path ): """Test complete loading workflow doesn't trigger rescan.""" # Create series structure series_dir = tmp_path / "Complete Series" season_dir = series_dir / "Season 1" season_dir.mkdir(parents=True) (season_dir / "episode1.mp4").touch() (season_dir / "episode2.mp4").touch() task = SeriesLoadingTask( key="complete-series", folder="Complete Series", name="Complete Series" ) # Mock database mock_db = AsyncMock() mock_series_db = Mock() mock_series_db.episodes_loaded = False mock_series_db.has_nfo = False mock_series_db.logo_loaded = False mock_series_db.images_loaded = False with patch('src.server.database.service.AnimeSeriesService.get_by_key') as mock_get: with patch('src.server.database.connection.get_db_session') as mock_get_db: mock_get.return_value = mock_series_db mock_get_db.return_value.__aenter__.return_value = mock_db # Check missing data and load missing = await background_loader.check_missing_data( task.key, task.folder, str(tmp_path), mock_db ) if missing["episodes"]: await background_loader._load_episodes(task, mock_db) # Verify NO full rescan was triggered mock_anime_service.rescan.assert_not_called() # Verify task completed successfully assert task.progress["episodes"] is True @pytest.mark.asyncio async def test_multiple_series_no_cross_contamination( self, background_loader, tmp_path ): """Test loading multiple series doesn't cause cross-contamination.""" # Create multiple series for series_name in ["Series A", "Series B", "Series C"]: series_dir = tmp_path / series_name season_dir = series_dir / "Season 1" season_dir.mkdir(parents=True) (season_dir / "episode1.mp4").touch() tasks = [ SeriesLoadingTask(key=f"series-{i}", folder=f"Series {chr(65+i)}", name=f"Series {chr(65+i)}") for i in range(3) ] mock_db = AsyncMock() # Load all series for task in tasks: series_dir = await background_loader._find_series_directory(task) assert series_dir is not None episodes = await background_loader._scan_series_episodes(series_dir, task) assert len(episodes) == 1 assert "Season 1" in episodes class TestPerformanceComparison: """Tests to demonstrate performance improvement.""" @pytest.mark.asyncio async def test_scan_single_series_is_fast(self, background_loader, tmp_path): """Test that scanning a single series is fast.""" import time # Create series structure series_dir = tmp_path / "Performance Test" for season_num in range(1, 6): season_dir = series_dir / f"Season {season_num}" season_dir.mkdir(parents=True) for episode_num in range(1, 26): (season_dir / f"episode{episode_num}.mp4").touch() task = SeriesLoadingTask( key="performance-test", folder="Performance Test", name="Performance Test" ) # Measure time start_time = time.time() episodes = await background_loader._scan_series_episodes(series_dir, task) elapsed_time = time.time() - start_time # Verify it's fast (should be under 1 second even for 125 episodes) assert elapsed_time < 1.0 assert len(episodes) == 5 assert all(len(eps) == 25 for eps in episodes.values())