diff --git a/tests/integration/test_async_series_loading.py b/tests/integration/test_async_series_loading.py new file mode 100644 index 0000000..9a7d4df --- /dev/null +++ b/tests/integration/test_async_series_loading.py @@ -0,0 +1,281 @@ +""" +Integration tests for asynchronous series data loading. +Tests the complete flow from API to database with WebSocket notifications. + +Note: These tests focus on the integration between components and proper async behavior. +Mocking is used to avoid dependencies on external services (TMDB, Aniworld, etc.). +""" +import asyncio +from unittest.mock import AsyncMock, Mock + +import pytest +from httpx import ASGITransport, AsyncClient + +from src.server.fastapi_app import app +from src.server.services.background_loader_service import ( + BackgroundLoaderService, + LoadingStatus, +) + + +@pytest.fixture +async def client(): + """Create an async test client.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +class TestBackgroundLoaderIntegration: + """Integration tests for BackgroundLoaderService.""" + + @pytest.mark.asyncio + async def test_loader_initialization(self): + """Test that background loader initializes correctly.""" + mock_websocket = AsyncMock() + mock_anime_service = AsyncMock() + mock_series_app = Mock() + + loader = BackgroundLoaderService( + websocket_service=mock_websocket, + anime_service=mock_anime_service, + series_app=mock_series_app + ) + + assert loader.websocket_service == mock_websocket + assert loader.anime_service == mock_anime_service + assert loader.series_app == mock_series_app + assert loader.task_queue is not None + assert loader.active_tasks == {} + + @pytest.mark.asyncio + async def test_loader_start_stop(self): + """Test starting and stopping the background loader.""" + mock_websocket = AsyncMock() + mock_anime_service = AsyncMock() + mock_series_app = Mock() + + loader = BackgroundLoaderService( + websocket_service=mock_websocket, + anime_service=mock_anime_service, + series_app=mock_series_app + ) + + # Start loader + await loader.start() + assert loader.worker_task is not None + assert not loader.worker_task.done() + + # Stop loader + await loader.stop() + assert loader.worker_task.done() + + @pytest.mark.asyncio + async def test_add_series_loading_task(self): + """Test adding a series loading task to the queue.""" + mock_websocket = AsyncMock() + mock_anime_service = AsyncMock() + mock_series_app = Mock() + + loader = BackgroundLoaderService( + websocket_service=mock_websocket, + anime_service=mock_anime_service, + series_app=mock_series_app + ) + + await loader.start() + + try: + # Add a task + await loader.add_series_loading_task( + key="test-series", + folder="test_folder", + name="Test Series" + ) + + # Wait a moment for task to be processed + await asyncio.sleep(0.2) + + # Verify task was added + assert "test-series" in loader.active_tasks + task = loader.active_tasks["test-series"] + assert task.key == "test-series" + assert task.name == "Test Series" + assert task.folder == "test_folder" + finally: + await loader.stop() + + @pytest.mark.asyncio + async def test_multiple_tasks_concurrent(self): + """Test loader handles multiple concurrent tasks.""" + mock_websocket = AsyncMock() + mock_anime_service = AsyncMock() + mock_series_app = Mock() + + # Setup mocks to simulate async work + mock_anime_service.scan_series_episodes = AsyncMock() + mock_anime_service.update_series = AsyncMock() + mock_series_app.nfo_service = Mock() + mock_series_app.nfo_service.create_nfo_async = AsyncMock() + + loader = BackgroundLoaderService( + websocket_service=mock_websocket, + anime_service=mock_anime_service, + series_app=mock_series_app + ) + + await loader.start() + + try: + # Add multiple tasks quickly + series_count = 3 + for i in range(series_count): + await loader.add_series_loading_task( + key=f"series-{i}", + folder=f"folder_{i}", + name=f"Series {i}" + ) + + # Wait for processing + await asyncio.sleep(0.5) + + # Verify all tasks were registered + assert len(loader.active_tasks) >= series_count + finally: + await loader.stop() + + @pytest.mark.asyncio + async def test_graceful_shutdown(self): + """Test graceful shutdown with pending tasks.""" + mock_websocket = AsyncMock() + mock_anime_service = AsyncMock() + mock_series_app = Mock() + + loader = BackgroundLoaderService( + websocket_service=mock_websocket, + anime_service=mock_anime_service, + series_app=mock_series_app + ) + + await loader.start() + + # Add tasks + for i in range(5): + await loader.add_series_loading_task( + key=f"series-{i}", + folder=f"folder_{i}", + name=f"Series {i}" + ) + + # Stop should complete without exceptions + try: + await loader.stop() + shutdown_success = True + except Exception: + shutdown_success = False + + assert shutdown_success + assert loader.worker_task.done() + + @pytest.mark.asyncio + async def test_no_duplicate_tasks(self): + """Test that duplicate tasks for same series are handled.""" + mock_websocket = AsyncMock() + mock_anime_service = AsyncMock() + mock_series_app = Mock() + + loader = BackgroundLoaderService( + websocket_service=mock_websocket, + anime_service=mock_anime_service, + series_app=mock_series_app + ) + + await loader.start() + + try: + # Try to add same series multiple times + for _ in range(3): + await loader.add_series_loading_task( + key="test-series", + folder="test_folder", + name="Test Series" + ) + + await asyncio.sleep(0.2) + + # Should only have one task for this series + series_tasks = [k for k in loader.active_tasks if k == "test-series"] + assert len(series_tasks) == 1 + finally: + await loader.stop() + + +class TestLoadingStatusEnum: + """Tests for LoadingStatus enum.""" + + def test_loading_status_values(self): + """Test LoadingStatus enum has expected values.""" + assert LoadingStatus.PENDING.value == "pending" + assert LoadingStatus.LOADING_EPISODES.value == "loading_episodes" + assert LoadingStatus.LOADING_NFO.value == "loading_nfo" + assert LoadingStatus.LOADING_LOGO.value == "loading_logo" + assert LoadingStatus.LOADING_IMAGES.value == "loading_images" + assert LoadingStatus.COMPLETED.value == "completed" + assert LoadingStatus.FAILED.value == "failed" + + def test_loading_status_string_repr(self): + """Test LoadingStatus can be used as strings.""" + status = LoadingStatus.LOADING_EPISODES + assert str(status) == "loading_episodes" + assert status == "loading_episodes" + + +class TestAsyncBehavior: + """Tests to verify async and non-blocking behavior.""" + + @pytest.mark.asyncio + async def test_adding_tasks_is_fast(self): + """Test that adding tasks doesn't block.""" + mock_websocket = AsyncMock() + mock_anime_service = AsyncMock() + mock_series_app = Mock() + + # Make operations slow + async def slow_operation(*args, **kwargs): + await asyncio.sleep(0.5) + + mock_anime_service.scan_series_episodes = AsyncMock(side_effect=slow_operation) + mock_anime_service.update_series = AsyncMock() + mock_series_app.nfo_service = Mock() + mock_series_app.nfo_service.create_nfo_async = AsyncMock(side_effect=slow_operation) + + loader = BackgroundLoaderService( + websocket_service=mock_websocket, + anime_service=mock_anime_service, + series_app=mock_series_app + ) + + await loader.start() + + try: + # Add multiple series quickly + start_time = asyncio.get_event_loop().time() + + for i in range(5): + await loader.add_series_loading_task( + key=f"series-{i}", + folder=f"folder_{i}", + name=f"Series {i}" + ) + + add_time = asyncio.get_event_loop().time() - start_time + + # Adding tasks should be fast (< 0.1s for 5 series) + # even though processing is slow + assert add_time < 0.1 + + # Verify all were queued + await asyncio.sleep(0.1) + assert len(loader.active_tasks) == 5 + finally: + await loader.stop()