"""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 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_update_series_deletes_downloaded_episodes_when_no_longer_missing( self, anime_service ): """Regression: a finished download marks the Episode row with is_downloaded=True. When a later rescan confirms the file is on disk (i.e. the episode is no longer in the missing set), the DB row should be deleted so it stops appearing in queries. Bug shape: previously the ``downloaded_set`` guard in ``_update_series_in_db`` preserved the row forever, so the DB kept stale ``is_downloaded=True`` entries that the user could see in the database but not in any UI. """ mock_serie = MagicMock() mock_serie.key = "the-100-girlfriends" mock_serie.name = "The 100 Girlfriends" mock_serie.site = "aniworld.to" mock_serie.folder = "The 100 Girlfriends (2023)" # Scanner reports no missing episodes for this series — # every file is on disk. mock_serie.episodeDict = {} existing = MagicMock() existing.id = 1 existing.folder = "The 100 Girlfriends (2023)" # DB currently has one row for S03E08 marked as downloaded # (the result of an earlier successful download). The # scanner confirms the file is on disk, so the episode is # no longer missing. existing_eps = [ MagicMock( id=10, season=3, episode_number=8, is_downloaded=True, ), ] mock_session = AsyncMock() with patch( "src.server.database.service.EpisodeService.get_by_series", new_callable=AsyncMock, return_value=existing_eps, ), patch( "src.server.database.service.EpisodeService.delete", new_callable=AsyncMock, ) as mock_delete: await anime_service._update_series_in_db( mock_serie, existing, mock_session ) # The downloaded episode (S03E08) MUST be deleted — the file # is on disk and the scanner does not report it as missing. # EpisodeService.delete is (db, episode_id) — episode_id is # the second positional arg. deleted_ids = [ call.args[1] for call in mock_delete.call_args_list ] assert deleted_ids == [10], ( f"Expected S03E08 (id=10) to be the only deleted row; " f"got delete calls for {deleted_ids}" ) @pytest.mark.asyncio async def test_update_series_keeps_still_missing_episodes( self, anime_service ): """A still-missing episode (is_downloaded=False, in scanner's missing set) must NOT be deleted by _update_series_in_db. Sanity-check sibling to the downloaded-episode regression test, ensuring the fix does not over-reach. """ mock_serie = MagicMock() mock_serie.key = "naruto" mock_serie.name = "Naruto" mock_serie.site = "aniworld.to" mock_serie.folder = "Naruto" # Scanner reports S01E07 still missing. mock_serie.episodeDict = {1: [7]} existing = MagicMock() existing.id = 1 existing.folder = "Naruto" existing_eps = [ MagicMock( id=20, season=1, episode_number=7, is_downloaded=False, ), ] mock_session = AsyncMock() with patch( "src.server.database.service.EpisodeService.get_by_series", new_callable=AsyncMock, return_value=existing_eps, ), patch( "src.server.database.service.EpisodeService.delete", new_callable=AsyncMock, ) as mock_delete, patch( "src.server.database.service.EpisodeService.create", new_callable=AsyncMock, ): await anime_service._update_series_in_db( mock_serie, existing, mock_session ) # S01E07 is still missing per the scanner — it must NOT be # deleted. (No new episode needs to be created either — it # is already in the DB.) assert mock_delete.call_count == 0, ( f"Still-missing episode S01E07 must not be deleted; " f"got delete calls: {mock_delete.call_args_list}" ) @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