Add download retry logic tests (12 tests, all passing)
✅ COMPLETE: 12/12 tests passing
Test Coverage:
- Automatic retry: Single item retry, retry all failed items
- Retry count tracking: Count increments on retry, persists across retries
- Maximum retry limit: Items not retried after max, mixed eligibility, configurable max_retries
- Retry state management: Error cleared, progress cleared, status updated, selective retry by IDs
- Exponential backoff: ImageDownloader implements exponential backoff (0.1s→0.2s delays)
All download retry mechanisms validated with proper state management and limit enforcement.
This commit is contained in:
486
tests/unit/test_download_retry.py
Normal file
486
tests/unit/test_download_retry.py
Normal file
@@ -0,0 +1,486 @@
|
||||
"""Unit tests for download retry logic.
|
||||
|
||||
This module tests automatic retry mechanisms, retry count tracking,
|
||||
exponential backoff, maximum retry limits, and state persistence.
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.models.download import DownloadItem, DownloadStatus, EpisodeIdentifier
|
||||
from src.server.services.download_service import DownloadService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_progress_service():
|
||||
"""Auto-mock progress service for all tests."""
|
||||
with patch('src.server.services.download_service.get_progress_service') as mock:
|
||||
mock_service = Mock()
|
||||
mock_service.update_progress = AsyncMock()
|
||||
mock.return_value = mock_service
|
||||
yield mock_service
|
||||
|
||||
|
||||
class TestAutomaticRetry:
|
||||
"""Test automatic retry after download failure."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_failed_single_item(self):
|
||||
"""Test retrying a single failed download."""
|
||||
mock_anime_service = Mock()
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=3)
|
||||
|
||||
# Create failed item
|
||||
failed_item = DownloadItem(
|
||||
id="test-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=0,
|
||||
error="Network timeout"
|
||||
)
|
||||
download_service._failed_items.append(failed_item)
|
||||
|
||||
# Retry
|
||||
retried_ids = await download_service.retry_failed([failed_item.id])
|
||||
|
||||
# Should be retried
|
||||
assert len(retried_ids) == 1
|
||||
assert failed_item.id in retried_ids
|
||||
assert len(download_service._failed_items) == 0
|
||||
assert len(download_service._pending_queue) == 1
|
||||
|
||||
# Check item moved to pending
|
||||
pending_item = download_service._pending_queue[0]
|
||||
assert pending_item.id == failed_item.id
|
||||
assert pending_item.status == DownloadStatus.PENDING
|
||||
assert pending_item.retry_count == 1
|
||||
assert pending_item.error is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_all_failed_items(self):
|
||||
"""Test retrying all failed downloads."""
|
||||
mock_anime_service = Mock()
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=3)
|
||||
|
||||
# Create multiple failed items
|
||||
for i in range(5):
|
||||
failed_item = DownloadItem(
|
||||
id=f"test-{i}",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=i+1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=0,
|
||||
error="Test error"
|
||||
)
|
||||
download_service._failed_items.append(failed_item)
|
||||
|
||||
# Retry all (passing None)
|
||||
retried_ids = await download_service.retry_failed(item_ids=None)
|
||||
|
||||
# All should be retried
|
||||
assert len(retried_ids) == 5
|
||||
assert len(download_service._failed_items) == 0
|
||||
assert len(download_service._pending_queue) == 5
|
||||
|
||||
|
||||
class TestRetryCountTracking:
|
||||
"""Test retry attempt count tracking."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_count_increments_on_retry(self):
|
||||
"""Test that retry_count increments each retry."""
|
||||
mock_anime_service = Mock()
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=5)
|
||||
|
||||
# Initial failure (retry_count = 0)
|
||||
failed_item = DownloadItem(
|
||||
id="test-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
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)
|
||||
|
||||
# First retry
|
||||
await download_service.retry_failed([failed_item.id])
|
||||
assert download_service._pending_queue[0].retry_count == 1
|
||||
|
||||
# Simulate failure again
|
||||
item = download_service._pending_queue.popleft()
|
||||
item.status = DownloadStatus.FAILED
|
||||
download_service._failed_items.append(item)
|
||||
|
||||
# Second retry
|
||||
await download_service.retry_failed([item.id])
|
||||
assert download_service._pending_queue[0].retry_count == 2
|
||||
|
||||
# Simulate failure again
|
||||
item = download_service._pending_queue.popleft()
|
||||
item.status = DownloadStatus.FAILED
|
||||
download_service._failed_items.append(item)
|
||||
|
||||
# Third retry
|
||||
await download_service.retry_failed([item.id])
|
||||
assert download_service._pending_queue[0].retry_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_count_persists_across_retries(self):
|
||||
"""Test that retry_count persists correctly."""
|
||||
mock_anime_service = Mock()
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=3)
|
||||
|
||||
# Create item with previous retries
|
||||
failed_item = DownloadItem(
|
||||
id="test-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=2, # Already retried twice
|
||||
error="Test error"
|
||||
)
|
||||
download_service._failed_items.append(failed_item)
|
||||
|
||||
# Retry
|
||||
await download_service.retry_failed()
|
||||
|
||||
# Should now have retry_count = 3
|
||||
assert download_service._pending_queue[0].retry_count == 3
|
||||
|
||||
|
||||
class TestMaximumRetryLimit:
|
||||
"""Test maximum retry limit enforcement."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_not_retried_after_max_retries(self):
|
||||
"""Test that items exceeding max_retries are not retried."""
|
||||
mock_anime_service = Mock()
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=3)
|
||||
|
||||
# Create item that reached max retries
|
||||
failed_item = DownloadItem(
|
||||
id="test-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=3, # At max retries
|
||||
error="Test error"
|
||||
)
|
||||
download_service._failed_items.append(failed_item)
|
||||
|
||||
# Try to retry
|
||||
retried_ids = await download_service.retry_failed()
|
||||
|
||||
# Should NOT be retried
|
||||
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_mixed_retry_eligibility(self):
|
||||
"""Test mix of items at/below max retries."""
|
||||
mock_anime_service = Mock()
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=3)
|
||||
|
||||
# Item 1: eligible for retry
|
||||
item1 = DownloadItem(
|
||||
id="eligible-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=0,
|
||||
error="Test error"
|
||||
)
|
||||
|
||||
# Item 2: at max retries
|
||||
item2 = DownloadItem(
|
||||
id="max-retries-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=2),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=3,
|
||||
error="Test error"
|
||||
)
|
||||
|
||||
# Item 3: eligible for retry
|
||||
item3 = DownloadItem(
|
||||
id="eligible-2",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=3),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=1,
|
||||
error="Test error"
|
||||
)
|
||||
|
||||
download_service._failed_items.extend([item1, item2, item3])
|
||||
|
||||
# Retry all
|
||||
retried_ids = await download_service.retry_failed()
|
||||
|
||||
# Only 2 should be retried
|
||||
assert len(retried_ids) == 2
|
||||
assert item1.id in retried_ids
|
||||
assert item3.id in retried_ids
|
||||
assert item2.id not in retried_ids
|
||||
|
||||
# item2 should still be in failed
|
||||
assert len(download_service._failed_items) == 1
|
||||
assert download_service._failed_items[0].id == item2.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configurable_max_retries(self):
|
||||
"""Test that max_retries is configurable."""
|
||||
mock_anime_service = Mock()
|
||||
|
||||
# Service with max_retries=5
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=5)
|
||||
|
||||
# Item with 4 retries
|
||||
failed_item = DownloadItem(
|
||||
id="test-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=4,
|
||||
error="Test error"
|
||||
)
|
||||
download_service._failed_items.append(failed_item)
|
||||
|
||||
# Should still be retried (4 < 5)
|
||||
retried_ids = await download_service.retry_failed()
|
||||
assert len(retried_ids) == 1
|
||||
|
||||
# Now with 5 retries
|
||||
item = download_service._pending_queue.popleft()
|
||||
item.status = DownloadStatus.FAILED
|
||||
download_service._failed_items.append(item)
|
||||
|
||||
# Should NOT be retried (5 >= 5)
|
||||
retried_ids = await download_service.retry_failed()
|
||||
assert len(retried_ids) == 0
|
||||
|
||||
|
||||
class TestRetryStateManagement:
|
||||
"""Test retry state management and persistence."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_cleared_on_retry(self):
|
||||
"""Test that error message is cleared on retry."""
|
||||
mock_anime_service = Mock()
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=3)
|
||||
|
||||
# Failed item with error
|
||||
failed_item = DownloadItem(
|
||||
id="test-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=0,
|
||||
error="Network timeout error"
|
||||
)
|
||||
download_service._failed_items.append(failed_item)
|
||||
|
||||
# Retry
|
||||
await download_service.retry_failed()
|
||||
|
||||
# Error should be cleared
|
||||
pending_item = download_service._pending_queue[0]
|
||||
assert pending_item.error is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_cleared_on_retry(self):
|
||||
"""Test that progress is cleared on retry."""
|
||||
mock_anime_service = Mock()
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=3)
|
||||
|
||||
# Failed item with partial progress
|
||||
failed_item = DownloadItem(
|
||||
id="test-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=0,
|
||||
error="Connection lost",
|
||||
progress={"percent": 45.5}
|
||||
)
|
||||
download_service._failed_items.append(failed_item)
|
||||
|
||||
# Retry
|
||||
await download_service.retry_failed()
|
||||
|
||||
# Progress should be cleared
|
||||
pending_item = download_service._pending_queue[0]
|
||||
assert pending_item.progress is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_updated_on_retry(self):
|
||||
"""Test that status changes from FAILED to PENDING."""
|
||||
mock_anime_service = Mock()
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=3)
|
||||
|
||||
failed_item = DownloadItem(
|
||||
id="test-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
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)
|
||||
|
||||
assert failed_item.status == DownloadStatus.FAILED
|
||||
|
||||
# Retry
|
||||
await download_service.retry_failed()
|
||||
|
||||
# Status should change to PENDING
|
||||
pending_item = download_service._pending_queue[0]
|
||||
assert pending_item.status == DownloadStatus.PENDING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selective_retry_by_ids(self):
|
||||
"""Test retrying only specific items by IDs."""
|
||||
mock_anime_service = Mock()
|
||||
download_service = DownloadService(anime_service=mock_anime_service, max_retries=3)
|
||||
|
||||
# Create 3 failed items
|
||||
item1 = DownloadItem(
|
||||
id="item-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=0,
|
||||
error="Test error"
|
||||
)
|
||||
|
||||
item2 = DownloadItem(
|
||||
id="item-2",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=2),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=0,
|
||||
error="Test error"
|
||||
)
|
||||
|
||||
item3 = DownloadItem(
|
||||
id="item-3",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2024)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=3),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=0,
|
||||
error="Test error"
|
||||
)
|
||||
|
||||
download_service._failed_items.extend([item1, item2, item3])
|
||||
|
||||
# Retry only item-1 and item-3
|
||||
retried_ids = await download_service.retry_failed(["item-1", "item-3"])
|
||||
|
||||
# Only 2 should be retried
|
||||
assert len(retried_ids) == 2
|
||||
assert "item-1" in retried_ids
|
||||
assert "item-3" in retried_ids
|
||||
|
||||
# item-2 should still be in failed
|
||||
assert len(download_service._failed_items) == 1
|
||||
assert download_service._failed_items[0].id == "item-2"
|
||||
|
||||
|
||||
class TestExponentialBackoff:
|
||||
"""Test exponential backoff implementation in ImageDownloader."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_downloader_retry_with_backoff(self):
|
||||
"""Test that ImageDownloader implements exponential backoff."""
|
||||
from src.core.utils.image_downloader import ImageDownloader, ImageDownloadError
|
||||
from pathlib import Path
|
||||
import aiohttp
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
downloader = ImageDownloader(max_retries=3, retry_delay=0.1)
|
||||
|
||||
# Mock session with failures
|
||||
mock_session = AsyncMock()
|
||||
mock_session.closed = False
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 500
|
||||
|
||||
# Create mock RequestInfo
|
||||
mock_request_info = MagicMock()
|
||||
mock_request_info.real_url = "https://test.com/image.jpg"
|
||||
|
||||
mock_response.raise_for_status = MagicMock(
|
||||
side_effect=aiohttp.ClientResponseError(
|
||||
request_info=mock_request_info,
|
||||
history=(),
|
||||
status=500,
|
||||
message="Server Error"
|
||||
)
|
||||
)
|
||||
|
||||
# Setup context manager
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session.get = MagicMock(return_value=mock_cm)
|
||||
|
||||
downloader.session = mock_session
|
||||
|
||||
# Track timing for backoff validation
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
# Attempt download (should fail after retries)
|
||||
try:
|
||||
await downloader.download_image(
|
||||
"https://test.com/image.jpg",
|
||||
Path("/tmp/test.jpg")
|
||||
)
|
||||
except ImageDownloadError:
|
||||
pass # Expected
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# With initial delay 0.1s and doubling:
|
||||
# Attempt 1: immediate
|
||||
# Attempt 2: wait 0.1s
|
||||
# Attempt 3: wait 0.2s
|
||||
# Total: at least 0.3s
|
||||
assert elapsed >= 0.3, f"Backoff too fast: {elapsed}s"
|
||||
|
||||
# Should have attempted 3 times
|
||||
assert mock_session.get.call_count == 3
|
||||
Reference in New Issue
Block a user