"""Unit tests for AnimeService. Tests cover service initialization, async operations, caching, error handling, and progress reporting integration. """ from __future__ import annotations import asyncio 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 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