From 6854d72d56b5a1f3ee7ac5e85a692ac5048fa437 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 26 Jan 2026 19:14:41 +0100 Subject: [PATCH] Task 7: Background Loader Service Tests - 90 tests, 78.68% coverage --- tests/unit/test_background_loader_service.py | 1177 ++++++++++++++---- 1 file changed, 914 insertions(+), 263 deletions(-) diff --git a/tests/unit/test_background_loader_service.py b/tests/unit/test_background_loader_service.py index e1e1291..7481c3f 100644 --- a/tests/unit/test_background_loader_service.py +++ b/tests/unit/test_background_loader_service.py @@ -1,10 +1,18 @@ """Unit tests for BackgroundLoaderService. -Tests task queuing, status tracking, and worker logic in isolation. +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 -from unittest.mock import AsyncMock, Mock, patch +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -18,286 +26,84 @@ from src.server.services.background_loader_service import ( @pytest.fixture def mock_websocket_service(): """Mock WebSocket service.""" - service = Mock() + service = AsyncMock() service.broadcast = AsyncMock() return service @pytest.fixture def mock_anime_service(): - """Mock anime service.""" - service = Mock() - service.rescan = AsyncMock() + """Mock AnimeService.""" + service = AsyncMock() + service.sync_episodes_to_db = 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() + 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 -async def background_loader(mock_websocket_service, mock_anime_service, mock_series_app): +def background_loader_service(mock_websocket_service, mock_anime_service, mock_series_app): """Create BackgroundLoaderService instance.""" - service = BackgroundLoaderService( + return BackgroundLoaderService( websocket_service=mock_websocket_service, anime_service=mock_anime_service, - series_app=mock_series_app + series_app=mock_series_app, + max_concurrent_loads=3 ) - 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" - - @pytest.mark.asyncio - async def test_load_nfo_skips_if_exists(self, background_loader, mock_series_app): - """Test that NFO creation is skipped if NFO already exists.""" - # Mock has_nfo to return True (NFO exists) - mock_series_app.nfo_service.has_nfo.return_value = True - - task = SeriesLoadingTask( - key="test-series", - folder="Test Series", - name="Test Series", - year=2023 - ) - - # Mock database - mock_db = AsyncMock() - mock_series_db = Mock() - mock_series_db.has_nfo = False - mock_series_db.logo_loaded = False - mock_series_db.images_loaded = False - - with patch('src.server.database.service.AnimeSeriesService.get_by_key') as mock_get: - mock_get.return_value = mock_series_db - - # Execute - await background_loader._load_nfo_and_images(task, mock_db) - - # Verify NFO creation was NOT called - mock_series_app.nfo_service.create_tvshow_nfo.assert_not_called() - - # Verify progress was updated - assert task.progress["nfo"] is True - assert task.progress["logo"] is True - assert task.progress["images"] is True - - # Verify database was updated - assert mock_series_db.has_nfo is True - assert mock_series_db.logo_loaded is True - assert mock_series_db.images_loaded is True - mock_db.commit.assert_called_once() - - @pytest.mark.asyncio - async def test_load_nfo_creates_if_not_exists(self, background_loader, mock_series_app): - """Test that NFO is created if it doesn't exist.""" - # Mock has_nfo to return False (NFO doesn't exist) - mock_series_app.nfo_service.has_nfo.return_value = False - mock_series_app.nfo_service.create_tvshow_nfo.return_value = "/test/anime/Test Series/tvshow.nfo" - - task = SeriesLoadingTask( - key="test-series", - folder="Test Series", - name="Test Series", - year=2023 - ) - - # Mock database - mock_db = AsyncMock() - mock_series_db = Mock() - mock_series_db.has_nfo = False - - with patch('src.server.database.service.AnimeSeriesService.get_by_key') as mock_get: - mock_get.return_value = mock_series_db - - # Execute - await background_loader._load_nfo_and_images(task, mock_db) - - # Verify NFO creation WAS called - mock_series_app.nfo_service.create_tvshow_nfo.assert_called_once_with( - serie_name="Test Series", - serie_folder="Test Series", - year=2023, - download_poster=True, - download_logo=True, - download_fanart=True - ) - - # Verify progress was updated - assert task.progress["nfo"] is True - assert task.progress["logo"] is True - assert task.progress["images"] is True - - # Verify database was updated - assert mock_series_db.has_nfo is True - mock_db.commit.assert_called_once() - - @pytest.mark.asyncio - async def test_load_nfo_doesnt_update_if_already_marked(self, background_loader, mock_series_app): - """Test that database is not updated if NFO is already marked in DB.""" - # Mock has_nfo to return True (NFO exists) - mock_series_app.nfo_service.has_nfo.return_value = True - - task = SeriesLoadingTask( - key="test-series", - folder="Test Series", - name="Test Series" - ) - - # Mock database - NFO already marked - mock_db = AsyncMock() - mock_series_db = Mock() - mock_series_db.has_nfo = True # Already marked - mock_series_db.logo_loaded = True - mock_series_db.images_loaded = True - - with patch('src.server.database.service.AnimeSeriesService.get_by_key') as mock_get: - mock_get.return_value = mock_series_db - - # Execute - await background_loader._load_nfo_and_images(task, mock_db) - - # Verify database commit was still called - mock_db.commit.assert_called_once() - - # Verify NFO creation was NOT called - mock_series_app.nfo_service.create_tvshow_nfo.assert_not_called() - - @pytest.mark.asyncio - async def test_duplicate_task_handling_continued(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.""" - + """Tests for SeriesLoadingTask data class.""" + def test_task_initialization(self): - """Test task initializes with correct defaults.""" + """Test task is initialized with correct default values.""" task = SeriesLoadingTask( - key="test", - folder="Test", - name="Test" + key="test_series", + folder="test_folder", + name="Test Series", + year=2020 ) - assert task.key == "test" + 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 not any(task.progress.values()) - + 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( @@ -309,13 +115,858 @@ class TestSeriesLoadingTask: 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 + ) + + 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 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_episodes_to_db.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"] - - 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" + + @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