Implement async series data loading with background processing
- Add loading status fields to AnimeSeries model
- Create BackgroundLoaderService for async task processing
- Update POST /api/anime/add to return 202 Accepted immediately
- Add GET /api/anime/{key}/loading-status endpoint
- Integrate background loader with startup/shutdown lifecycle
- Create database migration script for loading status fields
- Add unit tests for BackgroundLoaderService (10 tests, all passing)
- Update AnimeSeriesService.create() to accept loading status fields
Architecture follows clean separation with no code duplication:
- BackgroundLoader orchestrates, doesn't reimplement
- Reuses existing AnimeService, NFOService, WebSocket patterns
- Database-backed status survives restarts
This commit is contained in:
201
tests/unit/test_background_loader_service.py
Normal file
201
tests/unit/test_background_loader_service.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Unit tests for BackgroundLoaderService.
|
||||
|
||||
Tests task queuing, status tracking, and worker logic in isolation.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.background_loader_service import (
|
||||
BackgroundLoaderService,
|
||||
LoadingStatus,
|
||||
SeriesLoadingTask,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket_service():
|
||||
"""Mock WebSocket service."""
|
||||
service = Mock()
|
||||
service.broadcast = AsyncMock()
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service():
|
||||
"""Mock anime service."""
|
||||
service = Mock()
|
||||
service.rescan = AsyncMock()
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series_app():
|
||||
"""Mock SeriesApp."""
|
||||
app = Mock()
|
||||
app.directory_to_search = "/test/anime"
|
||||
app.nfo_service = Mock()
|
||||
app.nfo_service.create_tvshow_nfo = AsyncMock()
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def background_loader(mock_websocket_service, mock_anime_service, mock_series_app):
|
||||
"""Create BackgroundLoaderService instance."""
|
||||
service = BackgroundLoaderService(
|
||||
websocket_service=mock_websocket_service,
|
||||
anime_service=mock_anime_service,
|
||||
series_app=mock_series_app
|
||||
)
|
||||
yield service
|
||||
await service.stop()
|
||||
|
||||
|
||||
class TestBackgroundLoaderService:
|
||||
"""Test suite for BackgroundLoaderService."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_initialization(self, background_loader):
|
||||
"""Test service initializes correctly."""
|
||||
assert background_loader.task_queue is not None
|
||||
assert isinstance(background_loader.active_tasks, dict)
|
||||
assert len(background_loader.active_tasks) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker(self, background_loader):
|
||||
"""Test worker starts successfully."""
|
||||
await background_loader.start()
|
||||
assert background_loader.worker_task is not None
|
||||
assert not background_loader.worker_task.done()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_worker_gracefully(self, background_loader):
|
||||
"""Test worker stops gracefully."""
|
||||
await background_loader.start()
|
||||
await background_loader.stop()
|
||||
assert background_loader.worker_task.done()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_loading_task(self, background_loader):
|
||||
"""Test adding a series to the loading queue."""
|
||||
await background_loader.add_series_loading_task(
|
||||
key="test-series",
|
||||
folder="Test Series",
|
||||
name="Test Series",
|
||||
year=2024
|
||||
)
|
||||
|
||||
# Verify task in active tasks
|
||||
assert "test-series" in background_loader.active_tasks
|
||||
task = background_loader.active_tasks["test-series"]
|
||||
assert task.key == "test-series"
|
||||
assert task.status == LoadingStatus.PENDING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_task_handling(self, background_loader):
|
||||
"""Test that duplicate tasks for same series are handled correctly."""
|
||||
key = "test-series"
|
||||
|
||||
await background_loader.add_series_loading_task(
|
||||
key=key,
|
||||
folder="Test Series",
|
||||
name="Test Series"
|
||||
)
|
||||
await background_loader.add_series_loading_task(
|
||||
key=key,
|
||||
folder="Test Series",
|
||||
name="Test Series"
|
||||
)
|
||||
|
||||
# Verify only one task exists
|
||||
assert len([k for k in background_loader.active_tasks if k == key]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_missing_data_all_missing(
|
||||
self,
|
||||
background_loader,
|
||||
mock_series_app
|
||||
):
|
||||
"""Test checking for missing data when all data is missing."""
|
||||
with patch('src.server.database.service.AnimeSeriesService.get_by_key') as mock_get:
|
||||
mock_series = Mock()
|
||||
mock_series.episodes_loaded = False
|
||||
mock_series.has_nfo = False
|
||||
mock_series.logo_loaded = False
|
||||
mock_series.images_loaded = False
|
||||
mock_get.return_value = mock_series
|
||||
|
||||
mock_db = AsyncMock()
|
||||
|
||||
missing_data = await background_loader.check_missing_data(
|
||||
key="test-series",
|
||||
folder="Test Series",
|
||||
anime_directory="/test/anime",
|
||||
db=mock_db
|
||||
)
|
||||
|
||||
assert missing_data["episodes"] is True
|
||||
assert missing_data["nfo"] is True
|
||||
assert missing_data["logo"] is True
|
||||
assert missing_data["images"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status(self, background_loader, mock_websocket_service):
|
||||
"""Test status broadcasting via WebSocket."""
|
||||
task = SeriesLoadingTask(
|
||||
key="test-series",
|
||||
folder="Test Series",
|
||||
name="Test Series",
|
||||
status=LoadingStatus.LOADING_EPISODES
|
||||
)
|
||||
|
||||
await background_loader._broadcast_status(task)
|
||||
|
||||
# Verify broadcast was called
|
||||
mock_websocket_service.broadcast.assert_called_once()
|
||||
|
||||
# Verify message structure
|
||||
call_args = mock_websocket_service.broadcast.call_args[0][0]
|
||||
assert call_args["type"] == "series_loading_update"
|
||||
assert call_args["key"] == "test-series"
|
||||
assert call_args["loading_status"] == "loading_episodes"
|
||||
|
||||
|
||||
class TestSeriesLoadingTask:
|
||||
"""Test SeriesLoadingTask model."""
|
||||
|
||||
def test_task_initialization(self):
|
||||
"""Test task initializes with correct defaults."""
|
||||
task = SeriesLoadingTask(
|
||||
key="test",
|
||||
folder="Test",
|
||||
name="Test"
|
||||
)
|
||||
|
||||
assert task.key == "test"
|
||||
assert task.status == LoadingStatus.PENDING
|
||||
assert not any(task.progress.values())
|
||||
|
||||
def test_task_progress_tracking(self):
|
||||
"""Test progress tracking updates correctly."""
|
||||
task = SeriesLoadingTask(
|
||||
key="test",
|
||||
folder="Test",
|
||||
name="Test"
|
||||
)
|
||||
|
||||
task.progress["episodes"] = True
|
||||
assert task.progress["episodes"] is True
|
||||
assert not task.progress["nfo"]
|
||||
assert not task.progress["logo"]
|
||||
assert not task.progress["images"]
|
||||
|
||||
def test_loading_status_enum(self):
|
||||
"""Test LoadingStatus enum values."""
|
||||
assert LoadingStatus.PENDING == "pending"
|
||||
assert LoadingStatus.LOADING_EPISODES == "loading_episodes"
|
||||
assert LoadingStatus.LOADING_NFO == "loading_nfo"
|
||||
assert LoadingStatus.COMPLETED == "completed"
|
||||
assert LoadingStatus.FAILED == "failed"
|
||||
Reference in New Issue
Block a user