- test_anime_endpoints.py: Minor updates - test_download_retry.py: Refinements - test_i18n.js: Updates - test_tmdb_client.py: Improvements - test_tmdb_rate_limiting.py: Test enhancements - test_user_preferences.js: Updates
489 lines
17 KiB
Python
489 lines
17 KiB
Python
"""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 pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import aiohttp
|
|
|
|
from src.core.utils.image_downloader import ImageDownloader, ImageDownloadError
|
|
|
|
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
|