"""Unit tests for the image fetcher service.""" from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from datetime import datetime import aiohttp import pytest from tenacity import stop_after_attempt from models.listing import RentListing, ListingSite, FurnishType from services.image_fetcher import dump_images_for_listing, MAX_CONCURRENT_DOWNLOADS def _make_listing(**kwargs) -> RentListing: # type: ignore[no-untyped-def] """Create a RentListing with sensible defaults for testing.""" defaults = dict( id=12345, price=2000.0, number_of_bedrooms=2, square_meters=None, agency="Test Agency", council_tax_band="C", longitude=0.0, latitude=0.0, price_history_json="[]", listing_site=ListingSite.RIGHTMOVE, last_seen=datetime.now(), photo_thumbnail=None, floorplan_image_paths=[], additional_info={ "property": { "visible": True, "floorplans": [ {"url": "https://media.rightmove.co.uk/imgs/floorplan_1.jpg"} ], } }, routing_info_json=None, furnish_type=FurnishType.FURNISHED, available_from=None, ) defaults.update(kwargs) return RentListing(**defaults) class TestDumpImagesForListing: """Tests for dump_images_for_listing function.""" async def test_downloads_floorplan_image(self, tmp_path: Path) -> None: """Test successful floorplan image download.""" listing = _make_listing() image_bytes = b"\x89PNG fake image data" mock_response = AsyncMock() mock_response.status = 200 mock_response.read = AsyncMock(return_value=image_bytes) mock_session = MagicMock(spec=aiohttp.ClientSession) mock_cm = AsyncMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_cm.__aexit__ = AsyncMock(return_value=False) mock_session.get = MagicMock(return_value=mock_cm) result = await dump_images_for_listing( listing, tmp_path, session=mock_session ) assert result is not None assert result.id == 12345 assert len(result.floorplan_image_paths) == 1 # Verify the image was written written_path = Path(result.floorplan_image_paths[0]) assert written_path.exists() assert written_path.read_bytes() == image_bytes async def test_skips_existing_images(self, tmp_path: Path) -> None: """Test that existing images are not re-downloaded.""" listing = _make_listing() # Pre-create the floorplan file floorplan_dir = tmp_path / str(listing.id) / "floorplans" floorplan_dir.mkdir(parents=True) existing_file = floorplan_dir / "floorplan_1.jpg" existing_file.write_bytes(b"existing image") mock_session = MagicMock(spec=aiohttp.ClientSession) result = await dump_images_for_listing( listing, tmp_path, session=mock_session ) # Should return None because the only floorplan was skipped (continue) assert result is None # Session.get should NOT have been called mock_session.get.assert_not_called() async def test_returns_none_on_404(self, tmp_path: Path) -> None: """Test that 404 responses return None (image not found).""" listing = _make_listing() mock_response = AsyncMock() mock_response.status = 404 mock_session = MagicMock(spec=aiohttp.ClientSession) mock_cm = AsyncMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_cm.__aexit__ = AsyncMock(return_value=False) mock_session.get = MagicMock(return_value=mock_cm) result = await dump_images_for_listing( listing, tmp_path, session=mock_session ) assert result is None async def test_raises_on_non_200_status(self, tmp_path: Path) -> None: """Test that non-200/404 status raises exception.""" listing = _make_listing() mock_response = AsyncMock() mock_response.status = 500 mock_session = MagicMock(spec=aiohttp.ClientSession) mock_cm = AsyncMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_cm.__aexit__ = AsyncMock(return_value=False) mock_session.get = MagicMock(return_value=mock_cm) with pytest.raises(Exception, match="HTTP 500"): # Disable tenacity retry for testing: stop after 1 attempt and reraise await dump_images_for_listing.retry_with( stop=stop_after_attempt(1), reraise=True, )(listing, tmp_path, session=mock_session) async def test_returns_none_when_no_floorplans(self, tmp_path: Path) -> None: """Test listing with no floorplans returns None.""" listing = _make_listing( additional_info={"property": {"visible": True, "floorplans": []}} ) mock_session = MagicMock(spec=aiohttp.ClientSession) result = await dump_images_for_listing( listing, tmp_path, session=mock_session ) assert result is None async def test_url_filename_extraction(self, tmp_path: Path) -> None: """Test that filenames are correctly extracted from URLs.""" listing = _make_listing( additional_info={ "property": { "visible": True, "floorplans": [ { "url": "https://media.rightmove.co.uk/dir/sub/my_floorplan.png" } ], } } ) image_bytes = b"fake png" mock_response = AsyncMock() mock_response.status = 200 mock_response.read = AsyncMock(return_value=image_bytes) mock_session = MagicMock(spec=aiohttp.ClientSession) mock_cm = AsyncMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_cm.__aexit__ = AsyncMock(return_value=False) mock_session.get = MagicMock(return_value=mock_cm) result = await dump_images_for_listing( listing, tmp_path, session=mock_session ) assert result is not None written_path = Path(result.floorplan_image_paths[0]) assert written_path.name == "my_floorplan.png" async def test_creates_session_when_none_provided(self, tmp_path: Path) -> None: """Test that a session is created and closed when none is provided.""" listing = _make_listing() image_bytes = b"fake image" mock_response = AsyncMock() mock_response.status = 200 mock_response.read = AsyncMock(return_value=image_bytes) mock_session_instance = MagicMock(spec=aiohttp.ClientSession) mock_cm = AsyncMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_cm.__aexit__ = AsyncMock(return_value=False) mock_session_instance.get = MagicMock(return_value=mock_cm) mock_session_instance.close = AsyncMock() with patch( "services.image_fetcher.aiohttp.ClientSession", return_value=mock_session_instance, ): result = await dump_images_for_listing(listing, tmp_path, session=None) assert result is not None mock_session_instance.close.assert_awaited_once() class TestImageFetcherConfig: """Tests for image fetcher configuration.""" def test_max_concurrent_downloads_constant(self) -> None: """Test that MAX_CONCURRENT_DOWNLOADS is defined and reasonable.""" assert MAX_CONCURRENT_DOWNLOADS > 0 assert MAX_CONCURRENT_DOWNLOADS <= 20