"""Unit tests for image downloader.""" import io from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from PIL import Image from src.core.utils.image_downloader import ( ImageDownloader, ImageDownloadError, ) @pytest.fixture def image_downloader(): """Create image downloader instance.""" return ImageDownloader() @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() mock.get = AsyncMock() 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): """Test validation of valid image.""" # Should not raise exception image_downloader._validate_image(valid_image_bytes) def test_validate_too_small(self, image_downloader): """Test validation rejects too-small file.""" tiny_data = b"tiny" with pytest.raises(ImageDownloadError, match="too small"): image_downloader._validate_image(tiny_data) def test_validate_invalid_image_data(self, image_downloader): """Test validation rejects invalid image data.""" invalid_data = b"x" * 2000 # Large enough but not an image with pytest.raises(ImageDownloadError, match="Cannot open"): image_downloader._validate_image(invalid_data) def test_validate_corrupted_image(self, image_downloader): """Test validation rejects corrupted image.""" # Create a corrupted JPEG-like file corrupted = b"\xFF\xD8\xFF\xE0" + b"corrupted_data" * 100 with pytest.raises(ImageDownloadError): image_downloader._validate_image(corrupted) 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_response = AsyncMock() mock_response.status = 200 mock_response.read = AsyncMock(return_value=valid_image_bytes) mock_session.get = AsyncMock(return_value=mock_response) 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" output_path.write_bytes(b"existing") mock_session = AsyncMock() 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"existing" # 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_response = AsyncMock() mock_response.status = 200 mock_response.read = AsyncMock(return_value=valid_image_bytes) mock_session.get = AsyncMock(return_value=mock_response) 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_response = AsyncMock() mock_response.status = 404 mock_response.raise_for_status = MagicMock(side_effect=Exception("Not Found")) mock_session.get = AsyncMock(return_value=mock_response) image_downloader.session = mock_session output_path = tmp_path / "test.jpg" with pytest.raises(ImageDownloadError): await image_downloader.download_image("https://test.com/missing.jpg", output_path) 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() # First two calls fail, third succeeds mock_response_fail = AsyncMock() mock_response_fail.status = 500 mock_response_fail.raise_for_status = MagicMock(side_effect=Exception("Server Error")) mock_response_success = AsyncMock() mock_response_success.status = 200 mock_response_success.read = AsyncMock(return_value=valid_image_bytes) mock_session.get = AsyncMock( side_effect=[mock_response_fail, mock_response_fail, mock_response_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_response = AsyncMock() mock_response.status = 500 mock_response.raise_for_status = MagicMock(side_effect=Exception("Server Error")) mock_session.get = AsyncMock(return_value=mock_response) 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 3 times (initial + 2 retries) assert mock_session.get.call_count == 3