"""Unit tests for AnimeService. Tests cover service initialization, async operations, caching, error handling, progress reporting integration, scan/download status event handling, database persistence, and WebSocket broadcasting. """ from __future__ import annotations import asyncio import time from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.server.services.anime_service import ( AnimeService, AnimeServiceError, sync_series_from_data_files, ) from src.server.services.progress_service import ProgressService @pytest.fixture def mock_series_app(tmp_path): """Create a mock SeriesApp instance.""" mock_instance = MagicMock() mock_instance.directory_to_search = str(tmp_path) mock_instance.series_list = [] mock_instance.search = AsyncMock(return_value=[]) mock_instance.rescan = AsyncMock() mock_instance.download = AsyncMock(return_value=True) mock_instance.download_status = None mock_instance.scan_status = None return mock_instance @pytest.fixture def mock_progress_service(): """Create a mock ProgressService instance.""" service = MagicMock(spec=ProgressService) service.start_progress = AsyncMock() service.update_progress = AsyncMock() service.complete_progress = AsyncMock() service.fail_progress = AsyncMock() return service @pytest.fixture def anime_service(tmp_path, mock_series_app, mock_progress_service): """Create an AnimeService instance for testing.""" return AnimeService( series_app=mock_series_app, progress_service=mock_progress_service, ) class TestAnimeServiceInitialization: """Test AnimeService initialization.""" def test_initialization_success( self, mock_series_app, mock_progress_service ): """Test successful service initialization.""" service = AnimeService( series_app=mock_series_app, progress_service=mock_progress_service, ) assert service._app is mock_series_app assert service._progress_service is mock_progress_service def test_initialization_failure_raises_error( self, tmp_path, mock_progress_service ): """Test SeriesApp initialization failure raises error.""" bad_series_app = MagicMock() bad_series_app.directory_to_search = str(tmp_path) # Make event subscription fail by raising on property access type(bad_series_app).download_status = property( lambda self: None, lambda self, value: (_ for _ in ()).throw( Exception("Initialization failed") ) ) with pytest.raises( AnimeServiceError, match="Initialization failed" ): AnimeService( series_app=bad_series_app, progress_service=mock_progress_service, ) class TestListMissing: """Test list_missing operation.""" @pytest.mark.asyncio async def test_list_missing_empty(self, anime_service, mock_series_app): """Test listing missing episodes when list is empty.""" mock_series_app.series_list = [] result = await anime_service.list_missing() assert isinstance(result, list) assert len(result) == 0 @pytest.mark.asyncio async def test_list_missing_with_series( self, anime_service, mock_series_app ): """Test listing missing episodes with series data.""" mock_series_app.series_list = [ {"name": "Test Series 1", "missing": [1, 2]}, {"name": "Test Series 2", "missing": [3]}, ] result = await anime_service.list_missing() assert len(result) == 2 assert result[0]["name"] == "Test Series 1" assert result[1]["name"] == "Test Series 2" @pytest.mark.asyncio async def test_list_missing_caching(self, anime_service, mock_series_app): """Test that list_missing uses caching.""" mock_series_app.series_list = [{"name": "Test Series"}] # First call result1 = await anime_service.list_missing() # Second call (should use cache) result2 = await anime_service.list_missing() assert result1 == result2 @pytest.mark.asyncio async def test_list_missing_error_handling( self, anime_service, mock_series_app ): """Test error handling in list_missing.""" mock_series_app.series_list = None # Cause an error # Error message will be about NoneType not being iterable with pytest.raises(AnimeServiceError): await anime_service.list_missing() class TestSearch: """Test search operation.""" @pytest.mark.asyncio async def test_search_empty_query(self, anime_service): """Test search with empty query returns empty list.""" result = await anime_service.search("") assert result == [] @pytest.mark.asyncio async def test_search_success(self, anime_service, mock_series_app): """Test successful search operation.""" mock_series_app.search.return_value = [ {"name": "Test Anime", "url": "http://example.com"} ] result = await anime_service.search("test") assert len(result) == 1 assert result[0]["name"] == "Test Anime" mock_series_app.search.assert_called_once_with("test") @pytest.mark.asyncio async def test_search_error_handling( self, anime_service, mock_series_app ): """Test error handling during search.""" mock_series_app.search.side_effect = Exception("Search failed") with pytest.raises(AnimeServiceError, match="Search failed"): await anime_service.search("test query") class TestRescan: """Test rescan operation.""" @pytest.mark.asyncio async def test_rescan_success( self, anime_service, mock_series_app, mock_progress_service ): """Test successful rescan operation.""" # Mock rescan to return empty list (no DB save needed) mock_series_app.rescan.return_value = [] # Mock the database operations with patch.object( anime_service, '_save_scan_results_to_db', new_callable=AsyncMock ): with patch.object( anime_service, '_load_series_from_db', new_callable=AsyncMock ): await anime_service.rescan() # Verify SeriesApp.rescan was called (lowercase, not ReScan) mock_series_app.rescan.assert_called_once() @pytest.mark.asyncio async def test_rescan_with_callback(self, anime_service, mock_series_app): """Test rescan operation (callback parameter removed).""" # Rescan no longer accepts callback parameter # Progress is tracked via event handlers automatically mock_series_app.rescan.return_value = [] with patch.object( anime_service, '_save_scan_results_to_db', new_callable=AsyncMock ): with patch.object( anime_service, '_load_series_from_db', new_callable=AsyncMock ): await anime_service.rescan() # Verify rescan was called mock_series_app.rescan.assert_called_once() @pytest.mark.asyncio async def test_rescan_clears_cache(self, anime_service, mock_series_app): """Test that rescan clears the list cache.""" # Populate cache mock_series_app.series_list = [{"name": "Test"}] await anime_service.list_missing() # Update series list mock_series_app.series_list = [{"name": "Test"}, {"name": "New"}] mock_series_app.rescan.return_value = [] # Mock the database operations with patch.object( anime_service, '_save_scan_results_to_db', new_callable=AsyncMock ): with patch.object( anime_service, '_load_series_from_db', new_callable=AsyncMock ): # Rescan should clear cache await anime_service.rescan() # Next list_missing should return updated data result = await anime_service.list_missing() assert len(result) == 2 @pytest.mark.asyncio async def test_rescan_error_handling( self, anime_service, mock_series_app, mock_progress_service ): """Test error handling during rescan.""" mock_series_app.rescan.side_effect = Exception("Rescan failed") with pytest.raises(AnimeServiceError, match="Rescan failed"): await anime_service.rescan() class TestDownload: """Test download operation.""" @pytest.mark.asyncio async def test_download_success(self, anime_service, mock_series_app): """Test successful download operation.""" mock_series_app.download.return_value = True result = await anime_service.download( serie_folder="test_series", season=1, episode=1, key="test_key", ) assert result is True mock_series_app.download.assert_called_once_with( serie_folder="test_series", season=1, episode=1, key="test_key", item_id=None, ) @pytest.mark.asyncio async def test_download_with_callback( self, anime_service, mock_series_app ): """Test download operation (callback parameter removed).""" # Download no longer accepts callback parameter # Progress is tracked via event handlers automatically mock_series_app.download.return_value = True result = await anime_service.download( serie_folder="test_series", season=1, episode=1, key="test_key", ) assert result is True # Verify download was called with correct parameters mock_series_app.download.assert_called_once_with( serie_folder="test_series", season=1, episode=1, key="test_key", item_id=None, ) @pytest.mark.asyncio async def test_download_error_handling( self, anime_service, mock_series_app ): """Test error handling during download.""" mock_series_app.download.side_effect = Exception("Download failed") with pytest.raises(AnimeServiceError, match="Download failed"): await anime_service.download( serie_folder="test_series", season=1, episode=1, key="test_key", ) class TestConcurrency: """Test concurrent operations.""" @pytest.mark.asyncio async def test_multiple_concurrent_operations( self, anime_service, mock_series_app ): """Test that multiple operations can run concurrently.""" mock_series_app.search.return_value = [{"name": "Test"}] # Run multiple searches concurrently tasks = [ anime_service.search("query1"), anime_service.search("query2"), anime_service.search("query3"), ] results = await asyncio.gather(*tasks) assert len(results) == 3 assert all(len(r) == 1 for r in results) class TestNFOTracking: """Test NFO status tracking methods.""" @pytest.mark.asyncio async def test_update_nfo_status_success(self, anime_service): """Test successful NFO status update.""" mock_series = MagicMock() mock_series.key = "test-series" mock_series.id = 1 mock_series.has_nfo = False mock_series.nfo_created_at = None mock_series.nfo_updated_at = None mock_series.tmdb_id = None mock_db = AsyncMock() with patch( 'src.server.database.service.AnimeSeriesService.get_by_key', new_callable=AsyncMock, return_value=mock_series ): await anime_service.update_nfo_status( key="test-series", has_nfo=True, tmdb_id=12345, db=mock_db ) # Verify series was updated via direct attribute setting assert mock_series.has_nfo is True assert mock_series.tmdb_id == 12345 assert mock_series.nfo_created_at is not None assert mock_series.nfo_updated_at is not None mock_db.commit.assert_called_once() @pytest.mark.asyncio async def test_update_nfo_status_not_found(self, anime_service): """Test NFO status update when series not found.""" mock_db = AsyncMock() with patch( 'src.server.database.service.AnimeSeriesService.get_by_key', new_callable=AsyncMock, return_value=None ): await anime_service.update_nfo_status( key="nonexistent", has_nfo=True, db=mock_db ) # Should not commit if series not found mock_db.commit.assert_not_called() @pytest.mark.asyncio async def test_get_series_without_nfo(self, anime_service): """Test getting series without NFO files.""" mock_series1 = MagicMock() mock_series1.key = "series-1" mock_series1.name = "Series 1" mock_series1.folder = "Series 1 (2020)" mock_series1.tmdb_id = 123 mock_series1.tvdb_id = None mock_series2 = MagicMock() mock_series2.key = "series-2" mock_series2.name = "Series 2" mock_series2.folder = "Series 2 (2021)" mock_series2.tmdb_id = None mock_series2.tvdb_id = 456 mock_db = AsyncMock() with patch( 'src.server.database.service.AnimeSeriesService.get_series_without_nfo', new_callable=AsyncMock, return_value=[mock_series1, mock_series2] ): result = await anime_service.get_series_without_nfo(db=mock_db) assert len(result) == 2 assert result[0]["key"] == "series-1" assert result[0]["has_nfo"] is False assert result[0]["tmdb_id"] == 123 assert result[1]["key"] == "series-2" assert result[1]["tvdb_id"] == 456 @pytest.mark.asyncio async def test_get_nfo_statistics(self, anime_service): """Test getting NFO statistics.""" mock_db = AsyncMock() # Mock the scalar result for the tvdb execute query mock_result = MagicMock() mock_result.scalar.return_value = 60 mock_db.execute = AsyncMock(return_value=mock_result) with patch( 'src.server.database.service.AnimeSeriesService.count_all', new_callable=AsyncMock, return_value=100 ), patch( 'src.server.database.service.AnimeSeriesService.count_with_nfo', new_callable=AsyncMock, return_value=75 ), patch( 'src.server.database.service.AnimeSeriesService.count_with_tmdb_id', new_callable=AsyncMock, return_value=80 ), patch( 'src.server.database.service.AnimeSeriesService.count_with_tvdb_id', new_callable=AsyncMock, return_value=60 ): result = await anime_service.get_nfo_statistics(db=mock_db) assert result["total"] == 100 assert result["with_nfo"] == 75 assert result["without_nfo"] == 25 assert result["with_tmdb_id"] == 80 assert result["with_tvdb_id"] == 60 class TestFactoryFunction: """Test factory function.""" def test_get_anime_service(self, mock_series_app): """Test get_anime_service factory function.""" from src.server.services.anime_service import get_anime_service # The factory function requires a series_app parameter service = get_anime_service(mock_series_app) assert isinstance(service, AnimeService) assert service._app is mock_series_app # ============================================================================= # New coverage tests – download / scan status, DB persistence, broadcasting # ============================================================================= class _FakeDownloadArgs: """Minimal stand-in for DownloadStatusEventArgs.""" def __init__(self, **kwargs): self.status = kwargs.get("status", "started") self.serie_folder = kwargs.get("serie_folder", "TestFolder") self.season = kwargs.get("season", 1) self.episode = kwargs.get("episode", 1) self.item_id = kwargs.get("item_id", None) self.progress = kwargs.get("progress", 0) self.message = kwargs.get("message", None) self.error = kwargs.get("error", None) self.mbper_sec = kwargs.get("mbper_sec", None) self.eta = kwargs.get("eta", None) class _FakeScanArgs: """Minimal stand-in for ScanStatusEventArgs.""" def __init__(self, **kwargs): self.status = kwargs.get("status", "started") self.current = kwargs.get("current", 0) self.total = kwargs.get("total", 10) self.folder = kwargs.get("folder", "") self.message = kwargs.get("message", None) self.error = kwargs.get("error", None) class TestOnDownloadStatus: """Test _on_download_status event handler.""" def test_download_started_schedules_start_progress( self, anime_service, mock_progress_service ): """started event should schedule start_progress.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe") as mock_run: args = _FakeDownloadArgs( status="started", item_id="q-1" ) anime_service._on_download_status(args) mock_run.assert_called_once() coro = mock_run.call_args[0][0] assert coro is not None finally: loop.close() def test_download_progress_schedules_update( self, anime_service, mock_progress_service ): """progress event should schedule update_progress.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe") as mock_run: args = _FakeDownloadArgs( status="progress", progress=42, message="Downloading...", mbper_sec=5.5, eta=30, ) anime_service._on_download_status(args) mock_run.assert_called_once() finally: loop.close() def test_download_completed_schedules_complete( self, anime_service, mock_progress_service ): """completed event should schedule complete_progress.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe") as mock_run: args = _FakeDownloadArgs(status="completed") anime_service._on_download_status(args) mock_run.assert_called_once() finally: loop.close() def test_download_failed_schedules_fail( self, anime_service, mock_progress_service ): """failed event should schedule fail_progress.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe") as mock_run: args = _FakeDownloadArgs( status="failed", error=Exception("Err") ) anime_service._on_download_status(args) mock_run.assert_called_once() finally: loop.close() def test_progress_id_from_item_id(self, anime_service): """item_id should be used as progress_id when available.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe") as mock_run: args = _FakeDownloadArgs( status="started", item_id="queue-42" ) anime_service._on_download_status(args) coro = mock_run.call_args[0][0] # The coroutine was created with progress_id="queue-42" assert mock_run.called finally: loop.close() def test_progress_id_fallback_without_item_id(self, anime_service): """Without item_id, progress_id is built from folder/season/episode.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe") as mock_run: args = _FakeDownloadArgs( status="started", item_id=None, serie_folder="FolderX", season=2, episode=5, ) anime_service._on_download_status(args) assert mock_run.called finally: loop.close() def test_no_event_loop_returns_silently(self, anime_service): """No loop available should not raise.""" anime_service._event_loop = None with patch("asyncio.get_running_loop", side_effect=RuntimeError): args = _FakeDownloadArgs(status="started") anime_service._on_download_status(args) # should not raise class TestOnScanStatus: """Test _on_scan_status event handler.""" def test_scan_started_schedules_progress_and_broadcast( self, anime_service, mock_progress_service ): """started scan event should schedule start_progress and broadcast.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe") as mock_run: args = _FakeScanArgs(status="started", total=5) anime_service._on_scan_status(args) # 2 calls: start_progress + broadcast_scan_started_safe assert mock_run.call_count == 2 assert anime_service._is_scanning is True finally: loop.close() def test_scan_progress_updates_counters( self, anime_service, mock_progress_service ): """progress scan event should update counters.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe"): args = _FakeScanArgs( status="progress", current=3, total=10, folder="Naruto" ) anime_service._on_scan_status(args) assert anime_service._scan_directories_count == 3 assert anime_service._scan_current_directory == "Naruto" finally: loop.close() def test_scan_completed_marks_done( self, anime_service, mock_progress_service ): """completed scan event should mark scanning as False.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop anime_service._is_scanning = True anime_service._scan_start_time = time.time() - 5 try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe"): args = _FakeScanArgs(status="completed", total=10) anime_service._on_scan_status(args) assert anime_service._is_scanning is False finally: loop.close() def test_scan_failed_marks_done( self, anime_service, mock_progress_service ): """failed scan event should reset scanning state.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop anime_service._is_scanning = True try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe"): args = _FakeScanArgs( status="failed", error=Exception("boom") ) anime_service._on_scan_status(args) assert anime_service._is_scanning is False finally: loop.close() def test_scan_cancelled_marks_done( self, anime_service, mock_progress_service ): """cancelled scan event should reset scanning state.""" loop = asyncio.new_event_loop() anime_service._event_loop = loop anime_service._is_scanning = True try: with patch("asyncio.get_running_loop", side_effect=RuntimeError): with patch("asyncio.run_coroutine_threadsafe"): args = _FakeScanArgs(status="cancelled") anime_service._on_scan_status(args) assert anime_service._is_scanning is False finally: loop.close() def test_scan_no_loop_returns_silently(self, anime_service): """No loop available should not raise for scan events.""" anime_service._event_loop = None with patch("asyncio.get_running_loop", side_effect=RuntimeError): args = _FakeScanArgs(status="started") anime_service._on_scan_status(args) # no error class TestGetScanStatus: """Test get_scan_status method.""" def test_returns_status_dict(self, anime_service): """Should return dict with expected keys.""" anime_service._is_scanning = True anime_service._scan_total_items = 42 anime_service._scan_directories_count = 7 anime_service._scan_current_directory = "Naruto" result = anime_service.get_scan_status() assert result["is_scanning"] is True assert result["total_items"] == 42 assert result["directories_scanned"] == 7 assert result["current_directory"] == "Naruto" class TestBroadcastHelpers: """Test WebSocket broadcast safety wrappers.""" @pytest.mark.asyncio async def test_broadcast_scan_started_safe(self, anime_service): """Should call websocket_service.broadcast_scan_started.""" anime_service._websocket_service.broadcast_scan_started = AsyncMock() await anime_service._broadcast_scan_started_safe(total_items=5) anime_service._websocket_service.broadcast_scan_started.assert_called_once() @pytest.mark.asyncio async def test_broadcast_scan_started_safe_handles_error( self, anime_service ): """WS failure should be swallowed, not raised.""" anime_service._websocket_service.broadcast_scan_started = AsyncMock( side_effect=Exception("ws-down") ) # Should NOT raise await anime_service._broadcast_scan_started_safe(total_items=5) @pytest.mark.asyncio async def test_broadcast_scan_progress_safe(self, anime_service): """Should call broadcast_scan_progress.""" anime_service._websocket_service.broadcast_scan_progress = AsyncMock() await anime_service._broadcast_scan_progress_safe( directories_scanned=3, files_found=3, current_directory="AOT", total_items=10, ) anime_service._websocket_service.broadcast_scan_progress.assert_called_once() @pytest.mark.asyncio async def test_broadcast_scan_progress_safe_handles_error( self, anime_service ): """WS failure should be swallowed.""" anime_service._websocket_service.broadcast_scan_progress = AsyncMock( side_effect=Exception("ws-down") ) await anime_service._broadcast_scan_progress_safe( directories_scanned=0, files_found=0, current_directory="", total_items=0, ) @pytest.mark.asyncio async def test_broadcast_scan_completed_safe(self, anime_service): """Should call broadcast_scan_completed.""" anime_service._websocket_service.broadcast_scan_completed = AsyncMock() await anime_service._broadcast_scan_completed_safe( total_directories=10, total_files=10, elapsed_seconds=5.0, ) anime_service._websocket_service.broadcast_scan_completed.assert_called_once() @pytest.mark.asyncio async def test_broadcast_scan_completed_safe_handles_error( self, anime_service ): """WS failure should be swallowed.""" anime_service._websocket_service.broadcast_scan_completed = AsyncMock( side_effect=Exception("ws-down") ) await anime_service._broadcast_scan_completed_safe( total_directories=0, total_files=0, elapsed_seconds=0, ) @pytest.mark.asyncio async def test_broadcast_series_updated(self, anime_service): """Should broadcast series_updated over WebSocket.""" anime_service._websocket_service.broadcast = AsyncMock() await anime_service._broadcast_series_updated("aot") anime_service._websocket_service.broadcast.assert_called_once() payload = anime_service._websocket_service.broadcast.call_args[0][0] assert payload["type"] == "series_updated" assert payload["key"] == "aot" @pytest.mark.asyncio async def test_broadcast_series_updated_no_ws_service(self, anime_service): """Should return silently if no websocket service.""" anime_service._websocket_service = None await anime_service._broadcast_series_updated("aot") # no error class TestListSeriesWithFilters: """Test list_series_with_filters with database enrichment.""" @pytest.mark.asyncio async def test_returns_enriched_list( self, anime_service, mock_series_app ): """Should merge SeriesApp data with DB metadata.""" mock_serie = MagicMock() mock_serie.key = "aot" mock_serie.name = "Attack on Titan" mock_serie.site = "aniworld.to" mock_serie.folder = "Attack on Titan (2013)" mock_serie.episodeDict = {1: [2, 3]} mock_list = MagicMock() mock_list.GetList.return_value = [mock_serie] mock_series_app.list = mock_list mock_db_series = MagicMock() mock_db_series.folder = "Attack on Titan (2013)" mock_db_series.has_nfo = True mock_db_series.nfo_created_at = None mock_db_series.nfo_updated_at = None mock_db_series.tmdb_id = 1234 mock_db_series.tvdb_id = None mock_db_series.id = 1 mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService" ) as MockASS: MockASS.get_all = AsyncMock(return_value=[mock_db_series]) result = await anime_service.list_series_with_filters() assert len(result) == 1 assert result[0]["key"] == "aot" assert result[0]["has_nfo"] is True assert result[0]["tmdb_id"] == 1234 @pytest.mark.asyncio async def test_empty_series_returns_empty( self, anime_service, mock_series_app ): """Should return [] when SeriesApp has no series.""" mock_list = MagicMock() mock_list.GetList.return_value = [] mock_series_app.list = mock_list result = await anime_service.list_series_with_filters() assert result == [] @pytest.mark.asyncio async def test_no_list_attribute_returns_empty( self, anime_service, mock_series_app ): """Should return [] when SeriesApp has no list attribute.""" del mock_series_app.list result = await anime_service.list_series_with_filters() assert result == [] @pytest.mark.asyncio async def test_db_error_raises_anime_service_error( self, anime_service, mock_series_app ): """DB failure should raise AnimeServiceError.""" mock_serie = MagicMock() mock_serie.key = "aot" mock_serie.name = "AOT" mock_serie.site = "x" mock_serie.folder = "AOT" mock_serie.episodeDict = {} mock_list = MagicMock() mock_list.GetList.return_value = [mock_serie] mock_series_app.list = mock_list mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock( side_effect=RuntimeError("DB down") ) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ): with pytest.raises(AnimeServiceError): await anime_service.list_series_with_filters() class TestSaveAndLoadDB: """Test DB persistence helpers.""" @pytest.mark.asyncio async def test_save_scan_results_creates_new( self, anime_service ): """New series should be created in DB.""" mock_serie = MagicMock() mock_serie.key = "naruto" mock_serie.name = "Naruto" mock_serie.site = "aniworld.to" mock_serie.folder = "Naruto" mock_serie.year = 2002 mock_serie.episodeDict = {1: [1, 2]} mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=None, ), patch( "src.server.database.service.AnimeSeriesService.create", new_callable=AsyncMock, return_value=MagicMock(id=1), ) as mock_create, patch( "src.server.database.service.EpisodeService.create", new_callable=AsyncMock, ) as mock_ep_create: count = await anime_service._save_scan_results_to_db( [mock_serie] ) assert count == 1 mock_create.assert_called_once() assert mock_ep_create.call_count == 2 @pytest.mark.asyncio async def test_save_scan_results_updates_existing( self, anime_service ): """Existing series should be updated in DB.""" mock_serie = MagicMock() mock_serie.key = "naruto" mock_serie.name = "Naruto" mock_serie.site = "aniworld.to" mock_serie.folder = "Naruto" mock_serie.episodeDict = {1: [3]} existing = MagicMock() existing.id = 1 existing.folder = "Naruto" mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=existing, ), patch.object( anime_service, "_update_series_in_db", new_callable=AsyncMock, ) as mock_update: count = await anime_service._save_scan_results_to_db( [mock_serie] ) assert count == 1 mock_update.assert_called_once() @pytest.mark.asyncio async def test_load_series_from_db( self, anime_service, mock_series_app ): """Should populate SeriesApp from DB records.""" mock_ep = MagicMock() mock_ep.season = 1 mock_ep.episode_number = 5 mock_db_series = MagicMock() mock_db_series.key = "naruto" mock_db_series.name = "Naruto" mock_db_series.site = "aniworld.to" mock_db_series.folder = "Naruto" mock_db_series.episodes = [mock_ep] mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_all", new_callable=AsyncMock, return_value=[mock_db_series], ): await anime_service._load_series_from_db() mock_series_app.load_series_from_list.assert_called_once() loaded = mock_series_app.load_series_from_list.call_args[0][0] assert len(loaded) == 1 assert loaded[0].key == "naruto" @pytest.mark.asyncio async def test_sync_episodes_to_db( self, anime_service, mock_series_app ): """Should sync missing episodes from memory to DB.""" mock_serie = MagicMock() mock_serie.episodeDict = {1: [4, 5]} mock_list = MagicMock() mock_list.keyDict = {"aot": mock_serie} mock_series_app.list = mock_list mock_db_series = MagicMock() mock_db_series.id = 10 mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) anime_service._websocket_service = MagicMock() anime_service._websocket_service.broadcast = AsyncMock() with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_db_series, ), patch( "src.server.database.service.EpisodeService.get_by_series", new_callable=AsyncMock, return_value=[], ), patch( "src.server.database.service.EpisodeService.create", new_callable=AsyncMock, ) as mock_ep_create: count = await anime_service.sync_episodes_to_db("aot") assert count == 2 assert mock_ep_create.call_count == 2 @pytest.mark.asyncio async def test_sync_episodes_no_list_returns_zero( self, anime_service, mock_series_app ): """No series list should return 0.""" del mock_series_app.list count = await anime_service.sync_episodes_to_db("aot") assert count == 0 class TestAddSeriesToDB: """Test add_series_to_db method.""" @pytest.mark.asyncio async def test_creates_new_series(self, anime_service): """New series should be created in DB.""" mock_serie = MagicMock() mock_serie.key = "x" mock_serie.name = "X" mock_serie.site = "y" mock_serie.folder = "X" mock_serie.year = 2020 mock_serie.episodeDict = {1: [1]} mock_db = AsyncMock() mock_created = MagicMock(id=99) with patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=None, ), patch( "src.server.database.service.AnimeSeriesService.create", new_callable=AsyncMock, return_value=mock_created, ), patch( "src.server.database.service.EpisodeService.create", new_callable=AsyncMock, ): result = await anime_service.add_series_to_db(mock_serie, mock_db) assert result is mock_created @pytest.mark.asyncio async def test_existing_returns_none(self, anime_service): """Already-existing series should return None.""" mock_serie = MagicMock() mock_serie.key = "x" mock_serie.name = "X" mock_db = AsyncMock() with patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=MagicMock(), ): result = await anime_service.add_series_to_db(mock_serie, mock_db) assert result is None class TestContainsInDB: """Test contains_in_db method.""" @pytest.mark.asyncio async def test_exists(self, anime_service): """Should return True when series exists.""" mock_db = AsyncMock() with patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=MagicMock(), ): assert await anime_service.contains_in_db("aot", mock_db) is True @pytest.mark.asyncio async def test_not_exists(self, anime_service): """Should return False when series missing.""" mock_db = AsyncMock() with patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=None, ): assert await anime_service.contains_in_db("x", mock_db) is False class TestUpdateNFOStatusWithoutSession: """Test update_nfo_status when no db session is passed (self-managed).""" @pytest.mark.asyncio async def test_update_creates_session_and_commits(self, anime_service): """Should open its own session and commit.""" mock_series = MagicMock() mock_series.id = 1 mock_series.has_nfo = False mock_series.nfo_created_at = None mock_series.nfo_updated_at = None mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=mock_series, ), patch( "src.server.database.service.AnimeSeriesService.update", new_callable=AsyncMock, ): await anime_service.update_nfo_status( key="test", has_nfo=True, tmdb_id=42 ) # commit called by update path mock_session.commit.assert_called_once() @pytest.mark.asyncio async def test_update_not_found_skips(self, anime_service): """Should return without error if series not in DB.""" mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=None, ): await anime_service.update_nfo_status(key="missing", has_nfo=True) mock_session.commit.assert_not_called() class TestGetSeriesWithoutNFOSelfManaged: """Test get_series_without_nfo when db=None (self-managed session).""" @pytest.mark.asyncio async def test_returns_list(self, anime_service): """Should return formatted dicts.""" mock_s = MagicMock() mock_s.key = "test" mock_s.name = "Test" mock_s.folder = "Test" mock_s.tmdb_id = 1 mock_s.tvdb_id = 2 mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService" ".get_series_without_nfo", new_callable=AsyncMock, return_value=[mock_s], ): result = await anime_service.get_series_without_nfo() assert len(result) == 1 assert result[0]["has_nfo"] is False class TestGetNFOStatisticsSelfManaged: """Test get_nfo_statistics when db=None (self-managed session).""" @pytest.mark.asyncio async def test_returns_stats(self, anime_service): """Should compute statistics correctly.""" mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.count_all", new_callable=AsyncMock, return_value=50, ), patch( "src.server.database.service.AnimeSeriesService.count_with_nfo", new_callable=AsyncMock, return_value=30, ), patch( "src.server.database.service.AnimeSeriesService" ".count_with_tmdb_id", new_callable=AsyncMock, return_value=40, ), patch( "src.server.database.service.AnimeSeriesService" ".count_with_tvdb_id", new_callable=AsyncMock, return_value=20, ): result = await anime_service.get_nfo_statistics() assert result["total"] == 50 assert result["without_nfo"] == 20 assert result["with_tmdb_id"] == 40 class TestSyncSeriesFromDataFiles: """Test module-level sync_series_from_data_files function.""" @pytest.mark.asyncio async def test_sync_adds_new_series(self, tmp_path): """Should create series for data files not in DB.""" mock_serie = MagicMock() mock_serie.key = "new-series" mock_serie.name = "New Series" mock_serie.site = "aniworld.to" mock_serie.folder = "New Series" mock_serie.episodeDict = {1: [1]} mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.services.anime_service.SeriesApp" ) as MockApp, patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=None, ), patch( "src.server.database.service.AnimeSeriesService.create", new_callable=AsyncMock, return_value=MagicMock(id=1), ) as mock_create, patch( "src.server.database.service.EpisodeService.create", new_callable=AsyncMock, ): mock_app_instance = MagicMock() mock_app_instance.get_all_series_from_data_files.return_value = [ mock_serie ] MockApp.return_value = mock_app_instance count = await sync_series_from_data_files(str(tmp_path)) assert count == 1 mock_create.assert_called_once() @pytest.mark.asyncio async def test_sync_skips_existing(self, tmp_path): """Already-existing series should be skipped.""" mock_serie = MagicMock() mock_serie.key = "exists" mock_serie.name = "Exists" mock_serie.site = "x" mock_serie.folder = "Exists" mock_serie.episodeDict = {} mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.services.anime_service.SeriesApp" ) as MockApp, patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=MagicMock(), ), patch( "src.server.database.service.AnimeSeriesService.create", new_callable=AsyncMock, ) as mock_create: mock_app_instance = MagicMock() mock_app_instance.get_all_series_from_data_files.return_value = [ mock_serie ] MockApp.return_value = mock_app_instance count = await sync_series_from_data_files(str(tmp_path)) assert count == 0 mock_create.assert_not_called() @pytest.mark.asyncio async def test_sync_no_data_files(self, tmp_path): """Empty directory should return 0.""" with patch( "src.server.services.anime_service.SeriesApp" ) as MockApp: mock_app_instance = MagicMock() mock_app_instance.get_all_series_from_data_files.return_value = [] MockApp.return_value = mock_app_instance count = await sync_series_from_data_files(str(tmp_path)) assert count == 0 @pytest.mark.asyncio async def test_sync_handles_empty_name(self, tmp_path): """Series with empty name should use folder as fallback.""" mock_serie = MagicMock() mock_serie.key = "no-name" mock_serie.name = "" mock_serie.site = "x" mock_serie.folder = "FallbackFolder" mock_serie.episodeDict = {} mock_session = AsyncMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch( "src.server.services.anime_service.SeriesApp" ) as MockApp, patch( "src.server.database.connection.get_db_session", return_value=mock_ctx, ), patch( "src.server.database.service.AnimeSeriesService.get_by_key", new_callable=AsyncMock, return_value=None, ), patch( "src.server.database.service.AnimeSeriesService.create", new_callable=AsyncMock, return_value=MagicMock(id=1), ) as mock_create: mock_app_instance = MagicMock() mock_app_instance.get_all_series_from_data_files.return_value = [ mock_serie ] MockApp.return_value = mock_app_instance count = await sync_series_from_data_files(str(tmp_path)) assert count == 1 # The name should have been set to folder assert mock_serie.name == "FallbackFolder"