"""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 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.has_nfo = False mock_series.nfo_created_at = None mock_series.nfo_updated_at = None mock_series.tmdb_id = None mock_query = MagicMock() mock_query.filter.return_value.first.return_value = mock_series mock_db = MagicMock() mock_db.query.return_value = mock_query # Update NFO status await anime_service.update_nfo_status( key="test-series", has_nfo=True, tmdb_id=12345, db=mock_db ) # Verify series was updated 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_query = MagicMock() mock_query.filter.return_value.first.return_value = None mock_db = MagicMock() mock_db.query.return_value = mock_query # Should not raise, just log warning 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_query = MagicMock() mock_query.filter.return_value.all.return_value = [ mock_series1, mock_series2 ] mock_db = MagicMock() mock_db.query.return_value = mock_query 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 = MagicMock() # Mock total count mock_total_query = MagicMock() mock_total_query.count.return_value = 100 # Mock with_nfo count mock_with_nfo_query = MagicMock() mock_with_nfo_filter = MagicMock() mock_with_nfo_filter.count.return_value = 75 mock_with_nfo_query.filter.return_value = mock_with_nfo_filter # Mock with_tmdb count mock_with_tmdb_query = MagicMock() mock_with_tmdb_filter = MagicMock() mock_with_tmdb_filter.count.return_value = 80 mock_with_tmdb_query.filter.return_value = mock_with_tmdb_filter # Mock with_tvdb count mock_with_tvdb_query = MagicMock() mock_with_tvdb_filter = MagicMock() mock_with_tvdb_filter.count.return_value = 60 mock_with_tvdb_query.filter.return_value = mock_with_tvdb_filter # Configure mock to return different queries for each call query_returns = [ mock_total_query, mock_with_nfo_query, mock_with_tmdb_query, mock_with_tvdb_query ] mock_db.query.side_effect = query_returns 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