From 35c82e68b7b15d5d9442e7426478ba90c3532eb7 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 21 Jan 2026 19:02:39 +0100 Subject: [PATCH] test: Add unit tests for anime list loading fix - Test that SeriesApp.list starts empty with skip_load=True - Test that load_series_from_list populates the keyDict correctly - Test that _load_series_from_db loads series from database into memory - Test that /api/anime endpoint returns series after loading - Test empty database edge case - Test episode dict conversion from DB format - All 7 tests passing --- tests/unit/test_anime_list_loading.py | 339 ++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 tests/unit/test_anime_list_loading.py diff --git a/tests/unit/test_anime_list_loading.py b/tests/unit/test_anime_list_loading.py new file mode 100644 index 0000000..0c1e8a8 --- /dev/null +++ b/tests/unit/test_anime_list_loading.py @@ -0,0 +1,339 @@ +"""Unit tests for anime list loading from database. + +Tests the fix for the issue where /api/anime returned empty array +because series weren't loaded from database into SeriesApp memory. +""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from src.core.entities.series import Serie +from src.core.SeriesApp import SeriesApp +from src.server.database.models import AnimeSeries, Episode +from src.server.services.anime_service import AnimeService + + +class TestAnimeListLoading: + """Test suite for anime list loading functionality.""" + + @pytest.fixture + def mock_series_app(self): + """Create a mock SeriesApp instance.""" + app = MagicMock(spec=SeriesApp) + app.directory_to_search = "/test/anime" + app.list = MagicMock() + app.list.keyDict = {} + app.list.GetList = MagicMock(return_value=[]) + app.load_series_from_list = MagicMock() + return app + + @pytest.fixture + def sample_db_series(self): + """Create sample database series for testing.""" + series1 = AnimeSeries( + id=1, + key="test-anime-1", + name="Test Anime 1", + site="aniworld.to", + folder="Test Anime 1 (2023)", + year=2023, + episodes=[] + ) + + series2 = AnimeSeries( + id=2, + key="test-anime-2", + name="Test Anime 2", + site="aniworld.to", + folder="Test Anime 2 (2024)", + year=2024, + episodes=[ + Episode( + id=1, + series_id=2, + season=1, + episode_number=1 + ), + Episode( + id=2, + series_id=2, + season=1, + episode_number=2 + ) + ] + ) + + return [series1, series2] + + @pytest.mark.asyncio + async def test_load_series_from_db_populates_series_app( + self, mock_series_app, sample_db_series + ): + """Test that _load_series_from_db loads series into SeriesApp memory. + + This is the core fix for the empty anime list issue: + - Database has series data + - _load_series_from_db() should load them into SeriesApp.list + - series_app.list.GetList() should then return the series + """ + # Create AnimeService with mock SeriesApp + anime_service = AnimeService(mock_series_app) + + # Mock database session and service + with patch('src.server.database.connection.get_db_session') as mock_get_session, \ + patch('src.server.database.service.AnimeSeriesService') as mock_db_service: + + # Setup mock database to return sample series + mock_db = AsyncMock() + mock_get_session.return_value.__aenter__.return_value = mock_db + mock_db_service.get_all = AsyncMock(return_value=sample_db_series) + + # Call the method that loads series from DB + await anime_service._load_series_from_db() + + # Verify load_series_from_list was called + mock_series_app.load_series_from_list.assert_called_once() + + # Verify it was called with Serie objects + called_series = mock_series_app.load_series_from_list.call_args[0][0] + assert len(called_series) == 2 + + # Verify Serie objects have correct attributes + assert all(isinstance(s, Serie) for s in called_series) + assert called_series[0].key == "test-anime-1" + assert called_series[0].name == "Test Anime 1" + assert called_series[0].folder == "Test Anime 1 (2023)" + assert called_series[0].episodeDict == {} + + assert called_series[1].key == "test-anime-2" + assert called_series[1].name == "Test Anime 2" + assert called_series[1].episodeDict == {1: [1, 2]} + + @pytest.mark.asyncio + async def test_series_app_list_empty_before_loading(self, tmpdir): + """Test that SeriesApp.list is empty when initialized with skip_load=True. + + This demonstrates the original issue: + - SeriesApp is initialized with skip_load=True + - GetList() returns empty list until series are loaded from DB + """ + # Create a real SeriesApp with skip_load=True + anime_dir = str(tmpdir.mkdir("anime")) + series_app = SeriesApp(anime_dir) + + # Verify list is empty (this was the bug) + assert len(series_app.list.GetList()) == 0 + assert len(series_app.list.keyDict) == 0 + + def test_load_series_from_list_populates_keydict(self, tmpdir): + """Test that load_series_from_list correctly populates the keyDict. + + This is the method that fixes the empty list issue by loading + series from database into SeriesApp memory. + """ + # Create a real SeriesApp + anime_dir = str(tmpdir.mkdir("anime")) + series_app = SeriesApp(anime_dir) + + # Verify list starts empty + assert len(series_app.list.GetList()) == 0 + + # Create test series + test_series = [ + Serie( + key="test-1", + name="Test Series 1", + site="aniworld.to", + folder="Test Series 1 (2023)", + episodeDict={1: [1, 2, 3]} + ), + Serie( + key="test-2", + name="Test Series 2", + site="aniworld.to", + folder="Test Series 2 (2024)", + episodeDict={} + ) + ] + + # Load series into SeriesApp (this is what the fix does) + series_app.load_series_from_list(test_series) + + # Verify list is now populated + loaded_series = series_app.list.GetList() + assert len(loaded_series) == 2 + assert loaded_series[0].key == "test-1" + assert loaded_series[1].key == "test-2" + + # Verify keyDict is populated correctly + assert "test-1" in series_app.list.keyDict + assert "test-2" in series_app.list.keyDict + assert series_app.list.keyDict["test-1"].name == "Test Series 1" + + @pytest.mark.asyncio + async def test_anime_endpoint_returns_series_after_loading( + self, tmpdir + ): + """Integration test: Verify /api/anime endpoint returns series after loading. + + This tests the complete flow: + 1. Series exist in database + 2. _load_series_from_db() loads them into memory + 3. /api/anime endpoint returns them + """ + from httpx import ASGITransport, AsyncClient + from src.server.fastapi_app import app as fastapi_app + from src.server.utils.dependencies import get_series_app, require_auth + + # Create real SeriesApp and load test data + anime_dir = str(tmpdir.mkdir("anime")) + series_app = SeriesApp(anime_dir) + test_series = [ + Serie( + key="attack-on-titan", + name="Attack on Titan", + site="aniworld.to", + folder="Attack on Titan (2013)", + episodeDict={1: [1, 2]} + ), + Serie( + key="one-piece", + name="One Piece", + site="aniworld.to", + folder="One Piece (1999)", + episodeDict={} + ) + ] + series_app.load_series_from_list(test_series) + + # Override dependencies to use our test SeriesApp and skip auth + fastapi_app.dependency_overrides[get_series_app] = lambda: series_app + fastapi_app.dependency_overrides[require_auth] = lambda: {"user": "test"} + + try: + transport = ASGITransport(app=fastapi_app) + async with AsyncClient( + transport=transport, + base_url="http://test" + ) as client: + # Call the endpoint (no auth needed due to override) + response = await client.get("/api/anime") + + # Verify response + assert response.status_code == 200 + data = response.json() + + # This was the bug: empty array + # After fix: returns series + assert len(data) == 2 + assert data[0]["key"] == "attack-on-titan" + assert data[0]["name"] == "Attack on Titan" + assert data[0]["has_missing"] is True + assert data[1]["key"] == "one-piece" + assert data[1]["has_missing"] is False + finally: + # Clean up override + fastapi_app.dependency_overrides.clear() + + @pytest.mark.asyncio + async def test_empty_database_returns_empty_list(self, tmpdir): + """Test that empty database correctly returns empty list. + + Edge case: No series in database should return empty array, + not cause an error. + """ + from httpx import ASGITransport, AsyncClient + from src.server.fastapi_app import app as fastapi_app + from src.server.utils.dependencies import get_series_app, require_auth + + # Create SeriesApp with no series + anime_dir = str(tmpdir.mkdir("anime")) + series_app = SeriesApp(anime_dir) + series_app.load_series_from_list([]) # Empty list + + # Override dependencies + fastapi_app.dependency_overrides[get_series_app] = lambda: series_app + fastapi_app.dependency_overrides[require_auth] = lambda: {"user": "test"} + + try: + transport = ASGITransport(app=fastapi_app) + async with AsyncClient( + transport=transport, + base_url="http://test" + ) as client: + # Call the endpoint (no auth needed due to override) + response = await client.get("/api/anime") + + # Should return 200 with empty array + assert response.status_code == 200 + data = response.json() + assert data == [] + finally: + # Clean up override + fastapi_app.dependency_overrides.clear() + + def test_series_app_skip_load_behavior(self, tmpdir): + """Test that skip_load=True prevents automatic filesystem loading. + + This documents why the bug occurred: + - SeriesApp initializes with skip_load=True + - This prevents automatic loading from filesystem + - Series must be explicitly loaded via load_series_from_list() + """ + # Test with skip_load=True (default in production) + anime_dir = str(tmpdir.mkdir("anime")) + series_app = SeriesApp(anime_dir) + assert len(series_app.list.GetList()) == 0, \ + "With skip_load=True, list should be empty initially" + + # Test that manual loading works + test_serie = Serie( + key="test", + name="Test", + site="aniworld.to", + folder="Test (2023)", + episodeDict={} + ) + series_app.load_series_from_list([test_serie]) + + assert len(series_app.list.GetList()) == 1, \ + "After load_series_from_list, series should be available" + + @pytest.mark.asyncio + async def test_load_series_with_missing_episodes(self, sample_db_series): + """Test that missing episodes are correctly converted from DB format. + + Verifies that Episode records in database are correctly converted + to episodeDict format in Serie objects. + """ + from src.server.services.anime_service import AnimeService + + # Create mock SeriesApp + series_app = MagicMock(spec=SeriesApp) + series_app.directory_to_search = "/test/anime" + series_app.load_series_from_list = MagicMock() + + anime_service = AnimeService(series_app) + + # Mock database + with patch('src.server.database.connection.get_db_session') as mock_get_session, \ + patch('src.server.database.service.AnimeSeriesService') as mock_db_service: + + mock_db = AsyncMock() + mock_get_session.return_value.__aenter__.return_value = mock_db + mock_db_service.get_all = AsyncMock(return_value=sample_db_series) + + # Load from DB + await anime_service._load_series_from_db() + + # Get the loaded series + called_series = series_app.load_series_from_list.call_args[0][0] + + # Verify episode dict conversion + series_with_episodes = [s for s in called_series if s.episodeDict] + assert len(series_with_episodes) == 1 + assert series_with_episodes[0].key == "test-anime-2" + assert series_with_episodes[0].episodeDict == {1: [1, 2]} + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])