Files
Aniworld/tests/unit/test_image_downloader.py
Lukas 4895e487c0 feat: Add NFO metadata infrastructure (Task 3 - partial)
- Created TMDB API client with async requests, caching, and retry logic
- Implemented NFO XML generator for Kodi/XBMC format
- Created image downloader for poster/logo/fanart with validation
- Added NFO service to orchestrate metadata creation
- Added NFO-related configuration settings
- Updated requirements.txt with aiohttp, lxml, pillow
- Created unit tests (need refinement due to implementation mismatch)

Components created:
- src/core/services/tmdb_client.py (270 lines)
- src/core/services/nfo_service.py (390 lines)
- src/core/utils/nfo_generator.py (180 lines)
- src/core/utils/image_downloader.py (296 lines)
- tests/unit/test_tmdb_client.py
- tests/unit/test_nfo_generator.py
- tests/unit/test_image_downloader.py

Note: Tests need to be updated to match actual implementation APIs.
Dependencies installed: aiohttp, lxml, pillow
2026-01-11 20:33:33 +01:00

412 lines
14 KiB
Python

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