Files
Aniworld/tests/unit/test_download_service.py
Lukas c579235af0 feat(download): persist retry state and dead-letter
Retry count and queue status were in-memory only and lost on
restart, so failed downloads could not be safely resumed and
permanently-failed episodes silently blocked re-queueing via the
episode-id unique index.

- Add status + retry_count columns to DownloadQueueItem
- Replace unique(episode_id) with unique(episode_id, status) so
  permanently_failed rows do not block new pending entries
- Add PERMANENTLY_FAILED to DownloadStatus enum
- Persist retry_count on each failure; mark permanently_failed once
  max_retries reached
- QueueRepository reads status/retry_count from DB instead of
  defaulting to PENDING/0
- Stop double-incrementing retry_count in retry_failed_items;
  increment only happens in _process_download on failure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 14:24:31 +02:00

1044 lines
36 KiB
Python

"""Unit tests for the download queue service.
Tests cover queue management, manual download control, database persistence,
and error scenarios for the simplified download service.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.server.models.download import (
DownloadItem,
DownloadPriority,
DownloadStatus,
EpisodeIdentifier,
)
from src.server.services.anime_service import AnimeService
from src.server.services.download_service import DownloadService, DownloadServiceError
class MockQueueRepository:
"""Mock implementation of QueueRepository for testing.
This provides an in-memory storage that mimics the simplified database
repository behavior without requiring actual database connections.
Note: The repository is simplified - status, priority, progress are
now managed in-memory by DownloadService, not stored in database.
"""
def __init__(self):
"""Initialize mock repository with in-memory storage."""
self._items: Dict[str, DownloadItem] = {}
async def save_item(self, item: DownloadItem) -> DownloadItem:
"""Save item to in-memory storage."""
self._items[item.id] = item
return item
async def get_item(self, item_id: str) -> Optional[DownloadItem]:
"""Get item by ID from in-memory storage."""
return self._items.get(item_id)
async def get_all_items(self) -> List[DownloadItem]:
"""Get all items in storage."""
return list(self._items.values())
async def set_error(
self,
item_id: str,
error: str,
) -> bool:
"""Set error message on an item."""
if item_id not in self._items:
return False
self._items[item_id].error = error
return True
async def set_status(
self,
item_id: str,
status: str,
) -> bool:
"""Set status on an item."""
if item_id not in self._items:
return False
self._items[item_id].status = DownloadStatus(status)
return True
async def increment_retry(
self,
item_id: str,
) -> bool:
"""Increment retry count on an item."""
if item_id not in self._items:
return False
self._items[item_id].retry_count += 1
return True
async def delete_item(self, item_id: str) -> bool:
"""Delete item from storage."""
if item_id in self._items:
del self._items[item_id]
return True
return False
async def clear_all(self) -> int:
"""Clear all items."""
count = len(self._items)
self._items.clear()
return count
@pytest.fixture
def mock_anime_service():
"""Create a mock AnimeService."""
service = MagicMock(spec=AnimeService)
service.download = AsyncMock(return_value=True)
service._directory = "/mock/anime/directory"
return service
@pytest.fixture
def mock_queue_repository():
"""Create a mock QueueRepository for testing."""
return MockQueueRepository()
@pytest.fixture
def download_service(mock_anime_service, mock_queue_repository):
"""Create a DownloadService instance for testing."""
return DownloadService(
anime_service=mock_anime_service,
queue_repository=mock_queue_repository,
max_retries=3,
)
class TestDownloadServiceInitialization:
"""Test download service initialization."""
def test_initialization_creates_queues(
self, mock_anime_service, mock_queue_repository
):
"""Test that initialization creates empty queues."""
service = DownloadService(
anime_service=mock_anime_service,
queue_repository=mock_queue_repository,
)
assert len(service._pending_queue) == 0
assert service._active_download is None
assert len(service._completed_items) == 0
assert len(service._failed_items) == 0
assert service._is_stopped is True
@pytest.mark.asyncio
async def test_initialization_loads_persisted_queue(
self, mock_anime_service, mock_queue_repository
):
"""Test that initialization loads persisted queue from database."""
# Pre-populate the mock repository with a pending item
test_item = DownloadItem(
id="test-id-1",
serie_id="series-1",
serie_folder="test-series",
serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.PENDING,
priority=DownloadPriority.NORMAL,
added_at=datetime.now(timezone.utc),
)
await mock_queue_repository.save_item(test_item)
# Create service and initialize from database
service = DownloadService(
anime_service=mock_anime_service,
queue_repository=mock_queue_repository,
)
await service.initialize()
assert len(service._pending_queue) == 1
assert service._pending_queue[0].id == "test-id-1"
class TestQueueManagement:
"""Test queue management operations."""
@pytest.mark.asyncio
async def test_add_to_queue_single_episode(self, download_service):
"""Test adding a single episode to queue."""
episodes = [EpisodeIdentifier(season=1, episode=1)]
item_ids = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=episodes,
priority=DownloadPriority.NORMAL,
)
assert len(item_ids) == 1
assert len(download_service._pending_queue) == 1
assert download_service._pending_queue[0].serie_id == "series-1"
assert (
download_service._pending_queue[0].status
== DownloadStatus.PENDING
)
@pytest.mark.asyncio
async def test_add_to_queue_multiple_episodes(self, download_service):
"""Test adding multiple episodes to queue."""
episodes = [
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
EpisodeIdentifier(season=1, episode=3),
]
item_ids = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=episodes,
priority=DownloadPriority.NORMAL,
)
assert len(item_ids) == 3
assert len(download_service._pending_queue) == 3
@pytest.mark.asyncio
async def test_remove_from_pending_queue(self, download_service):
"""Test removing items from pending queue."""
item_ids = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
removed_ids = await download_service.remove_from_queue(item_ids)
assert len(removed_ids) == 1
assert removed_ids[0] == item_ids[0]
assert len(download_service._pending_queue) == 0
@pytest.mark.asyncio
async def test_start_next_download(self, download_service):
"""Test starting the next download from queue."""
# Add items to queue
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
],
)
# Start next download
started_id = await download_service.start_next_download()
assert started_id is not None
assert started_id == "queue_started" # Service returns this string
# Queue processing starts in background, wait a moment
await asyncio.sleep(0.2)
# First item should be processing or completed
assert len(download_service._pending_queue) <= 2
assert download_service._is_stopped is False
@pytest.mark.asyncio
async def test_start_next_download_empty_queue(self, download_service):
"""Test starting download with empty queue returns None."""
result = await download_service.start_next_download()
assert result is None
@pytest.mark.asyncio
async def test_start_next_download_already_active(
self, download_service, mock_anime_service
):
"""Test that starting download while one is active raises error."""
# Add items and start one
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
],
)
# Make download slow so it stays active (fake - no real download)
async def fake_slow_download(**kwargs):
await asyncio.sleep(0.5) # Reduced from 10s to speed up test
return True # Fake success
mock_anime_service.download = AsyncMock(side_effect=fake_slow_download)
# Start first download (will block for 0.5s in background)
item_id = await download_service.start_next_download()
assert item_id is not None
await asyncio.sleep(0.1) # Let it start processing
# Try to start another - should fail because one is active
with pytest.raises(DownloadServiceError, match="already active"):
await download_service.start_next_download()
@pytest.mark.asyncio
async def test_stop_downloads(self, download_service):
"""Test stopping queue processing."""
await download_service.stop_downloads()
assert download_service._is_stopped is True
@pytest.mark.asyncio
async def test_download_completion_moves_to_list(
self, download_service, mock_anime_service
):
"""Test successful download moves item to completed list."""
# Ensure mock returns success (fake download - no real download)
mock_anime_service.download = AsyncMock(return_value=True)
# Add item
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Start and wait for completion
await download_service.start_next_download()
await asyncio.sleep(0.2) # Wait for download to complete
assert len(download_service._completed_items) == 1
assert download_service._active_download is None
@pytest.mark.asyncio
async def test_download_failure_moves_to_list(
self, download_service, mock_anime_service
):
"""Test failed download moves item to failed list."""
# Make download fail (fake download failure - no real download)
mock_anime_service.download = AsyncMock(return_value=False)
# Add item
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Start and wait for failure
await download_service.start_next_download()
await asyncio.sleep(0.2) # Wait for download to fail
assert len(download_service._failed_items) == 1
assert download_service._active_download is None
class TestQueueStatus:
"""Test queue status reporting."""
@pytest.mark.asyncio
async def test_get_queue_status(self, download_service):
"""Test getting queue status."""
# Add items to queue
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
],
)
status = await download_service.get_queue_status()
# Queue is stopped until start_next_download() is called
assert status.is_running is False
assert status.is_paused is False
assert len(status.pending_queue) == 2
assert len(status.active_downloads) == 0
assert len(status.completed_downloads) == 0
assert len(status.failed_downloads) == 0
@pytest.mark.asyncio
async def test_get_queue_stats(self, download_service):
"""Test getting queue statistics."""
# Add items
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
],
)
stats = await download_service.get_queue_stats()
assert stats.total_items == 2
assert stats.pending_count == 2
assert stats.active_count == 0
assert stats.completed_count == 0
assert stats.failed_count == 0
assert stats.total_downloaded_mb == 0.0
class TestQueueControl:
"""Test queue control operations."""
@pytest.mark.asyncio
async def test_clear_completed(self, download_service):
"""Test clearing completed downloads."""
# Manually add completed item
completed_item = DownloadItem(
id="completed-1",
serie_id="series-1",
serie_folder="Test Series (2023)",
serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.COMPLETED,
)
download_service._completed_items.append(completed_item)
count = await download_service.clear_completed()
assert count == 1
assert len(download_service._completed_items) == 0
@pytest.mark.asyncio
async def test_clear_pending(self, download_service):
"""Test clearing all pending downloads from the queue."""
# Add multiple items to the queue
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="test-series-1",
serie_name="Test Series 1",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_service.add_to_queue(
serie_id="series-2",
serie_folder="test-series-2",
serie_name="Test Series 2",
episodes=[
EpisodeIdentifier(season=1, episode=2),
EpisodeIdentifier(season=1, episode=3),
],
)
# Verify items were added
assert len(download_service._pending_queue) == 3
# Clear pending queue
count = await download_service.clear_pending()
# Verify all pending items were cleared
assert count == 3
assert len(download_service._pending_queue) == 0
assert len(download_service._pending_items_by_id) == 0
class TestPersistence:
"""Test queue persistence functionality with database backend."""
@pytest.mark.asyncio
async def test_queue_persistence(
self, download_service, mock_queue_repository
):
"""Test that queue state is persisted to database."""
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Item should be saved in mock repository
all_items = await mock_queue_repository.get_all_items()
assert len(all_items) == 1
assert all_items[0].serie_id == "series-1"
@pytest.mark.asyncio
async def test_queue_recovery_after_restart(
self, mock_anime_service, mock_queue_repository
):
"""Test that queue is recovered after service restart."""
# Create and populate first service
service1 = DownloadService(
anime_service=mock_anime_service,
queue_repository=mock_queue_repository,
)
await service1.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
],
)
# Create new service with same repository (simulating restart)
service2 = DownloadService(
anime_service=mock_anime_service,
queue_repository=mock_queue_repository,
)
# Initialize to load from database to recover state
await service2.initialize()
# Should recover pending items
assert len(service2._pending_queue) == 2
class TestRetryLogic:
"""Test retry logic for failed downloads."""
@pytest.mark.asyncio
async def test_retry_failed_items(self, download_service):
"""Test retrying failed downloads."""
# Manually add failed item
failed_item = DownloadItem(
id="failed-1",
serie_id="series-1",
serie_folder="Test Series (2023)",
serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.FAILED,
retry_count=0,
error="Test error",
)
download_service._failed_items.append(failed_item)
retried_ids = await download_service.retry_failed()
assert len(retried_ids) == 1
assert len(download_service._failed_items) == 0
assert len(download_service._pending_queue) == 1
# retry_count stays same when retrying; incremented only on failure
assert download_service._pending_queue[0].retry_count == 0
assert download_service._pending_queue[0].status == DownloadStatus.PENDING
@pytest.mark.asyncio
async def test_max_retries_not_exceeded(self, download_service):
"""Test that items with max retries are not retried."""
# Create item with max retries
failed_item = DownloadItem(
id="failed-1",
serie_id="series-1",
serie_folder="Test Series (2023)",
serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.FAILED,
retry_count=3, # Max retries
error="Test error",
)
download_service._failed_items.append(failed_item)
retried_ids = await download_service.retry_failed()
assert len(retried_ids) == 0
assert len(download_service._failed_items) == 1
assert len(download_service._pending_queue) == 0
@pytest.mark.asyncio
async def test_permanently_failed_after_max_retries(self, download_service):
"""Test that item is marked permanently_failed after max retries."""
# Mock download to fail
download_service._anime_service.download = AsyncMock(
side_effect=Exception("Download failed")
)
# Create item with max_retries - 1 already used
item = DownloadItem(
id="perm-failed-1",
serie_id="series-1",
serie_folder="Test Series (2023)",
serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.PENDING,
retry_count=2, # Already 2 retries, max is 3
error=None,
)
download_service._pending_queue.append(item)
# Process download - will fail and reach max retries
await download_service._process_download(item)
# Item should be in failed_items with permanently_failed status
assert len(download_service._failed_items) == 1
assert download_service._failed_items[0].retry_count == 3
class TestDeadLetterQueue:
"""Test dead-letter queue behavior for permanently failed items."""
@pytest.mark.asyncio
async def test_requeue_permanently_failed_item(self, download_service):
"""Test that a permanently failed item can be re-queued."""
# The unique constraint now includes status, so a permanently_failed
# item doesn't block re-queuing the same episode
pass # Implementation depends on UI/API behavior
class TestBroadcastCallbacks:
"""Test WebSocket broadcast functionality."""
@pytest.mark.asyncio
async def test_broadcast_on_queue_update(self, download_service):
"""Test that queue updates work correctly (no broadcast callbacks)."""
# Note: The service no longer has set_broadcast_callback method
# It uses the progress service internally for websocket updates
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Verify item was added successfully
assert len(download_service._pending_queue) == 1
@pytest.mark.asyncio
async def test_progress_callback_format(self, download_service):
"""Test that download completes successfully with mocked service."""
# Note: Progress updates are handled by SeriesApp events and
# ProgressService, not via direct callbacks to the download service.
# This test verifies that downloads complete without errors.
# Mock successful download (fake download - no real download)
download_service._anime_service.download = AsyncMock(return_value=True)
# Add and process a download
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Start download and wait for completion
await download_service.start_next_download()
await asyncio.sleep(0.5) # Wait for processing
# Verify download completed successfully
assert len(download_service._completed_items) == 1
assert download_service._completed_items[0].status == (
DownloadStatus.COMPLETED
)
class TestServiceLifecycle:
"""Test service start and stop operations."""
@pytest.mark.asyncio
async def test_start_service(self, download_service):
"""Test starting the service."""
# initialize() is the proper method for service initialization
await download_service.initialize()
# Service initialized successfully
@pytest.mark.asyncio
async def test_stop_service(self, download_service):
"""Test stopping the service."""
await download_service.initialize()
await download_service.stop_downloads()
# Verifies service can be stopped without errors
# No _is_running attribute in simplified service
@pytest.mark.asyncio
async def test_start_already_running(self, download_service):
"""Test starting service when already running."""
await download_service.initialize()
await download_service.initialize() # Should not raise error
# No _is_running attribute in simplified service
class TestErrorHandling:
"""Test error handling in download service."""
@pytest.mark.asyncio
async def test_download_failure_moves_to_failed(self, download_service):
"""Test that download failures are handled correctly."""
# Mock download to fail with exception (fake - no real download)
download_service._anime_service.download = AsyncMock(
side_effect=Exception("Fake download failed")
)
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Process the download
item = download_service._pending_queue.popleft()
await download_service._process_download(item)
# Item should be in failed queue
assert len(download_service._failed_items) == 1
assert (
download_service._failed_items[0].status == DownloadStatus.FAILED
)
assert download_service._failed_items[0].error is not None
class TestRemoveEpisodeFromMissingList:
"""Test that completed downloads remove episodes from missing list."""
@pytest.mark.asyncio
async def test_remove_episode_from_memory(self, download_service):
"""Test _remove_episode_from_memory updates in-memory state."""
from src.core.entities.series import Serie
# Set up in-memory series with missing episodes
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series (2024)",
episodeDict={1: [1, 2, 3], 2: [1, 2]},
)
mock_app = MagicMock()
mock_app.list.keyDict = {"test-series": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
download_service._anime_service._app = mock_app
# Remove episode S01E02
download_service._remove_episode_from_memory("test-series", 1, 2)
# Episode should be removed from episodeDict
assert 2 not in serie.episodeDict[1]
assert serie.episodeDict[1] == [1, 3]
# Season 2 should be untouched
assert serie.episodeDict[2] == [1, 2]
@pytest.mark.asyncio
async def test_remove_last_episode_in_season_removes_season(
self, download_service
):
"""Test removing the last episode in a season removes the season key."""
from src.core.entities.series import Serie
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series (2024)",
episodeDict={1: [5], 2: [1, 2]},
)
mock_app = MagicMock()
mock_app.list.keyDict = {"test-series": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
download_service._anime_service._app = mock_app
# Remove the only episode in season 1
download_service._remove_episode_from_memory("test-series", 1, 5)
# Season 1 should be completely removed
assert 1 not in serie.episodeDict
# Season 2 untouched
assert serie.episodeDict[2] == [1, 2]
# GetMissingEpisode should have been called to refresh
mock_app.list.GetMissingEpisode.assert_called()
@pytest.mark.asyncio
async def test_remove_episode_unknown_series_no_error(
self, download_service
):
"""Test removing episode for unknown series does not raise."""
mock_app = MagicMock()
mock_app.list.keyDict = {}
download_service._anime_service._app = mock_app
# Should not raise
download_service._remove_episode_from_memory(
"nonexistent-series", 1, 1
)
@pytest.mark.asyncio
async def test_remove_episode_from_missing_list_calls_db_and_memory(
self, download_service
):
"""Test _remove_episode_from_missing_list updates both DB and memory."""
from unittest.mock import patch
from src.core.entities.series import Serie
# Set up in-memory state
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series (2024)",
episodeDict={1: [1, 2, 3]},
)
mock_app = MagicMock()
mock_app.list.keyDict = {"test-series": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
download_service._anime_service._app = mock_app
download_service._anime_service._cached_list_missing = MagicMock()
# Mock DB session
mock_db_session = AsyncMock()
# Mock series returned by get_by_key
mock_series = MagicMock()
mock_series.id = 1
# Mock episode returned by get_by_episode
mock_episode = MagicMock()
mock_episode.id = 100
with patch(
"src.server.database.connection.get_db_session"
) as mock_get_db, patch(
"src.server.database.service.AnimeSeriesService"
) as mock_series_svc, patch(
"src.server.database.service.EpisodeService"
) as mock_ep_svc:
mock_get_db.return_value.__aenter__ = AsyncMock(
return_value=mock_db_session
)
mock_get_db.return_value.__aexit__ = AsyncMock(
return_value=False
)
# Mock get_by_key to return series
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
# Mock get_by_episode to return episode
mock_ep_svc.get_by_episode = AsyncMock(return_value=mock_episode)
# Mock mark_downloaded to succeed
mock_ep_svc.mark_downloaded = AsyncMock(return_value=mock_episode)
result = await download_service._remove_episode_from_missing_list(
series_key="test-series",
season=1,
episode=2,
serie_folder="Test Series (2024)",
)
# mark_downloaded was called instead of delete
mock_ep_svc.mark_downloaded.assert_awaited_once_with(
db=mock_db_session,
episode_id=100,
file_path=(
f"{download_service._directory}/Test Series (2024)/Season 1"
),
)
# In-memory update happened
assert 2 not in serie.episodeDict[1]
assert serie.episodeDict[1] == [1, 3]
# Cache was cleared
download_service._anime_service._cached_list_missing.cache_clear.assert_called()
assert result is True
@pytest.mark.asyncio
async def test_download_completion_removes_missing_episode(
self, download_service
):
"""Test full flow: download success removes episode from missing list."""
from unittest.mock import patch
from src.core.entities.series import Serie
# Setup mock anime service to return success
download_service._anime_service.download = AsyncMock(
return_value=True
)
# Set up in-memory series state
serie = Serie(
key="series-1",
name="Test Series",
site="https://example.com",
folder="series",
episodeDict={1: [1, 2, 3]},
)
mock_app = MagicMock()
mock_app.list.keyDict = {"series-1": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
download_service._anime_service._app = mock_app
download_service._anime_service._cached_list_missing = MagicMock()
# Add episode to queue
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=2)],
)
# Mock DB calls
mock_db_session = AsyncMock()
# Mock series returned by get_by_key
mock_series = MagicMock()
mock_series.id = 1
# Mock episode returned by get_by_episode
mock_episode = MagicMock()
mock_episode.id = 100
with patch(
"src.server.database.connection.get_db_session"
) as mock_get_db, patch(
"src.server.database.service.AnimeSeriesService"
) as mock_series_svc, patch(
"src.server.database.service.EpisodeService"
) as mock_ep_svc:
mock_get_db.return_value.__aenter__ = AsyncMock(
return_value=mock_db_session
)
mock_get_db.return_value.__aexit__ = AsyncMock(
return_value=False
)
# Mock get_by_key to return series
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
# Mock get_by_episode to return episode
mock_ep_svc.get_by_episode = AsyncMock(return_value=mock_episode)
# Mock mark_downloaded to succeed
mock_ep_svc.mark_downloaded = AsyncMock(return_value=mock_episode)
# Process the download
item = download_service._pending_queue.popleft()
download_service._pending_items_by_id.pop(item.id, None)
await download_service._process_download(item)
# Episode should be completed
assert len(download_service._completed_items) == 1
assert download_service._completed_items[0].status == DownloadStatus.COMPLETED
# Episode 2 should be removed from in-memory missing list
assert 2 not in serie.episodeDict[1]
assert serie.episodeDict[1] == [1, 3]
class TestQueueDeduplication:
"""Test queue deduplication to prevent duplicate entries."""
@pytest.mark.asyncio
async def test_add_same_episode_twice_creates_only_one_entry(
self, download_service
):
"""Test that adding the same episode twice only creates one queue entry."""
episodes = [EpisodeIdentifier(season=1, episode=1)]
# Add same episode twice
ids1 = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=episodes,
)
ids2 = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=episodes,
)
# Should only have one entry
assert len(download_service._pending_queue) == 1
# First call creates one ID
assert len(ids1) == 1
# Second call creates zero IDs (deduplicated)
assert len(ids2) == 0
@pytest.mark.asyncio
async def test_add_different_episodes_creates_separate_entries(
self, download_service
):
"""Test that different episodes create separate queue entries."""
episodes1 = [EpisodeIdentifier(season=1, episode=1)]
episodes2 = [EpisodeIdentifier(season=1, episode=2)]
ids1 = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=episodes1,
)
ids2 = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=episodes2,
)
# Should have two separate entries
assert len(download_service._pending_queue) == 2
assert len(ids1) == 1
assert len(ids2) == 1
# IDs should be different
assert ids1[0] != ids2[0]
@pytest.mark.asyncio
async def test_add_same_episode_different_series_creates_entries(
self, download_service
):
"""Test that same episode in different series creates separate entries."""
episodes = [EpisodeIdentifier(season=1, episode=1)]
ids1 = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series1",
serie_name="Test Series 1",
episodes=episodes,
)
ids2 = await download_service.add_to_queue(
serie_id="series-2",
serie_folder="series2",
serie_name="Test Series 2",
episodes=episodes,
)
# Should have two separate entries (different series)
assert len(download_service._pending_queue) == 2
assert len(ids1) == 1
assert len(ids2) == 1
@pytest.mark.asyncio
async def test_add_multiple_episodes_with_duplicates_filters_correctly(
self, download_service
):
"""Test that adding multiple episodes with some duplicates filters correctly."""
episodes = [
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
EpisodeIdentifier(season=1, episode=1), # duplicate
EpisodeIdentifier(season=1, episode=3),
]
ids1 = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=episodes,
)
# Should only have 3 entries (1, 2, 3) - one filtered out
assert len(download_service._pending_queue) == 3
assert len(ids1) == 3