"""Unit tests for image downloader.""" import io from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import aiohttp import pytest from PIL import Image from src.core.utils.image_downloader import ImageDownloader, ImageDownloadError @pytest.fixture def image_downloader(): """Create image downloader instance.""" # Use smaller min_file_size for tests since test images are small return ImageDownloader(min_file_size=100) @pytest.fixture def valid_image_bytes(): """Create valid test image bytes.""" img = Image.new('RGB', (100, 100), color='red') buf = io.BytesIO() img.save(buf, format='JPEG') return buf.getvalue() @pytest.fixture def mock_session(): """Create mock aiohttp session.""" mock = AsyncMock() # Make get() return an async context manager mock.get = MagicMock() mock.closed = False return mock class TestImageDownloaderInit: """Test ImageDownloader initialization.""" def test_init_default_values(self): """Test initialization with default values.""" downloader = ImageDownloader() assert downloader.min_file_size == 1024 assert downloader.max_retries == 3 assert downloader.retry_delay == 1.0 assert downloader.timeout == 30 assert downloader.session is None def test_init_custom_values(self): """Test initialization with custom values.""" downloader = ImageDownloader( min_file_size=5000, max_retries=5, retry_delay=2.0, timeout=60 ) assert downloader.min_file_size == 5000 assert downloader.max_retries == 5 assert downloader.retry_delay == 2.0 assert downloader.timeout == 60 class TestImageDownloaderContextManager: """Test ImageDownloader as context manager.""" @pytest.mark.asyncio async def test_async_context_manager(self, image_downloader): """Test async context manager creates session.""" async with image_downloader as d: assert d.session is not None assert image_downloader.session is None @pytest.mark.asyncio async def test_close_closes_session(self, image_downloader): """Test close method closes session.""" await image_downloader.__aenter__() assert image_downloader.session is not None await image_downloader.close() assert image_downloader.session is None class TestImageDownloaderValidateImage: """Test validate_image method.""" def test_validate_valid_image(self, image_downloader, valid_image_bytes, tmp_path): """Test validation of valid image.""" image_path = tmp_path / "valid.jpg" image_path.write_bytes(valid_image_bytes) result = image_downloader.validate_image(image_path) assert result is True def test_validate_too_small(self, image_downloader, tmp_path): """Test validation rejects too-small file.""" tiny_data = b"tiny" image_path = tmp_path / "tiny.jpg" image_path.write_bytes(tiny_data) result = image_downloader.validate_image(image_path) assert result is False def test_validate_invalid_image_data(self, image_downloader, tmp_path): """Test validation rejects invalid image data.""" invalid_data = b"x" * 2000 # Large enough but not an image image_path = tmp_path / "invalid.jpg" image_path.write_bytes(invalid_data) result = image_downloader.validate_image(image_path) assert result is False def test_validate_corrupted_image(self, image_downloader, tmp_path): """Test validation rejects corrupted image.""" # Create a corrupted JPEG-like file corrupted = b"\xFF\xD8\xFF\xE0" + b"corrupted_data" * 100 image_path = tmp_path / "corrupted.jpg" image_path.write_bytes(corrupted) result = image_downloader.validate_image(image_path) assert result is False class TestImageDownloaderDownloadImage: """Test download_image method.""" @pytest.mark.asyncio async def test_download_image_success( self, image_downloader, valid_image_bytes, tmp_path ): """Test successful image download.""" mock_session = AsyncMock() mock_session.closed = False mock_response = AsyncMock() mock_response.status = 200 mock_response.read = AsyncMock(return_value=valid_image_bytes) # Setup async context manager for session.get() 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) image_downloader.session = mock_session output_path = tmp_path / "test.jpg" await image_downloader.download_image( "https://test.com/image.jpg", output_path ) assert output_path.exists() assert output_path.stat().st_size == len(valid_image_bytes) @pytest.mark.asyncio async def test_download_image_skip_existing( self, image_downloader, tmp_path ): """Test skipping existing file.""" output_path = tmp_path / "existing.jpg" # Write a file large enough to pass min_file_size check output_path.write_bytes(b"x" * 200) mock_session = AsyncMock() mock_session.closed = False image_downloader.session = mock_session result = await image_downloader.download_image( "https://test.com/image.jpg", output_path, skip_existing=True ) assert result is True assert output_path.read_bytes() == b"x" * 200 # Unchanged assert not mock_session.get.called @pytest.mark.asyncio async def test_download_image_overwrite_existing( self, image_downloader, valid_image_bytes, tmp_path ): """Test overwriting existing file.""" output_path = tmp_path / "existing.jpg" output_path.write_bytes(b"old") mock_session = AsyncMock() mock_session.closed = False mock_response = AsyncMock() mock_response.status = 200 mock_response.read = AsyncMock(return_value=valid_image_bytes) # Setup async context manager for session.get() 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) image_downloader.session = mock_session await image_downloader.download_image( "https://test.com/image.jpg", output_path, skip_existing=False ) assert output_path.exists() assert output_path.read_bytes() == valid_image_bytes @pytest.mark.asyncio async def test_download_image_invalid_url(self, image_downloader, tmp_path): """Test download with invalid URL.""" mock_session = AsyncMock() mock_session.closed = False mock_response = AsyncMock() mock_response.status = 404 # Setup async context manager for session.get() 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) image_downloader.session = mock_session output_path = tmp_path / "test.jpg" result = await image_downloader.download_image( "https://test.com/missing.jpg", output_path ) assert result is False # 404 returns False, not exception class TestImageDownloaderSpecificMethods: """Test type-specific download methods.""" @pytest.mark.asyncio async def test_download_poster(self, image_downloader, valid_image_bytes, tmp_path): """Test download_poster method.""" with patch.object( image_downloader, 'download_image', new_callable=AsyncMock ) as mock_download: await image_downloader.download_poster( "https://test.com/poster.jpg", tmp_path ) mock_download.assert_called_once() call_args = mock_download.call_args assert call_args[0][1] == tmp_path / "poster.jpg" @pytest.mark.asyncio async def test_download_logo(self, image_downloader, tmp_path): """Test download_logo method.""" with patch.object( image_downloader, 'download_image', new_callable=AsyncMock ) as mock_download: await image_downloader.download_logo( "https://test.com/logo.png", tmp_path ) mock_download.assert_called_once() call_args = mock_download.call_args assert call_args[0][1] == tmp_path / "logo.png" @pytest.mark.asyncio async def test_download_fanart(self, image_downloader, tmp_path): """Test download_fanart method.""" with patch.object( image_downloader, 'download_image', new_callable=AsyncMock ) as mock_download: await image_downloader.download_fanart( "https://test.com/fanart.jpg", tmp_path ) mock_download.assert_called_once() call_args = mock_download.call_args assert call_args[0][1] == tmp_path / "fanart.jpg" class TestImageDownloaderDownloadAll: """Test download_all_media method.""" @pytest.mark.asyncio async def test_download_all_success(self, image_downloader, tmp_path): """Test downloading all media types.""" with patch.object( image_downloader, 'download_poster', new_callable=AsyncMock, return_value=True ), patch.object( image_downloader, 'download_logo', new_callable=AsyncMock, return_value=True ), patch.object( image_downloader, 'download_fanart', new_callable=AsyncMock, return_value=True ): results = await image_downloader.download_all_media( tmp_path, poster_url="https://test.com/poster.jpg", logo_url="https://test.com/logo.png", fanart_url="https://test.com/fanart.jpg" ) assert results["poster"] is True assert results["logo"] is True assert results["fanart"] is True @pytest.mark.asyncio async def test_download_all_partial(self, image_downloader, tmp_path): """Test downloading with some URLs missing.""" with patch.object( image_downloader, 'download_poster', new_callable=AsyncMock, return_value=True ), patch.object( image_downloader, 'download_logo', new_callable=AsyncMock ) as mock_logo, patch.object( image_downloader, 'download_fanart', new_callable=AsyncMock ) as mock_fanart: results = await image_downloader.download_all_media( tmp_path, poster_url="https://test.com/poster.jpg", logo_url=None, fanart_url=None ) assert results["poster"] is True assert results["logo"] is None assert results["fanart"] is None assert not mock_logo.called assert not mock_fanart.called @pytest.mark.asyncio async def test_download_all_with_failures(self, image_downloader, tmp_path): """Test downloading with some failures.""" with patch.object( image_downloader, 'download_poster', new_callable=AsyncMock, return_value=True ), patch.object( image_downloader, 'download_logo', new_callable=AsyncMock, side_effect=ImageDownloadError("Failed") ), patch.object( image_downloader, 'download_fanart', new_callable=AsyncMock, return_value=True ): results = await image_downloader.download_all_media( tmp_path, poster_url="https://test.com/poster.jpg", logo_url="https://test.com/logo.png", fanart_url="https://test.com/fanart.jpg" ) assert results["poster"] is True assert results["logo"] is False assert results["fanart"] is True class TestImageDownloaderRetryLogic: """Test retry logic.""" @pytest.mark.asyncio async def test_retry_on_failure( self, image_downloader, valid_image_bytes, tmp_path ): """Test retry logic on temporary failure.""" mock_session = AsyncMock() mock_session.closed = False # First two calls fail, third succeeds mock_response_fail = AsyncMock() mock_response_fail.status = 500 # Create mock RequestInfo for ClientResponseError mock_request_info = MagicMock() mock_request_info.real_url = "https://test.com/image.jpg" mock_response_fail.raise_for_status = MagicMock( side_effect=aiohttp.ClientResponseError( request_info=mock_request_info, history=(), status=500, message="Server Error" ) ) mock_response_success = AsyncMock() mock_response_success.status = 200 mock_response_success.read = AsyncMock(return_value=valid_image_bytes) # Setup context managers mock_cm_fail = MagicMock() mock_cm_fail.__aenter__ = AsyncMock(return_value=mock_response_fail) mock_cm_fail.__aexit__ = AsyncMock(return_value=None) mock_cm_success = MagicMock() mock_cm_success.__aenter__ = AsyncMock( return_value=mock_response_success ) mock_cm_success.__aexit__ = AsyncMock(return_value=None) mock_session.get = MagicMock( side_effect=[mock_cm_fail, mock_cm_fail, mock_cm_success] ) image_downloader.session = mock_session image_downloader.retry_delay = 0.1 # Speed up test output_path = tmp_path / "test.jpg" await image_downloader.download_image( "https://test.com/image.jpg", output_path ) # Should have retried twice then succeeded assert mock_session.get.call_count == 3 assert output_path.exists() @pytest.mark.asyncio async def test_max_retries_exceeded(self, image_downloader, tmp_path): """Test failure after max retries.""" mock_session = AsyncMock() mock_session.closed = False mock_response = AsyncMock() mock_response.status = 500 # Create mock RequestInfo for ClientResponseError 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) image_downloader.session = mock_session image_downloader.max_retries = 2 image_downloader.retry_delay = 0.1 output_path = tmp_path / "test.jpg" with pytest.raises(ImageDownloadError): await image_downloader.download_image( "https://test.com/image.jpg", output_path ) # Should have tried 2 times (max_retries=2 means 2 total attempts) assert mock_session.get.call_count == 2