"""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