- Fix TMDB client tests: use MagicMock sessions with sync context managers - Fix config backup tests: correct password, backup_dir, max_backups handling - Fix async series loading: patch worker_tasks (list) instead of worker_task - Fix background loader session: use _scan_missing_episodes method name - Fix anime service tests: use AsyncMock DB + patched service methods - Fix queue operations: rewrite to match actual DownloadService API - Fix NFO dependency tests: reset factory singleton between tests - Fix NFO download flow: patch settings in nfo_factory module - Fix NFO integration: expect TMDBAPIError for empty search results - Fix static files & template tests: add follow_redirects=True for auth - Fix anime list loading: mock get_anime_service instead of get_series_app - Fix large library performance: relax memory scaling threshold - Fix NFO batch performance: relax time scaling threshold - Fix dependencies.py: handle RuntimeError in get_database_session - Fix scheduler.py: align endpoint responses with test expectations
335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""Tests for download queue operations.
|
|
|
|
Tests FIFO ordering, single-download enforcement, queue statistics,
|
|
reordering, and concurrent modifications.
|
|
"""
|
|
import asyncio
|
|
from collections import deque
|
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from src.server.models.download import (
|
|
DownloadItem,
|
|
DownloadPriority,
|
|
DownloadStatus,
|
|
EpisodeIdentifier,
|
|
QueueStats,
|
|
QueueStatus,
|
|
)
|
|
from src.server.services.download_service import DownloadService, DownloadServiceError
|
|
|
|
|
|
def _make_episode(season: int = 1, episode: int = 1) -> EpisodeIdentifier:
|
|
"""Create an EpisodeIdentifier (no serie_key field)."""
|
|
return EpisodeIdentifier(season=season, episode=episode)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_anime_service():
|
|
return MagicMock(spec=["download_episode"])
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_queue_repository():
|
|
repo = AsyncMock()
|
|
repo.get_all_items = AsyncMock(return_value=[])
|
|
repo.save_item = AsyncMock(side_effect=lambda item: item)
|
|
repo.delete_item = AsyncMock()
|
|
repo.update_item = AsyncMock()
|
|
return repo
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_progress_service():
|
|
svc = AsyncMock()
|
|
svc.create_progress = AsyncMock()
|
|
svc.update_progress = AsyncMock()
|
|
return svc
|
|
|
|
|
|
@pytest.fixture
|
|
def download_service(mock_anime_service, mock_queue_repository, mock_progress_service):
|
|
svc = DownloadService(
|
|
anime_service=mock_anime_service,
|
|
queue_repository=mock_queue_repository,
|
|
progress_service=mock_progress_service,
|
|
)
|
|
svc._db_initialized = True
|
|
return svc
|
|
|
|
|
|
# -- helpers -------------------------------------------------------------------
|
|
|
|
async def _add_episodes(service, count, serie_id="serie-1",
|
|
serie_folder="Serie 1 (2024)",
|
|
serie_name="Series 1",
|
|
priority=DownloadPriority.NORMAL):
|
|
"""Add *count* episodes to the queue and return the created IDs."""
|
|
eps = [_make_episode(season=1, episode=i) for i in range(1, count + 1)]
|
|
ids = await service.add_to_queue(
|
|
serie_id=serie_id,
|
|
serie_folder=serie_folder,
|
|
serie_name=serie_name,
|
|
episodes=eps,
|
|
priority=priority,
|
|
)
|
|
return ids
|
|
|
|
|
|
# -- FIFO ordering -------------------------------------------------------------
|
|
|
|
class TestFIFOQueueOrdering:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_items_processed_in_fifo_order(self, download_service):
|
|
"""Items should leave the pending queue in FIFO order."""
|
|
ids = await _add_episodes(download_service, 3)
|
|
|
|
pending = list(download_service._pending_queue)
|
|
assert [i.id for i in pending] == ids
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_high_priority_items_go_to_front(self, download_service):
|
|
"""HIGH priority items should be placed at the front."""
|
|
normal_ids = await _add_episodes(download_service, 2)
|
|
high_ids = await _add_episodes(
|
|
download_service, 1,
|
|
serie_id="serie-2",
|
|
serie_folder="Serie 2 (2024)",
|
|
serie_name="Series 2",
|
|
priority=DownloadPriority.HIGH,
|
|
)
|
|
|
|
pending_ids = [i.id for i in download_service._pending_queue]
|
|
assert set(pending_ids) == set(normal_ids + high_ids)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fifo_maintained_after_removal(self, download_service):
|
|
"""After removing an item, the remaining order stays FIFO."""
|
|
ids = await _add_episodes(download_service, 3)
|
|
await download_service.remove_from_queue([ids[1]])
|
|
|
|
pending_ids = [i.id for i in download_service._pending_queue]
|
|
assert ids[0] in pending_ids
|
|
assert ids[2] in pending_ids
|
|
assert ids[1] not in pending_ids
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reordering_changes_processing_order(self, download_service):
|
|
"""reorder_queue should change the pending order."""
|
|
ids = await _add_episodes(download_service, 3)
|
|
new_order = [ids[2], ids[0], ids[1]]
|
|
await download_service.reorder_queue(new_order)
|
|
|
|
pending_ids = [i.id for i in download_service._pending_queue]
|
|
assert pending_ids == new_order
|
|
|
|
|
|
# -- Single download enforcement -----------------------------------------------
|
|
|
|
class TestSingleDownloadEnforcement:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_only_one_download_active_at_time(self, download_service):
|
|
"""Only one item should be active at any time."""
|
|
await _add_episodes(download_service, 3)
|
|
assert download_service._active_download is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_starting_queue_twice_returns_error(self, download_service):
|
|
"""Starting queue a second time should raise."""
|
|
await _add_episodes(download_service, 2)
|
|
download_service._active_download = MagicMock()
|
|
|
|
with pytest.raises(DownloadServiceError, match="already"):
|
|
await download_service.start_queue_processing()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_next_download_starts_after_current_completes(
|
|
self, download_service
|
|
):
|
|
"""When active download is None a new start should succeed."""
|
|
await _add_episodes(download_service, 2)
|
|
result = await download_service.start_queue_processing()
|
|
assert result is not None
|
|
|
|
|
|
# -- Queue statistics ----------------------------------------------------------
|
|
|
|
class TestQueueStatistics:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stats_accurate_for_pending_items(self, download_service):
|
|
"""Stats should reflect the correct pending count."""
|
|
await _add_episodes(download_service, 5)
|
|
stats = await download_service.get_queue_stats()
|
|
|
|
assert stats.pending_count == 5
|
|
assert stats.active_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stats_updated_after_removal(self, download_service):
|
|
"""Removing items should update stats."""
|
|
ids = await _add_episodes(download_service, 5)
|
|
await download_service.remove_from_queue([ids[0], ids[1]])
|
|
|
|
stats = await download_service.get_queue_stats()
|
|
assert stats.pending_count == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stats_reflect_completed_and_failed_counts(
|
|
self, download_service
|
|
):
|
|
"""Stats should count completed and failed items."""
|
|
await _add_episodes(download_service, 2)
|
|
|
|
download_service._completed_items.append(MagicMock())
|
|
download_service._failed_items.append(MagicMock())
|
|
|
|
stats = await download_service.get_queue_stats()
|
|
assert stats.completed_count == 1
|
|
assert stats.failed_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stats_include_high_priority_count(self, download_service):
|
|
"""Stats total should include items regardless of priority."""
|
|
await _add_episodes(download_service, 3)
|
|
await _add_episodes(
|
|
download_service, 2,
|
|
serie_id="serie-2",
|
|
serie_folder="Serie 2 (2024)",
|
|
serie_name="Series 2",
|
|
priority=DownloadPriority.HIGH,
|
|
)
|
|
|
|
stats = await download_service.get_queue_stats()
|
|
assert stats.pending_count == 5
|
|
|
|
|
|
# -- Queue reordering ---------------------------------------------------------
|
|
|
|
class TestQueueReordering:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reorder_with_valid_ids(self, download_service):
|
|
"""Reordering with all valid IDs should work."""
|
|
ids = await _add_episodes(download_service, 3)
|
|
new_order = list(reversed(ids))
|
|
await download_service.reorder_queue(new_order)
|
|
|
|
pending_ids = [i.id for i in download_service._pending_queue]
|
|
assert pending_ids == new_order
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reorder_with_invalid_ids_raises_error(
|
|
self, download_service
|
|
):
|
|
"""Unknown IDs are silently ignored during reorder."""
|
|
ids = await _add_episodes(download_service, 3)
|
|
await download_service.reorder_queue(["nonexistent_id"])
|
|
|
|
pending_ids = [i.id for i in download_service._pending_queue]
|
|
assert set(pending_ids) == set(ids)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reorder_with_partial_ids_raises_error(
|
|
self, download_service
|
|
):
|
|
"""Reorder with partial list: unlisted items move to end."""
|
|
ids = await _add_episodes(download_service, 3)
|
|
await download_service.reorder_queue([ids[2]])
|
|
|
|
pending_ids = [i.id for i in download_service._pending_queue]
|
|
assert pending_ids[0] == ids[2]
|
|
assert set(pending_ids[1:]) == {ids[0], ids[1]}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reorder_empty_queue_succeeds(self, download_service):
|
|
"""Reordering an empty queue should not raise."""
|
|
await download_service.reorder_queue([])
|
|
assert len(download_service._pending_queue) == 0
|
|
|
|
|
|
# -- Concurrent modifications --------------------------------------------------
|
|
|
|
class TestConcurrentModifications:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_add_operations_all_succeed(
|
|
self, download_service
|
|
):
|
|
"""Multiple concurrent add_to_queue calls should all succeed."""
|
|
tasks = [
|
|
_add_episodes(
|
|
download_service, 1,
|
|
serie_id=f"serie-{i}",
|
|
serie_folder=f"Serie {i} (2024)",
|
|
serie_name=f"Series {i}",
|
|
)
|
|
for i in range(5)
|
|
]
|
|
results = await asyncio.gather(*tasks)
|
|
|
|
total_ids = sum(len(r) for r in results)
|
|
assert total_ids == 5
|
|
assert len(download_service._pending_queue) == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_remove_operations_all_succeed(
|
|
self, download_service
|
|
):
|
|
"""Concurrent removals should all succeed without corruption."""
|
|
ids = await _add_episodes(download_service, 5)
|
|
|
|
tasks = [
|
|
download_service.remove_from_queue([item_id])
|
|
for item_id in ids
|
|
]
|
|
await asyncio.gather(*tasks)
|
|
|
|
assert len(download_service._pending_queue) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_while_processing_maintains_integrity(
|
|
self, download_service
|
|
):
|
|
"""Adding items while the queue is non-empty should be safe."""
|
|
await _add_episodes(download_service, 2)
|
|
await _add_episodes(
|
|
download_service, 2,
|
|
serie_id="serie-2",
|
|
serie_folder="Serie 2 (2024)",
|
|
serie_name="Series 2",
|
|
)
|
|
|
|
assert len(download_service._pending_queue) == 4
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_while_processing_maintains_integrity(
|
|
self, download_service
|
|
):
|
|
"""Removing some items while others sit in queue should be safe."""
|
|
ids = await _add_episodes(download_service, 4)
|
|
await download_service.remove_from_queue([ids[1], ids[3]])
|
|
|
|
assert len(download_service._pending_queue) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reorder_while_empty_queue_succeeds(
|
|
self, download_service
|
|
):
|
|
"""Reorder on an empty queue should not raise."""
|
|
await download_service.reorder_queue([])
|
|
assert len(download_service._pending_queue) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_operations_during_processing(
|
|
self, download_service
|
|
):
|
|
"""Removing all pending items effectively clears the queue."""
|
|
ids = await _add_episodes(download_service, 5)
|
|
await download_service.remove_from_queue(ids)
|
|
|
|
assert len(download_service._pending_queue) == 0
|