"""Unit tests for adding series with episode scanning. This module tests the complete flow of adding a series: 1. Series is added to database 2. Episodes are scanned 3. Episodes are saved to database 4. GUI is updated via WebSocket All tests use mocks to avoid network traffic. """ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from src.core.entities.series import Serie from src.server.database.models import AnimeSeries, Episode @pytest.fixture def mock_series_app(): """Create a mock SeriesApp with scanner.""" app = MagicMock() # Mock serie_scanner app.serie_scanner = MagicMock() app.serie_scanner.keyDict = {} # Mock list app.list = MagicMock() app.list.keyDict = {} # Mock loader app.loader = MagicMock() app.loader.get_year = MagicMock(return_value=2024) return app @pytest.fixture def mock_db_session(): """Create a mock database session.""" session = AsyncMock() session.commit = AsyncMock() session.rollback = AsyncMock() session.close = AsyncMock() session.flush = AsyncMock() session.refresh = AsyncMock() return session @pytest.fixture def mock_anime_service(mock_series_app): """Create a mock AnimeService.""" from src.server.services.anime_service import AnimeService service = AnimeService(mock_series_app) return service @pytest.mark.asyncio class TestAddSeriesWithEpisodes: """Test suite for adding series with episode scanning.""" async def test_scan_single_series_updates_scanner_keydict( self, mock_series_app ): """Test that scan_single_series updates serie_scanner.keyDict.""" # Arrange key = "test-anime" folder = "Test Anime (2024)" # Mock scan_single_series to update keyDict def mock_scan(key, folder): # Create Serie with episodes serie = Serie( key=key, name="Test Anime", site="aniworld.to", folder=folder, episodeDict={1: [1, 2, 3]}, year=2024 ) # Update scanner's keyDict mock_series_app.serie_scanner.keyDict[key] = serie return {1: [1, 2, 3]} mock_series_app.serie_scanner.scan_single_series = mock_scan # Act result = mock_series_app.serie_scanner.scan_single_series(key, folder) # Assert assert key in mock_series_app.serie_scanner.keyDict serie = mock_series_app.serie_scanner.keyDict[key] assert serie.episodeDict == {1: [1, 2, 3]} assert len(serie.episodeDict[1]) == 3 async def test_sync_single_series_gets_from_scanner_keydict( self, mock_series_app, mock_anime_service ): """Test that sync_single_series_after_scan gets Serie from scanner.keyDict.""" # Arrange key = "test-anime" # Create Serie in scanner's keyDict with episodes serie = Serie( key=key, name="Test Anime", site="aniworld.to", folder="Test Anime (2024)", episodeDict={1: [1, 2, 3], 2: [1, 2]}, year=2024 ) mock_series_app.serie_scanner.keyDict[key] = serie # Mock the database save method with patch.object( mock_anime_service, '_save_scan_results_to_db', new_callable=AsyncMock ) as mock_save: mock_save.return_value = 1 with patch.object( mock_anime_service, '_load_series_from_db', new_callable=AsyncMock ): with patch.object( mock_anime_service, '_broadcast_series_updated', new_callable=AsyncMock ): # Act await mock_anime_service.sync_single_series_after_scan(key) # Assert mock_save.assert_called_once() series_list = mock_save.call_args[0][0] assert len(series_list) == 1 saved_serie = series_list[0] assert saved_serie.key == key assert saved_serie.episodeDict == {1: [1, 2, 3], 2: [1, 2]} async def test_save_scan_results_creates_episodes_in_db( self, mock_anime_service, mock_db_session ): """Test that _save_scan_results_to_db creates episodes.""" # Arrange serie = Serie( key="test-anime", name="Test Anime", site="aniworld.to", folder="Test Anime (2024)", episodeDict={1: [1, 2, 3], 2: [1, 2]}, year=2024 ) # Mock database services with patch('src.server.database.connection.get_db_session') as mock_get_db: # Setup context manager for database session mock_get_db.return_value.__aenter__.return_value = mock_db_session mock_get_db.return_value.__aexit__.return_value = None with patch('src.server.database.service.AnimeSeriesService') as mock_series_service: with patch('src.server.database.service.EpisodeService') as mock_episode_service: # Series doesn't exist - will create new mock_series_service.get_by_key = AsyncMock(return_value=None) # Mock create to return a series with ID mock_db_series = MagicMock() mock_db_series.id = 1 mock_db_series.key = "test-anime" mock_series_service.create = AsyncMock(return_value=mock_db_series) # Mock episode creation episode_create_calls = [] async def track_episode_create(db, series_id, season, episode_number): episode_create_calls.append((series_id, season, episode_number)) ep = MagicMock() ep.id = len(episode_create_calls) ep.series_id = series_id ep.season = season ep.episode_number = episode_number return ep mock_episode_service.create = AsyncMock(side_effect=track_episode_create) # Act result = await mock_anime_service._save_scan_results_to_db([serie]) # Assert assert result == 1 # One series saved # Verify episodes were created assert len(episode_create_calls) == 5 # 3 from season 1, 2 from season 2 # Check season 1 episodes assert (1, 1, 1) in episode_create_calls assert (1, 1, 2) in episode_create_calls assert (1, 1, 3) in episode_create_calls # Check season 2 episodes assert (1, 2, 1) in episode_create_calls assert (1, 2, 2) in episode_create_calls async def test_update_series_adds_missing_episodes( self, mock_anime_service, mock_db_session ): """Test that _update_series_in_db adds new missing episodes.""" # Arrange serie = Serie( key="test-anime", name="Test Anime", site="aniworld.to", folder="Test Anime (2024)", episodeDict={1: [1, 2, 3, 4]}, # 4 episodes year=2024 ) # Existing series in DB with only 2 episodes existing_db_series = MagicMock() existing_db_series.id = 1 existing_db_series.key = "test-anime" existing_db_series.folder = "Test Anime (2024)" # Mock existing episodes in DB existing_episode_1 = MagicMock() existing_episode_1.id = 1 existing_episode_1.series_id = 1 existing_episode_1.season = 1 existing_episode_1.episode_number = 1 existing_episode_2 = MagicMock() existing_episode_2.id = 2 existing_episode_2.series_id = 1 existing_episode_2.season = 1 existing_episode_2.episode_number = 2 existing_episodes = [existing_episode_1, existing_episode_2] with patch('src.server.database.connection.get_db_session') as mock_get_db: mock_get_db.return_value.__aenter__.return_value = mock_db_session mock_get_db.return_value.__aexit__.return_value = None with patch('src.server.database.service.AnimeSeriesService') as mock_series_service: with patch('src.server.database.service.EpisodeService') as mock_episode_service: # Series exists mock_series_service.get_by_key = AsyncMock(return_value=existing_db_series) # Mock get_by_series to return existing episodes mock_episode_service.get_by_series = AsyncMock(return_value=existing_episodes) # Track new episodes created new_episodes_created = [] async def track_episode_create(db, series_id, season, episode_number): new_episodes_created.append((series_id, season, episode_number)) return MagicMock() mock_episode_service.create = AsyncMock(side_effect=track_episode_create) mock_episode_service.delete = AsyncMock() # Act result = await mock_anime_service._save_scan_results_to_db([serie]) # Assert assert result == 1 # Should create 2 new episodes (episode 3 and 4) assert len(new_episodes_created) == 2 assert (1, 1, 3) in new_episodes_created assert (1, 1, 4) in new_episodes_created async def test_complete_add_series_flow( self, mock_series_app ): """Integration test for complete add series flow.""" from src.server.services.anime_service import AnimeService # Arrange key = "test-anime" folder = "Test Anime (2024)" # Setup mock scanner to populate keyDict def mock_scan(key, folder): serie = Serie( key=key, name="Test Anime", site="aniworld.to", folder=folder, episodeDict={1: [1, 2, 3]}, year=2024 ) mock_series_app.serie_scanner.keyDict[key] = serie return {1: [1, 2, 3]} mock_series_app.serie_scanner.scan_single_series = mock_scan # Create service anime_service = AnimeService(mock_series_app) # Mock database operations with patch('src.server.database.connection.get_db_session') as mock_get_db: mock_db = AsyncMock() mock_get_db.return_value.__aenter__.return_value = mock_db mock_get_db.return_value.__aexit__.return_value = None with patch('src.server.database.service.AnimeSeriesService') as mock_series_service: with patch('src.server.database.service.EpisodeService') as mock_episode_service: # Series doesn't exist mock_series_service.get_by_key = AsyncMock(return_value=None) # Mock series creation mock_db_series = MagicMock() mock_db_series.id = 1 mock_series_service.create = AsyncMock(return_value=mock_db_series) # Track episodes episodes_created = [] async def track_create(db, series_id, season, episode_number): episodes_created.append((season, episode_number)) return MagicMock() mock_episode_service.create = AsyncMock(side_effect=track_create) # Mock other methods with patch.object(anime_service, '_load_series_from_db', new_callable=AsyncMock): with patch.object(anime_service, '_broadcast_series_updated', new_callable=AsyncMock): # Act # 1. Scan episodes result = mock_series_app.serie_scanner.scan_single_series(key, folder) # 2. Sync to database await anime_service.sync_single_series_after_scan(key) # Assert # Episodes were scanned assert result == {1: [1, 2, 3]} # Serie was added to scanner keyDict assert key in mock_series_app.serie_scanner.keyDict # Episodes were saved to DB assert len(episodes_created) == 3 assert (1, 1) in episodes_created assert (1, 2) in episodes_created assert (1, 3) in episodes_created async def test_websocket_broadcast_on_series_update( self, mock_series_app ): """Test that WebSocket broadcasts series_updated event with complete data including NFO fields.""" from unittest.mock import AsyncMock, MagicMock, patch from src.server.database.models import AnimeSeries from src.server.services.anime_service import AnimeService # Arrange key = "test-anime" # Create Serie in list.keyDict with episodes serie = Serie( key=key, name="Test Anime", site="aniworld.to", folder="Test Anime (2024)", episodeDict={1: [1, 2, 3]}, year=2024 ) mock_series_app.list.keyDict[key] = serie # Mock database AnimeSeries with NFO data mock_db_series = AnimeSeries( key=key, name="Test Anime", folder="Test Anime (2024)", site="aniworld.to", year=2024, has_nfo=True, tmdb_id="12345", tvdb_id="67890", nfo_created_at=datetime(2024, 1, 1, 12, 0, 0), nfo_updated_at=datetime(2024, 1, 2, 12, 0, 0) ) # Create service with mocked WebSocket anime_service = AnimeService(mock_series_app) mock_websocket = AsyncMock() anime_service._websocket_service = mock_websocket # Mock database session and service mock_db_session = AsyncMock() mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session) mock_db_session.__aexit__ = AsyncMock() with patch('src.server.database.connection.get_db_session', return_value=mock_db_session): with patch('src.server.database.service.AnimeSeriesService') as MockAnimeSeriesService: MockAnimeSeriesService.get_by_key = AsyncMock(return_value=mock_db_series) # Act await anime_service._broadcast_series_updated(key) # Assert mock_websocket.broadcast.assert_called_once() call_args = mock_websocket.broadcast.call_args[0][0] # Verify payload structure assert call_args["type"] == "series_updated" assert call_args["key"] == key assert "data" in call_args # Verify basic series data assert call_args["data"]["key"] == key assert call_args["data"]["name"] == "Test Anime" assert call_args["data"]["missing_episodes"] == {"1": [1, 2, 3]} assert call_args["data"]["has_missing"] is True # Verify NFO metadata fields are included assert call_args["data"]["has_nfo"] is True assert call_args["data"]["tmdb_id"] == "12345" assert call_args["data"]["tvdb_id"] == "67890" assert call_args["data"]["nfo_created_at"] == "2024-01-01T12:00:00" assert call_args["data"]["nfo_updated_at"] == "2024-01-02T12:00:00" assert "timestamp" in call_args if __name__ == "__main__": pytest.main([__file__, "-v"])