- Add missing TMDB async mock methods (_ensure_session, close) to all TMDB mocks in test_nfo_workflow.py - Refactor test_anime_add_nfo_isolation.py to mock get_nfo_factory() instead of asserting on series_app.nfo_service directly - Patch get_nfo_factory in test_background_loader_service.py to align with factory-based NFOService creation Fixes test failures caused by NFOService refactoring that introduced explicit TMDB session lifecycle and NFO factory pattern.
980 lines
35 KiB
Python
980 lines
35 KiB
Python
"""Unit tests for BackgroundLoaderService.
|
|
|
|
Tests cover:
|
|
- Task queuing and worker orchestration
|
|
- Loading status tracking and progress reporting
|
|
- Concurrent task processing
|
|
- WebSocket broadcasting
|
|
- Error handling and recovery
|
|
- Resource cleanup
|
|
- Missing data detection
|
|
"""
|
|
import asyncio
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.server.services.background_loader_service import (
|
|
BackgroundLoaderService,
|
|
LoadingStatus,
|
|
SeriesLoadingTask,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_websocket_service():
|
|
"""Mock WebSocket service."""
|
|
service = AsyncMock()
|
|
service.broadcast = AsyncMock()
|
|
return service
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_anime_service():
|
|
"""Mock AnimeService."""
|
|
service = AsyncMock()
|
|
service.sync_episodes_to_db = AsyncMock()
|
|
service.sync_single_series_after_scan = AsyncMock()
|
|
return service
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_series_app():
|
|
"""Mock SeriesApp."""
|
|
app = MagicMock()
|
|
app.directory_to_search = "/anime"
|
|
app.nfo_service = AsyncMock()
|
|
app.nfo_service.has_nfo = MagicMock(return_value=False)
|
|
app.nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/TestSeries/tvshow.nfo")
|
|
app.serie_scanner = MagicMock()
|
|
app.serie_scanner.scan_single_series = MagicMock(return_value={
|
|
"Season 1": ["episode1.mp4", "episode2.mp4"]
|
|
})
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def background_loader_service(mock_websocket_service, mock_anime_service, mock_series_app):
|
|
"""Create BackgroundLoaderService instance."""
|
|
return BackgroundLoaderService(
|
|
websocket_service=mock_websocket_service,
|
|
anime_service=mock_anime_service,
|
|
series_app=mock_series_app,
|
|
max_concurrent_loads=3
|
|
)
|
|
|
|
|
|
class TestSeriesLoadingTask:
|
|
"""Tests for SeriesLoadingTask data class."""
|
|
|
|
def test_task_initialization(self):
|
|
"""Test task is initialized with correct default values."""
|
|
task = SeriesLoadingTask(
|
|
key="test_series",
|
|
folder="test_folder",
|
|
name="Test Series",
|
|
year=2020
|
|
)
|
|
|
|
assert task.key == "test_series"
|
|
assert task.folder == "test_folder"
|
|
assert task.name == "Test Series"
|
|
assert task.year == 2020
|
|
assert task.status == LoadingStatus.PENDING
|
|
assert task.progress == {
|
|
"episodes": False,
|
|
"nfo": False,
|
|
"logo": False,
|
|
"images": False
|
|
}
|
|
assert task.started_at is None
|
|
assert task.completed_at is None
|
|
assert task.error is None
|
|
|
|
def test_task_with_minimal_fields(self):
|
|
"""Test task creation with minimal required fields."""
|
|
task = SeriesLoadingTask(
|
|
key="minimal",
|
|
folder="folder",
|
|
name="Name"
|
|
)
|
|
|
|
assert task.key == "minimal"
|
|
assert task.year is None
|
|
assert task.status == LoadingStatus.PENDING
|
|
|
|
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"]
|
|
|
|
|
|
class TestLoadingStatus:
|
|
"""Tests for LoadingStatus enumeration."""
|
|
|
|
def test_all_status_values(self):
|
|
"""Test all loading status values."""
|
|
assert LoadingStatus.PENDING.value == "pending"
|
|
assert LoadingStatus.LOADING_EPISODES.value == "loading_episodes"
|
|
assert LoadingStatus.LOADING_NFO.value == "loading_nfo"
|
|
assert LoadingStatus.COMPLETED.value == "completed"
|
|
assert LoadingStatus.FAILED.value == "failed"
|
|
|
|
|
|
class TestBackgroundLoaderServiceInitialization:
|
|
"""Tests for service initialization."""
|
|
|
|
def test_service_initialization(self, mock_websocket_service, mock_anime_service, mock_series_app):
|
|
"""Test service initializes with correct configuration."""
|
|
service = BackgroundLoaderService(
|
|
websocket_service=mock_websocket_service,
|
|
anime_service=mock_anime_service,
|
|
series_app=mock_series_app,
|
|
max_concurrent_loads=5
|
|
)
|
|
|
|
assert service.websocket_service is mock_websocket_service
|
|
assert service.anime_service is mock_anime_service
|
|
assert service.series_app is mock_series_app
|
|
assert service.max_concurrent_loads == 5
|
|
assert isinstance(service.task_queue, asyncio.Queue)
|
|
assert service.active_tasks == {}
|
|
assert service.worker_tasks == []
|
|
assert service._shutdown is False
|
|
|
|
def test_default_max_concurrent_loads(self, mock_websocket_service, mock_anime_service, mock_series_app):
|
|
"""Test default max_concurrent_loads is 5."""
|
|
service = BackgroundLoaderService(
|
|
websocket_service=mock_websocket_service,
|
|
anime_service=mock_anime_service,
|
|
series_app=mock_series_app
|
|
)
|
|
|
|
assert service.max_concurrent_loads == 5
|
|
|
|
|
|
class TestStartStopService:
|
|
"""Tests for service startup and shutdown."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_service_start(self, background_loader_service):
|
|
"""Test service starts worker tasks."""
|
|
await background_loader_service.start()
|
|
|
|
assert len(background_loader_service.worker_tasks) == 3
|
|
assert all(not task.done() for task in background_loader_service.worker_tasks)
|
|
|
|
await background_loader_service.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_service_stop(self, background_loader_service):
|
|
"""Test service stops all worker tasks gracefully."""
|
|
await background_loader_service.start()
|
|
assert len(background_loader_service.worker_tasks) == 3
|
|
|
|
await background_loader_service.stop()
|
|
|
|
assert background_loader_service.worker_tasks == []
|
|
assert background_loader_service._shutdown is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_service_stop_when_not_started(self, background_loader_service):
|
|
"""Test stopping a service that was never started."""
|
|
await background_loader_service.stop()
|
|
assert background_loader_service.worker_tasks == []
|
|
|
|
|
|
class TestAddSeriesLoadingTask:
|
|
"""Tests for adding tasks to the queue."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_series_loading_task(self, background_loader_service):
|
|
"""Test adding a series loading task to the queue."""
|
|
await background_loader_service.add_series_loading_task(
|
|
key="test_series",
|
|
folder="test_folder",
|
|
name="Test Series",
|
|
year=2020
|
|
)
|
|
|
|
assert "test_series" in background_loader_service.active_tasks
|
|
task = background_loader_service.active_tasks["test_series"]
|
|
|
|
assert task.key == "test_series"
|
|
assert task.folder == "test_folder"
|
|
assert task.name == "Test Series"
|
|
assert task.year == 2020
|
|
assert task.status == LoadingStatus.PENDING
|
|
assert task.started_at is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_duplicate_task_skips_addition(self, background_loader_service):
|
|
"""Test adding duplicate task skips the new one."""
|
|
await background_loader_service.add_series_loading_task(
|
|
key="test_series",
|
|
folder="test_folder1",
|
|
name="Test Series 1"
|
|
)
|
|
|
|
initial_task = background_loader_service.active_tasks["test_series"]
|
|
|
|
await background_loader_service.add_series_loading_task(
|
|
key="test_series",
|
|
folder="test_folder2",
|
|
name="Test Series 2"
|
|
)
|
|
|
|
assert background_loader_service.active_tasks["test_series"] is initial_task
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_task_broadcasts_status(self, background_loader_service, mock_websocket_service):
|
|
"""Test adding task broadcasts initial status."""
|
|
await background_loader_service.add_series_loading_task(
|
|
key="test_series",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
mock_websocket_service.broadcast.assert_called_once()
|
|
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["status"] == LoadingStatus.PENDING.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_multiple_tasks(self, background_loader_service):
|
|
"""Test adding multiple different tasks."""
|
|
await background_loader_service.add_series_loading_task(
|
|
key="series1",
|
|
folder="folder1",
|
|
name="Series 1"
|
|
)
|
|
await background_loader_service.add_series_loading_task(
|
|
key="series2",
|
|
folder="folder2",
|
|
name="Series 2"
|
|
)
|
|
|
|
assert len(background_loader_service.active_tasks) == 2
|
|
assert "series1" in background_loader_service.active_tasks
|
|
assert "series2" in background_loader_service.active_tasks
|
|
|
|
|
|
class TestCheckMissingData:
|
|
"""Tests for checking missing data for a series."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_missing_data_no_series_in_db(self, background_loader_service):
|
|
"""Test checking missing data when series doesn't exist in DB."""
|
|
mock_db = AsyncMock()
|
|
|
|
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class:
|
|
mock_service_class.get_by_key = AsyncMock(return_value=None)
|
|
|
|
missing = await background_loader_service.check_missing_data(
|
|
key="new_series",
|
|
folder="new_folder",
|
|
anime_directory="/anime",
|
|
db=mock_db
|
|
)
|
|
|
|
assert all(missing.values())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_missing_data_partial_loaded(self, background_loader_service):
|
|
"""Test checking missing data for partially loaded series."""
|
|
mock_db = AsyncMock()
|
|
mock_series = MagicMock()
|
|
mock_series.episodes_loaded = True
|
|
mock_series.has_nfo = True
|
|
mock_series.logo_loaded = False
|
|
mock_series.images_loaded = False
|
|
|
|
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class:
|
|
with patch("src.server.utils.media.check_media_files") as mock_check:
|
|
mock_service_class.get_by_key = AsyncMock(return_value=mock_series)
|
|
mock_check.return_value = {
|
|
"poster": False,
|
|
"logo": False,
|
|
"fanart": False,
|
|
"nfo": True
|
|
}
|
|
|
|
missing = await background_loader_service.check_missing_data(
|
|
key="partial_series",
|
|
folder="partial_folder",
|
|
anime_directory="/anime",
|
|
db=mock_db
|
|
)
|
|
|
|
assert missing["episodes"] is False
|
|
assert missing["nfo"] is False
|
|
assert missing["logo"] is True
|
|
assert missing["images"] is True
|
|
|
|
|
|
class TestBroadcastStatus:
|
|
"""Tests for WebSocket status broadcasting."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_status_pending(self, background_loader_service, mock_websocket_service):
|
|
"""Test broadcasting pending status."""
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="folder",
|
|
name="Test",
|
|
status=LoadingStatus.PENDING
|
|
)
|
|
|
|
await background_loader_service._broadcast_status(task)
|
|
|
|
call_args = mock_websocket_service.broadcast.call_args[0][0]
|
|
assert call_args["status"] == LoadingStatus.PENDING.value
|
|
assert "Queued" in call_args["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_status_completed(self, background_loader_service, mock_websocket_service):
|
|
"""Test broadcasting completed status."""
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="folder",
|
|
name="Test",
|
|
status=LoadingStatus.COMPLETED
|
|
)
|
|
|
|
await background_loader_service._broadcast_status(task)
|
|
|
|
call_args = mock_websocket_service.broadcast.call_args[0][0]
|
|
assert call_args["status"] == LoadingStatus.COMPLETED.value
|
|
assert "successfully" in call_args["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_status_failed(self, background_loader_service, mock_websocket_service):
|
|
"""Test broadcasting failed status with error message."""
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="folder",
|
|
name="Test",
|
|
status=LoadingStatus.FAILED,
|
|
error="Test error"
|
|
)
|
|
|
|
await background_loader_service._broadcast_status(task)
|
|
|
|
call_args = mock_websocket_service.broadcast.call_args[0][0]
|
|
assert call_args["status"] == LoadingStatus.FAILED.value
|
|
assert call_args["error"] == "Test error"
|
|
assert "failed" in call_args["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_status_custom_message(self, background_loader_service, mock_websocket_service):
|
|
"""Test broadcasting with custom message."""
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="folder",
|
|
name="Test"
|
|
)
|
|
|
|
await background_loader_service._broadcast_status(task, "Custom message")
|
|
|
|
call_args = mock_websocket_service.broadcast.call_args[0][0]
|
|
assert call_args["message"] == "Custom message"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_status_includes_metadata(self, background_loader_service, mock_websocket_service):
|
|
"""Test broadcast includes all required metadata."""
|
|
task = SeriesLoadingTask(
|
|
key="test_key",
|
|
folder="test_folder",
|
|
name="Test Name"
|
|
)
|
|
task.progress = {"episodes": True, "nfo": False, "logo": False, "images": False}
|
|
|
|
await background_loader_service._broadcast_status(task)
|
|
|
|
call_args = mock_websocket_service.broadcast.call_args[0][0]
|
|
assert call_args["type"] == "series_loading_update"
|
|
assert call_args["key"] == "test_key"
|
|
assert call_args["series_key"] == "test_key"
|
|
assert call_args["folder"] == "test_folder"
|
|
assert call_args["progress"] == {"episodes": True, "nfo": False, "logo": False, "images": False}
|
|
assert "timestamp" in call_args
|
|
|
|
|
|
class TestFindSeriesDirectory:
|
|
"""Tests for finding series directory."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_series_directory_exists(self, background_loader_service, tmp_path):
|
|
"""Test finding series directory when it exists."""
|
|
series_dir = tmp_path / "TestSeries"
|
|
series_dir.mkdir()
|
|
|
|
background_loader_service.series_app.directory_to_search = str(tmp_path)
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="TestSeries",
|
|
name="Test"
|
|
)
|
|
|
|
result = await background_loader_service._find_series_directory(task)
|
|
|
|
assert result is not None
|
|
assert result.name == "TestSeries"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_series_directory_not_found(self, background_loader_service, tmp_path):
|
|
"""Test finding series directory when it doesn't exist."""
|
|
background_loader_service.series_app.directory_to_search = str(tmp_path)
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="NonExistentSeries",
|
|
name="Test"
|
|
)
|
|
|
|
result = await background_loader_service._find_series_directory(task)
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestScanSeriesEpisodes:
|
|
"""Tests for scanning episodes in a series directory."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_series_episodes(self, background_loader_service, tmp_path):
|
|
"""Test scanning episodes from series directory."""
|
|
season1 = tmp_path / "Season 1"
|
|
season1.mkdir()
|
|
(season1 / "episode1.mp4").touch()
|
|
(season1 / "episode2.mp4").touch()
|
|
|
|
season2 = tmp_path / "Season 2"
|
|
season2.mkdir()
|
|
(season2 / "episode1.mp4").touch()
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="TestSeries",
|
|
name="Test"
|
|
)
|
|
|
|
result = await background_loader_service._scan_series_episodes(tmp_path, task)
|
|
|
|
assert "Season 1" in result
|
|
assert "Season 2" in result
|
|
assert len(result["Season 1"]) == 2
|
|
assert len(result["Season 2"]) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_series_episodes_ignores_non_mp4(self, background_loader_service, tmp_path):
|
|
"""Test that only .mp4 files are scanned."""
|
|
season = tmp_path / "Season 1"
|
|
season.mkdir()
|
|
(season / "episode1.mp4").touch()
|
|
(season / "episode2.txt").touch()
|
|
(season / "episode3.avi").touch()
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="TestSeries",
|
|
name="Test"
|
|
)
|
|
|
|
result = await background_loader_service._scan_series_episodes(tmp_path, task)
|
|
|
|
assert len(result["Season 1"]) == 1
|
|
assert result["Season 1"][0] == "episode1.mp4"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_series_episodes_empty_directory(self, background_loader_service, tmp_path):
|
|
"""Test scanning empty directory."""
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="TestSeries",
|
|
name="Test"
|
|
)
|
|
|
|
result = await background_loader_service._scan_series_episodes(tmp_path, task)
|
|
|
|
assert result == {}
|
|
|
|
|
|
class TestLoadNfoAndImages:
|
|
"""Tests for loading NFO files and images."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_nfo_creates_new_nfo(self, background_loader_service, mock_websocket_service):
|
|
"""Test creating new NFO file when it doesn't exist."""
|
|
mock_db = AsyncMock()
|
|
mock_series = MagicMock()
|
|
mock_series.has_nfo = False
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series",
|
|
year=2020
|
|
)
|
|
|
|
mock_nfo_service = AsyncMock()
|
|
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/test_folder/tvshow.nfo")
|
|
mock_factory = MagicMock()
|
|
mock_factory.create = MagicMock(return_value=mock_nfo_service)
|
|
|
|
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class, \
|
|
patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
|
|
mock_service_class.get_by_key = AsyncMock(return_value=mock_series)
|
|
|
|
result = await background_loader_service._load_nfo_and_images(task, mock_db)
|
|
|
|
assert result is True
|
|
assert task.progress["nfo"] is True
|
|
assert task.progress["logo"] is True
|
|
assert task.progress["images"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_nfo_uses_existing(self, background_loader_service):
|
|
"""Test using existing NFO file when it already exists."""
|
|
background_loader_service.series_app.nfo_service.has_nfo = MagicMock(return_value=True)
|
|
|
|
mock_db = AsyncMock()
|
|
mock_series = MagicMock()
|
|
mock_series.has_nfo = True
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class:
|
|
mock_service_class.get_by_key = AsyncMock(return_value=mock_series)
|
|
|
|
result = await background_loader_service._load_nfo_and_images(task, mock_db)
|
|
|
|
assert result is False
|
|
assert task.progress["nfo"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_nfo_without_nfo_service(self, background_loader_service):
|
|
"""Test graceful handling when NFO service not available."""
|
|
background_loader_service.series_app.nfo_service = None
|
|
|
|
mock_db = AsyncMock()
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
result = await background_loader_service._load_nfo_and_images(task, mock_db)
|
|
|
|
assert result is False
|
|
assert task.progress["nfo"] is False
|
|
assert task.progress["logo"] is False
|
|
|
|
|
|
class TestScanMissingEpisodes:
|
|
"""Tests for scanning missing episodes."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_missing_episodes(self, background_loader_service):
|
|
"""Test scanning for missing episodes."""
|
|
mock_db = AsyncMock()
|
|
mock_series = MagicMock()
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class:
|
|
mock_service_class.get_by_key = AsyncMock(return_value=mock_series)
|
|
|
|
await background_loader_service._scan_missing_episodes(task, mock_db)
|
|
|
|
assert task.progress["episodes"] is True
|
|
background_loader_service.anime_service.sync_single_series_after_scan.assert_called_once_with("test")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_missing_episodes_no_scanner(self, background_loader_service):
|
|
"""Test handling when scanner not available."""
|
|
background_loader_service.series_app.serie_scanner = None
|
|
|
|
mock_db = AsyncMock()
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
await background_loader_service._scan_missing_episodes(task, mock_db)
|
|
|
|
|
|
class TestConcurrentProcessing:
|
|
"""Tests for concurrent task processing."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_concurrent_tasks(self, background_loader_service):
|
|
"""Test processing multiple tasks concurrently."""
|
|
await background_loader_service.add_series_loading_task("series1", "folder1", "Series 1")
|
|
await background_loader_service.add_series_loading_task("series2", "folder2", "Series 2")
|
|
await background_loader_service.add_series_loading_task("series3", "folder3", "Series 3")
|
|
|
|
assert len(background_loader_service.active_tasks) == 3
|
|
assert background_loader_service.task_queue.qsize() == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_concurrent_load_limit(self, mock_websocket_service, mock_anime_service, mock_series_app):
|
|
"""Test respecting maximum concurrent loads setting."""
|
|
service = BackgroundLoaderService(
|
|
websocket_service=mock_websocket_service,
|
|
anime_service=mock_anime_service,
|
|
series_app=mock_series_app,
|
|
max_concurrent_loads=2
|
|
)
|
|
|
|
await service.start()
|
|
|
|
assert len(service.worker_tasks) == 2
|
|
|
|
await service.stop()
|
|
|
|
|
|
class TestLoadSeriesData:
|
|
"""Tests for complete series data loading process."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_series_data_successful(self, background_loader_service):
|
|
"""Test successful series data loading."""
|
|
mock_db = AsyncMock()
|
|
mock_series = MagicMock()
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
with patch("src.server.database.connection.get_db_session"):
|
|
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class:
|
|
mock_service_class.get_by_key = AsyncMock(return_value=mock_series)
|
|
|
|
with patch.object(background_loader_service, "check_missing_data", return_value={
|
|
"episodes": True,
|
|
"nfo": True,
|
|
"logo": True,
|
|
"images": True
|
|
}):
|
|
with patch.object(background_loader_service, "_load_nfo_and_images", return_value=True):
|
|
with patch.object(background_loader_service, "_scan_missing_episodes"):
|
|
with patch.object(background_loader_service, "_broadcast_status"):
|
|
await background_loader_service._load_series_data(task)
|
|
|
|
assert task.status == LoadingStatus.COMPLETED
|
|
assert task.completed_at is not None
|
|
assert "test" not in background_loader_service.active_tasks
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_series_data_handles_error(self, background_loader_service):
|
|
"""Test error handling during series data loading."""
|
|
mock_db = AsyncMock()
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
with patch("src.server.database.connection.get_db_session"):
|
|
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class:
|
|
mock_service_class.get_by_key = AsyncMock(return_value=None)
|
|
|
|
with patch.object(background_loader_service, "check_missing_data", side_effect=Exception("Test error")):
|
|
with patch.object(background_loader_service, "_broadcast_status"):
|
|
await background_loader_service._load_series_data(task)
|
|
|
|
assert task.status == LoadingStatus.FAILED
|
|
assert task.error == "Test error"
|
|
assert task.completed_at is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_series_data_with_partial_missing(self, background_loader_service):
|
|
"""Test loading series when only some data is missing."""
|
|
mock_db = AsyncMock()
|
|
mock_series = MagicMock()
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
with patch("src.server.database.connection.get_db_session"):
|
|
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class:
|
|
mock_service_class.get_by_key = AsyncMock(return_value=mock_series)
|
|
|
|
with patch.object(background_loader_service, "check_missing_data", return_value={
|
|
"episodes": False,
|
|
"nfo": False,
|
|
"logo": False,
|
|
"images": False
|
|
}):
|
|
with patch.object(background_loader_service, "_load_nfo_and_images", return_value=False):
|
|
with patch.object(background_loader_service, "_scan_missing_episodes"):
|
|
with patch.object(background_loader_service, "_broadcast_status"):
|
|
await background_loader_service._load_series_data(task)
|
|
|
|
assert task.status == LoadingStatus.COMPLETED
|
|
# When nothing is missing, nfo/logo/images get marked true, but episodes marked false by scan
|
|
assert task.progress["nfo"] is True or task.progress["nfo"] is False
|
|
|
|
|
|
class TestWorkerExecution:
|
|
"""Tests for background worker execution."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_worker_processes_task_from_queue(self, background_loader_service):
|
|
"""Test worker processes task from queue."""
|
|
# Create a task
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
# Add it to queue
|
|
await background_loader_service.task_queue.put(task)
|
|
|
|
# Start service
|
|
await background_loader_service.start()
|
|
|
|
# Let worker process briefly
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Stop service
|
|
await background_loader_service.stop()
|
|
|
|
# Queue should be empty
|
|
assert background_loader_service.task_queue.empty()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_worker_timeout_loop(self, background_loader_service):
|
|
"""Test worker timeout loop works correctly."""
|
|
await background_loader_service.start()
|
|
|
|
# Let worker loop a few times with no tasks
|
|
await asyncio.sleep(0.2)
|
|
|
|
# Workers should still be running
|
|
assert all(not task.done() for task in background_loader_service.worker_tasks)
|
|
|
|
await background_loader_service.stop()
|
|
|
|
|
|
class TestBroadcastStatusMessages:
|
|
"""Tests for various status message broadcasts."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_loading_episodes_status(self, background_loader_service, mock_websocket_service):
|
|
"""Test broadcasting loading episodes status."""
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="folder",
|
|
name="Test",
|
|
status=LoadingStatus.LOADING_EPISODES
|
|
)
|
|
|
|
await background_loader_service._broadcast_status(task)
|
|
|
|
call_args = mock_websocket_service.broadcast.call_args[0][0]
|
|
assert "loading" in call_args["message"].lower() or "episode" in call_args["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_loading_nfo_status(self, background_loader_service, mock_websocket_service):
|
|
"""Test broadcasting loading NFO status."""
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="folder",
|
|
name="Test",
|
|
status=LoadingStatus.LOADING_NFO
|
|
)
|
|
|
|
await background_loader_service._broadcast_status(task)
|
|
|
|
call_args = mock_websocket_service.broadcast.call_args[0][0]
|
|
assert "nfo" in call_args["message"].lower() or "loading" in call_args["message"].lower()
|
|
|
|
|
|
class TestErrorHandling:
|
|
"""Tests for error handling scenarios."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_nfo_error_handling(self, background_loader_service):
|
|
"""Test error handling during NFO creation."""
|
|
background_loader_service.series_app.nfo_service.has_nfo = MagicMock(return_value=False)
|
|
background_loader_service.series_app.nfo_service.create_tvshow_nfo = AsyncMock(
|
|
side_effect=Exception("API error")
|
|
)
|
|
|
|
mock_db = AsyncMock()
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
result = await background_loader_service._load_nfo_and_images(task, mock_db)
|
|
|
|
assert result is False
|
|
assert task.progress["nfo"] is False
|
|
assert task.progress["logo"] is False
|
|
assert task.progress["images"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_missing_episodes_error_handling(self, background_loader_service):
|
|
"""Test error handling during episode scan."""
|
|
background_loader_service.series_app.serie_scanner.scan_single_series = MagicMock(
|
|
side_effect=Exception("Scan error")
|
|
)
|
|
|
|
mock_db = AsyncMock()
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="test_folder",
|
|
name="Test Series"
|
|
)
|
|
|
|
await background_loader_service._scan_missing_episodes(task, mock_db)
|
|
|
|
assert task.progress["episodes"] is False
|
|
|
|
|
|
class TestDirectoryScanning:
|
|
"""Tests for directory operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_series_directory_error_handling(self, background_loader_service):
|
|
"""Test error handling when directory lookup fails."""
|
|
background_loader_service.series_app.directory_to_search = "/invalid/path"
|
|
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="TestSeries",
|
|
name="Test"
|
|
)
|
|
|
|
result = await background_loader_service._find_series_directory(task)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_series_episodes_error_handling(self, background_loader_service):
|
|
"""Test error handling during episode scanning."""
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="TestSeries",
|
|
name="Test"
|
|
)
|
|
|
|
# Pass a non-existent path
|
|
result = await background_loader_service._scan_series_episodes(Path("/nonexistent"), task)
|
|
|
|
assert result == {}
|
|
|
|
|
|
class TestCheckMissingDataEdgeCases:
|
|
"""Tests for edge cases in missing data checking."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_missing_data_all_loaded(self, background_loader_service):
|
|
"""Test checking missing data when all data is already loaded."""
|
|
mock_db = AsyncMock()
|
|
mock_series = MagicMock()
|
|
mock_series.episodes_loaded = True
|
|
mock_series.has_nfo = True
|
|
mock_series.logo_loaded = True
|
|
mock_series.images_loaded = True
|
|
|
|
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class:
|
|
with patch("src.server.utils.media.check_media_files") as mock_check:
|
|
mock_service_class.get_by_key = AsyncMock(return_value=mock_series)
|
|
mock_check.return_value = {
|
|
"poster": True,
|
|
"logo": True,
|
|
"fanart": True,
|
|
"nfo": True
|
|
}
|
|
|
|
missing = await background_loader_service.check_missing_data(
|
|
key="complete_series",
|
|
folder="complete_folder",
|
|
anime_directory="/anime",
|
|
db=mock_db
|
|
)
|
|
|
|
assert not any(missing.values())
|
|
|
|
|
|
class TestTaskProgressTracking:
|
|
"""Tests for task progress tracking."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_progress_updates(self, background_loader_service):
|
|
"""Test that task progress is properly updated."""
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="folder",
|
|
name="Test"
|
|
)
|
|
|
|
# Initially all progress is false
|
|
assert not any(task.progress.values())
|
|
|
|
# Update progress
|
|
task.progress["episodes"] = True
|
|
assert task.progress["episodes"] is True
|
|
|
|
task.progress["nfo"] = True
|
|
assert task.progress["nfo"] is True
|
|
|
|
# Other progress still false
|
|
assert not task.progress["logo"]
|
|
assert not task.progress["images"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_status_lifecycle(self, background_loader_service):
|
|
"""Test task goes through complete status lifecycle."""
|
|
task = SeriesLoadingTask(
|
|
key="test",
|
|
folder="folder",
|
|
name="Test"
|
|
)
|
|
|
|
# Start with PENDING
|
|
assert task.status == LoadingStatus.PENDING
|
|
|
|
# Simulate transitions
|
|
task.status = LoadingStatus.LOADING_EPISODES
|
|
assert task.status == LoadingStatus.LOADING_EPISODES
|
|
|
|
task.status = LoadingStatus.LOADING_NFO
|
|
assert task.status == LoadingStatus.LOADING_NFO
|
|
|
|
task.status = LoadingStatus.COMPLETED
|
|
assert task.status == LoadingStatus.COMPLETED
|
|
assert task.completed_at is None # Not set automatically
|