diff --git a/backend/app/api/v1/endpoint_modules/resources/thumbnail.py b/backend/app/api/v1/endpoint_modules/resources/thumbnail.py index 1dac080..00d0575 100644 --- a/backend/app/api/v1/endpoint_modules/resources/thumbnail.py +++ b/backend/app/api/v1/endpoint_modules/resources/thumbnail.py @@ -13,6 +13,7 @@ from app.api.v1.utils import _get_thumbnail_asset_url, sanitize_for_json from app.services.cache_service import alias_redirect_cache_control_header from app.services.distribution_repository import fetch_distribution_context +from app.services.iiif_url import is_iiif_info_url from app.services.image_service import ImageService from app.services.static_map_service import StaticMapService from app.services.thumbnail_alias_service import is_thumbnail_hash, thumbnail_alias_service @@ -532,6 +533,7 @@ async def _get_resource_thumbnail_response( # URLs are processed server-side, so skip probe. if ( not image_service._is_manifest_url(source_url) + and not is_iiif_info_url(source_url) and not image_service._is_cog_url(source_url) and not image_service._is_pmtiles_url(source_url) and THUMBNAIL_REQUEST_PROBE_ENABLED @@ -606,7 +608,7 @@ async def _get_resource_thumbnail_response( state_detail="PMTiles thumbnail generation already queued", ) ) - elif image_service._is_manifest_url(source_url): + elif image_service._is_manifest_url(source_url) or is_iiif_info_url(source_url): image_service._queue_thumbnail_processing(source_url, id) else: standardized_url = image_service._standardize_iiif_url(source_url) @@ -743,10 +745,19 @@ async def get_resource_thumbnail_no_cache( ) return await _svg_icon_for_resource(resource_dict, variant=variant) - # Resolve manifests to actual image URLs when needed - if image_service._is_manifest_url(source_url): + # Resolve IIIF metadata to an actual image URL when needed. + if is_iiif_info_url(source_url): + resolved = await asyncio.to_thread(image_service.get_iiif_image_thumbnail, source_url) + if not resolved: + return _svg_placeholder( + title="Thumbnail unavailable", subtitle="Error resolving IIIF" + ) + fetch_url = resolved + elif image_service._is_manifest_url(source_url): # This may fetch the manifest once to resolve thumbnail URL - resolved = image_service.get_iiif_manifest_thumbnail(source_url) + resolved = await asyncio.to_thread( + image_service.get_iiif_manifest_thumbnail, source_url + ) if not resolved: return _svg_placeholder( title="Thumbnail unavailable", subtitle="Error resolving IIIF" diff --git a/backend/app/services/iiif_url.py b/backend/app/services/iiif_url.py new file mode 100644 index 0000000..67a8089 --- /dev/null +++ b/backend/app/services/iiif_url.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from urllib.parse import urlparse + + +def _path_basename(url: str | None) -> str: + if not url: + return "" + try: + path = urlparse(url).path.rstrip("/").lower() + except (TypeError, ValueError): + return "" + return path.rsplit("/", 1)[-1] + + +def is_iiif_manifest_url(url: str | None) -> bool: + """Return True for an actual IIIF Presentation manifest path. + + Matching the final path component avoids treating OGM package metadata such + as ``dataset_manifest.json`` as a IIIF Presentation manifest. + """ + basename = _path_basename(url) + if basename == "manifest" or (basename.startswith("manifest") and basename.endswith(".json")): + return True + return isinstance(url, str) and "/cgi/i/image/api/" in url.lower() + + +def is_iiif_info_url(url: str | None) -> bool: + """Return True for a IIIF Image API info document path.""" + return _path_basename(url) == "info.json" diff --git a/backend/app/services/image_service.py b/backend/app/services/image_service.py index 6ed591e..58fa8b8 100644 --- a/backend/app/services/image_service.py +++ b/backend/app/services/image_service.py @@ -16,6 +16,7 @@ DistributionContext, build_distribution_context, ) +from app.services.iiif_url import is_iiif_info_url, is_iiif_manifest_url from app.services.thumbnail_alias_service import thumbnail_alias_service from app.services.thumbnail_queue_service import acquire_thumbnail_queue_slot from app.services.thumbnail_state_service import ( @@ -313,6 +314,84 @@ def get_iiif_manifest_thumbnail(self, manifest_url: str) -> Optional[str]: return self._extract_thumbnail_from_manifest_json(manifest_json, manifest_url) + def _iiif_thumbnail_target_edge(self) -> int: + """Return the requested thumbnail edge encoded in IIIF_THUMBNAIL_BOX.""" + dimensions = [int(value) for value in re.findall(r"\d+", IIIF_THUMBNAIL_BOX)] + return max(dimensions, default=800) + + def _is_level_zero_iiif_info(self, info_json: Dict[str, Any]) -> bool: + """Return True when an Image API info document advertises Level 0.""" + profile = info_json.get("profile") + candidates: List[Any] + if isinstance(profile, list): + candidates = profile + else: + candidates = [profile] + + for candidate in candidates: + if isinstance(candidate, str) and "level0" in candidate.lower(): + return True + if isinstance(candidate, dict): + identifier = candidate.get("@id") or candidate.get("id") + if isinstance(identifier, str) and "level0" in identifier.lower(): + return True + return False + + def _extract_thumbnail_from_iiif_info_json( + self, + info_json: Dict[str, Any], + info_url: str, + ) -> Optional[str]: + """Resolve a thumbnail request supported by a IIIF Image API service.""" + service_id = info_json.get("@id") or info_json.get("id") + if not isinstance(service_id, str) or not service_id.strip(): + service_id = info_url[: -len("/info.json")] if is_iiif_info_url(info_url) else None + if not service_id: + return None + service_id = service_id.rstrip("/") + + if not self._is_level_zero_iiif_info(info_json): + return f"{service_id}{IIIF_THUMBNAIL_PATH}" + + advertised_sizes = [] + for size in info_json.get("sizes") or []: + if not isinstance(size, dict): + continue + width = size.get("width") + height = size.get("height") + if ( + isinstance(width, int) + and not isinstance(width, bool) + and width > 0 + and isinstance(height, int) + and not isinstance(height, bool) + and height > 0 + ): + advertised_sizes.append((width, height)) + + if advertised_sizes: + target_edge = self._iiif_thumbnail_target_edge() + width, _height = min( + advertised_sizes, + key=lambda size: (abs(max(size) - target_edge), max(size)), + ) + # Level 0 static services commonly materialize advertised sizes using + # the aspect-preserving width form (for example ``924,``), not an + # arbitrary bounded-box request such as ``!800,800``. + return f"{service_id}/full/{width},/0/default.jpg" + + # Level 0 guarantees only a small request set. If no sizes are advertised, + # request the full image rather than inventing an unsupported resize. + return f"{service_id}/full/full/0/default.jpg" + + def get_iiif_image_thumbnail(self, info_url: str) -> Optional[str]: + """Fetch Image API info.json and select a supported thumbnail rendition.""" + info_json = self._get_manifest(info_url) + if not info_json: + self.logger.warning(f"Could not fetch IIIF image info {info_url}") + return None + return self._extract_thumbnail_from_iiif_info_json(info_json, info_url) + def _standardize_iiif_url(self, url: str) -> str: """ Standardize IIIF image URLs to ensure consistent size. @@ -411,6 +490,28 @@ def thumbnail_image_hash_for_source_sync( return hashlib.sha256((COG_THUMBNAIL_PREFIX + source_url).encode()).hexdigest() if self._is_pmtiles_url(source_url): return hashlib.sha256((PMTILES_THUMBNAIL_PREFIX + source_url).encode()).hexdigest() + if self._is_iiif_info_url(source_url): + info_cache_key = f"manifest:{source_url}" + cached_info_data = self.cache.get(info_cache_key) + if cached_info_data: + info_json = json.loads(cached_info_data) + resolved_url = self._extract_thumbnail_from_iiif_info_json( + info_json, source_url + ) + if resolved_url: + return hashlib.sha256( + (REMOTE_THUMBNAIL_PREFIX + resolved_url).encode() + ).hexdigest() + + if resolve_manifest: + from app.tasks.worker import _resolve_image_url + + resolved_url = _resolve_image_url(source_url) + if resolved_url and resolved_url != source_url: + return hashlib.sha256( + (REMOTE_THUMBNAIL_PREFIX + resolved_url).encode() + ).hexdigest() + return None if self._is_manifest_url(source_url): manifest_cache_key = f"manifest:{source_url}" cached_manifest_data = self.cache.get(manifest_cache_key) @@ -720,7 +821,12 @@ def _get_thumbnail_source_url( f"https://cdm16022.contentdm.oclc.org/iiif/2/{collection_item}" ) - # For non-ContentDM IIIF URLs, use standard format + # Preserve Image API info documents for the worker. Level 0 services + # must be read before choosing one of their advertised static sizes. + if self._is_iiif_info_url(iiif_url): + return iiif_url + + # For non-ContentDM IIIF URLs, use standard format. return self._standardize_iiif_url(iiif_url) # Check for IIIF Manifest - only extract URL, don't fetch manifest @@ -729,15 +835,11 @@ def _get_thumbnail_source_url( "http://iiif.io/api/presentation#manifest", references=references ) or self._first_url("https://iiif.io/api/presentation#manifest", references=references) - # If not found, scan values for common manifest endings + # If not found, scan values for actual manifest path components. This + # intentionally excludes package metadata such as dataset_manifest.json. if not manifest_url: for value in self._all_reference_urls(references=references): - if ( - value.endswith( - ("/iiif3/manifest", "/iiif/manifest", "/manifest", "manifest.json") - ) - or "/manifest" in value - ): + if is_iiif_manifest_url(value): manifest_url = value break @@ -762,10 +864,8 @@ def _get_thumbnail_source_url( ) return image_url - # For other manifests, queue background resolution and return manifest URL - # The Celery worker will resolve the manifest and extract the image URL - self.logger.info(f"🚀 Queueing manifest resolution for {manifest_url}") - self._queue_manifest_processing(manifest_url) + # For other manifests, return the source without side effects. The + # thumbnail endpoint owns queueing so each request creates at most one job. return manifest_url # Use curated b1g_image_ss only after exhausting IIIF-based options. @@ -861,20 +961,11 @@ def _is_pmtiles_url(self, url: str) -> bool: def _is_manifest_url(self, url: str) -> bool: """Check if URL looks like a IIIF manifest URL.""" - if not url: - return False - url_lower = url.lower() - # Check for common IIIF manifest patterns - return ( - url.endswith(("/iiif3/manifest", "/iiif/manifest", "/manifest", "manifest.json")) - or "/manifest" in url - or ( - ".json" in url - and ("iiif" in url_lower or "/object/" in url or "/collection/" in url) - ) - or ("/api/" in url and ("iiif" in url_lower or "image" in url_lower)) - or ("/cgi/i/image/api/" in url_lower) # U of Michigan pattern - ) + return is_iiif_manifest_url(url) + + def _is_iiif_info_url(self, url: str) -> bool: + """Check if URL points to an Image API info document.""" + return is_iiif_info_url(url) def _first_url(self, uri: str, references: Optional[Dict[str, Any]] = None) -> Optional[str]: # Prefer distribution context if available and no explicit references provided @@ -971,21 +1062,6 @@ def _queue_thumbnail_processing(self, thumbnail_url: str, doc_id: str) -> None: self.logger.error(f"Failed to queue thumbnail processing for {doc_id}: {e}") # Don't raise - this is a background operation that shouldn't fail the main request - def _queue_manifest_processing(self, manifest_url: str) -> None: - """ - Queue manifest processing in the background without blocking. - This method is fire-and-forget. - """ - try: - from app.tasks.worker import fetch_and_cache_image - - task = fetch_and_cache_image.delay(manifest_url) - self.logger.info(f"Manifest resolution queued: {task.id}") - - except Exception as e: - self.logger.error(f"Failed to queue manifest processing for {manifest_url}: {e}") - # Don't raise - this is a background operation that shouldn't fail the main request - async def get_cached_image(self, image_hash: str) -> Optional[bytes]: """Retrieve a cached image by its hash.""" image_key = f"image:{image_hash}" diff --git a/backend/app/services/thumbnail_state_service.py b/backend/app/services/thumbnail_state_service.py index 6bcb347..7eb8e22 100644 --- a/backend/app/services/thumbnail_state_service.py +++ b/backend/app/services/thumbnail_state_service.py @@ -12,6 +12,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from app.services.distribution_repository import async_session_factory +from app.services.iiif_url import is_iiif_manifest_url from app.services.thumbnail_alias_service import thumbnail_alias_service from db.models import resource_thumbnail_state from db.session import sync_engine as _sync_engine @@ -46,11 +47,7 @@ def infer_source_type(source_url: str | None) -> str | None: or "display_raster" in lowered ): return "cog" - if ( - source_url.endswith(("/iiif3/manifest", "/iiif/manifest", "/manifest", "manifest.json")) - or "/manifest" in source_url - or (".json" in source_url and ("iiif" in lowered or "/object/" in lowered)) - ): + if is_iiif_manifest_url(source_url): return "manifest" return "remote" diff --git a/backend/app/tasks/worker.py b/backend/app/tasks/worker.py index f33f6ae..a8f6a44 100644 --- a/backend/app/tasks/worker.py +++ b/backend/app/tasks/worker.py @@ -456,16 +456,9 @@ def fetch_and_cache_image(self, url: str, doc_id: Optional[str] = None) -> bool: def _looks_like_manifest_url(url: str) -> bool: """Heuristic to detect IIIF manifest URLs by path patterns.""" - if not url: - return False - lowered = url.lower() - return ( - url.endswith(("/iiif3/manifest", "/iiif/manifest", "/manifest", "manifest.json")) - or "/manifest" in url - or (".json" in url and ("iiif" in lowered or "/object/" in url or "/collection/" in url)) - or ("/api/" in url and ("iiif" in lowered or "image" in lowered)) - or "/cgi/i/image/api/" in lowered # U of Michigan pattern - ) + from app.services.iiif_url import is_iiif_manifest_url + + return is_iiif_manifest_url(url) def _validate_image_content( @@ -1158,8 +1151,18 @@ def generate_pmtiles_thumbnail(self, pmtiles_url: str, doc_id: Optional[str] = N def _resolve_image_url(url: str) -> str: - """Resolve the URL to an actual image URL if given a manifest; otherwise return the original.""" + """Resolve IIIF metadata URLs to supported image renditions.""" try: + from app.services.iiif_url import is_iiif_info_url + + if is_iiif_info_url(url): + from app.services.image_service import ImageService + + service = ImageService({}) + image_url = service.get_iiif_image_thumbnail(url) + if image_url: + return image_url + # Only run manifest resolution when it looks like a manifest URL if _looks_like_manifest_url(url): from app.services.image_service import ImageService diff --git a/backend/scripts/clear_thumbnail_cache.py b/backend/scripts/clear_thumbnail_cache.py index 3f35662..a44c9df 100644 --- a/backend/scripts/clear_thumbnail_cache.py +++ b/backend/scripts/clear_thumbnail_cache.py @@ -12,8 +12,6 @@ """ import asyncio -import hashlib -import json import logging import os import sys @@ -38,41 +36,11 @@ def _compute_thumbnail_image_hash(image_service, source_url: str) -> str | None: Compute the Redis cache key hash for any thumbnail source URL. Mirrors the logic in resources/thumbnail.py and worker.py. """ - from app.tasks.worker import ( - _cog_thumbnail_image_hash, - _pmtiles_thumbnail_image_hash, - _resolve_image_url, + return image_service.thumbnail_image_hash_for_source_sync( + source_url, + resolve_manifest=True, ) - if image_service._is_cog_url(source_url): - return _cog_thumbnail_image_hash(source_url) - if image_service._is_pmtiles_url(source_url): - return _pmtiles_thumbnail_image_hash(source_url) - if image_service._is_manifest_url(source_url): - # Try manifest cache first (no network) - manifest_cache_key = f"manifest:{source_url}" - try: - cached = image_service.cache.get(manifest_cache_key) - if cached: - manifest_json = json.loads(cached) - resolved = image_service._extract_thumbnail_from_manifest_json( - manifest_json, source_url - ) - if resolved: - resolved = image_service._standardize_iiif_url(resolved) - return hashlib.sha256(resolved.encode()).hexdigest() - except Exception as e: - logger.debug(f"Manifest cache read failed: {e}") - # Fallback: resolve via network (same as worker) - resolved_url = _resolve_image_url(source_url) - if resolved_url != source_url: - return hashlib.sha256(resolved_url.encode()).hexdigest() - # Could not resolve manifest - return None - # Direct image URL (b1g_image_ss, schema.org thumbnail, IIIF, etc.) - standardized = image_service._standardize_iiif_url(source_url) - return hashlib.sha256(standardized.encode()).hexdigest() - async def clear_thumbnail_for_resource(resource_id: str) -> bool: """Clear thumbnail cache for one resource. Returns True if keys were deleted.""" diff --git a/backend/tests/api/v1/test_resource_thumbnail_endpoints.py b/backend/tests/api/v1/test_resource_thumbnail_endpoints.py index c41b533..2d3e7bc 100644 --- a/backend/tests/api/v1/test_resource_thumbnail_endpoints.py +++ b/backend/tests/api/v1/test_resource_thumbnail_endpoints.py @@ -755,3 +755,88 @@ def test_no_cache_remote_image_resizes_large_jpeg(self, mock_fetch_dist, mock_se assert image.format == "JPEG" assert max(image.size) <= 512 assert len(resp.content) < len(large_jpeg) + + +class TestResourceThumbnailIIIFInfoFlow: + """Regression coverage for capability-aware IIIF Image API sources.""" + + @patch("app.api.v1.endpoint_modules.resources.thumbnail.async_session") + @patch("app.api.v1.endpoint_modules.resources.thumbnail.fetch_distribution_context") + def test_info_document_is_queued_raw_without_image_probe( + self, mock_fetch_dist, mock_session, client + ): + mock_session_instance = AsyncMock() + mock_session.return_value.__aenter__.return_value = mock_session_instance + + resource_id = "unr-74479f22-0e6b-4c13-b376-0195a7461525" + info_url = f"https://example.com/iiif/{resource_id}/info.json" + mock_row = _resource_row( + resource_id, + f'{{"http://iiif.io/api/image": "{info_url}"}}', + ) + mock_result = MagicMock() + mock_result.fetchone.return_value = mock_row + mock_session_instance.execute = AsyncMock(return_value=mock_result) + mock_fetch_dist.return_value = MagicMock(by_uri={}, legacy_reference_payload={}) + + with ( + patch("app.api.v1.endpoint_modules.resources.thumbnail.ImageService") as mock_svc_cls, + patch( + "app.api.v1.endpoint_modules.resources.thumbnail.THUMBNAIL_REQUEST_PROBE_ENABLED", + True, + ), + patch( + "app.api.v1.endpoint_modules.resources.thumbnail._probe_thumbnail_url", + new=AsyncMock(return_value=False), + ) as mock_probe, + ): + svc = MagicMock() + svc.resolve_thumbnail_source_url.return_value = info_url + svc.thumbnail_image_hash_for_source_sync.return_value = None + svc._is_cog_url.return_value = False + svc._is_pmtiles_url.return_value = False + svc._is_manifest_url.return_value = False + mock_svc_cls.return_value = svc + + response = client.get(f"/resources/{resource_id}/thumbnail") + + assert response.status_code == 200 + assert response.headers["content-type"] == "image/svg+xml" + mock_probe.assert_not_awaited() + svc._queue_thumbnail_processing.assert_called_once_with(info_url, resource_id) + + @patch("app.api.v1.endpoint_modules.resources.thumbnail.async_session") + @patch("app.api.v1.endpoint_modules.resources.thumbnail.fetch_distribution_context") + def test_no_cache_info_document_uses_resolved_level_zero_size( + self, mock_fetch_dist, mock_session, client + ): + mock_session_instance = AsyncMock() + mock_session.return_value.__aenter__.return_value = mock_session_instance + + resource_id = "unr-74479f22-0e6b-4c13-b376-0195a7461525" + info_url = f"https://example.com/iiif/{resource_id}/info.json" + image_url = f"https://example.com/iiif/{resource_id}/full/924,/0/default.jpg" + mock_row = _resource_row( + resource_id, + f'{{"http://iiif.io/api/image": "{info_url}"}}', + ) + mock_result = MagicMock() + mock_result.fetchone.return_value = mock_row + mock_session_instance.execute = AsyncMock(return_value=mock_result) + mock_fetch_dist.return_value = MagicMock(by_uri={}, legacy_reference_payload={}) + + with patch("app.api.v1.endpoint_modules.resources.thumbnail.ImageService") as mock_svc_cls: + svc = MagicMock() + svc._get_thumbnail_source_url.return_value = info_url + svc._is_cog_url.return_value = False + svc._is_pmtiles_url.return_value = False + svc.get_iiif_image_thumbnail.return_value = image_url + svc.download_image = AsyncMock(return_value=_valid_png_bytes()) + mock_svc_cls.return_value = svc + + response = client.get(f"/resources/{resource_id}/thumbnail/no-cache") + + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + svc.get_iiif_image_thumbnail.assert_called_once_with(info_url) + svc.download_image.assert_awaited_once_with(image_url) diff --git a/backend/tests/services/test_iiif_url.py b/backend/tests/services/test_iiif_url.py new file mode 100644 index 0000000..b50be42 --- /dev/null +++ b/backend/tests/services/test_iiif_url.py @@ -0,0 +1,21 @@ +from app.services.iiif_url import is_iiif_info_url, is_iiif_manifest_url + + +def test_dataset_manifest_is_not_a_iiif_manifest(): + assert not is_iiif_manifest_url("https://example.com/uploads/item/dataset_manifest.json") + + +def test_manifest_final_path_component_is_detected(): + assert is_iiif_manifest_url("https://example.com/iiif/item/manifest.json") + assert is_iiif_manifest_url("https://example.com/iiif/item/manifest2.json") + assert is_iiif_manifest_url("https://example.com/concern/scanned_maps/item/manifest") + + +def test_michigan_image_api_manifest_is_detected(): + assert is_iiif_manifest_url("https://quod.lib.umich.edu/cgi/i/image/api/search/collection:id") + + +def test_info_document_is_not_a_manifest(): + url = "https://example.com/iiif/item/info.json" + assert is_iiif_info_url(url) + assert not is_iiif_manifest_url(url) diff --git a/backend/tests/services/test_unr_thumbnail_resolution.py b/backend/tests/services/test_unr_thumbnail_resolution.py new file mode 100644 index 0000000..10323cd --- /dev/null +++ b/backend/tests/services/test_unr_thumbnail_resolution.py @@ -0,0 +1,86 @@ +import hashlib +import json +from unittest.mock import MagicMock, patch + +from app.services.image_service import REMOTE_THUMBNAIL_PREFIX, ImageService + +RESOURCE_ID = "unr-74479f22-0e6b-4c13-b376-0195a7461525" +INFO_URL = f"https://s3.amazonaws.com/ogm-metadata-studio/uploads/{RESOURCE_ID}/iiif/info.json" +THUMBNAIL_URL = ( + f"https://s3.amazonaws.com/ogm-metadata-studio/uploads/{RESOURCE_ID}/thumbnail/thumbnail.jpg" +) +DATASET_MANIFEST_URL = ( + f"https://s3.amazonaws.com/ogm-metadata-studio/uploads/{RESOURCE_ID}/dataset_manifest.json" +) +LEVEL_ZERO_INFO = { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": INFO_URL.removesuffix("/info.json"), + "protocol": "http://iiif.io/api/image", + "width": 7392, + "height": 6270, + "profile": ["http://iiif.io/api/image/2/level0.json"], + "sizes": [ + {"width": 7392, "height": 6270}, + {"width": 3696, "height": 3135}, + {"width": 1848, "height": 1568}, + {"width": 924, "height": 784}, + ], +} + + +def test_explicit_iiif_info_source_is_preserved_for_capability_resolution(): + service = ImageService({"id": RESOURCE_ID}) + + assert service._get_thumbnail_source_url({"http://iiif.io/api/image": INFO_URL}) == INFO_URL + + +def test_dataset_manifest_is_not_selected_ahead_of_published_thumbnail(): + service = ImageService({"id": RESOURCE_ID}) + references = { + "https://opengeometadata.org/reference/dataset-manifest": DATASET_MANIFEST_URL, + "http://schema.org/thumbnailUrl": THUMBNAIL_URL, + } + + assert service._get_thumbnail_source_url(references) == THUMBNAIL_URL + assert service._is_manifest_url(DATASET_MANIFEST_URL) is False + + +def test_level_zero_info_selects_closest_advertised_static_size(): + service = ImageService({"id": RESOURCE_ID}) + + assert service._extract_thumbnail_from_iiif_info_json(LEVEL_ZERO_INFO, INFO_URL) == ( + INFO_URL.removesuffix("/info.json") + "/full/924,/0/default.jpg" + ) + + +def test_level_one_info_keeps_bounded_box_request(): + service = ImageService({"id": RESOURCE_ID}) + info = { + "@id": INFO_URL.removesuffix("/info.json"), + "profile": ["http://iiif.io/api/image/2/level1.json"], + } + + assert service._extract_thumbnail_from_iiif_info_json(info, INFO_URL) == ( + INFO_URL.removesuffix("/info.json") + "/full/!800,800/0/default.jpg" + ) + + +def test_level_zero_hash_uses_resolved_advertised_size(): + service = ImageService({"id": RESOURCE_ID}) + service.cache = MagicMock() + service.cache.get.return_value = json.dumps(LEVEL_ZERO_INFO) + resolved_url = INFO_URL.removesuffix("/info.json") + "/full/924,/0/default.jpg" + + assert ( + service.thumbnail_image_hash_for_source_sync(INFO_URL) + == hashlib.sha256((REMOTE_THUMBNAIL_PREFIX + resolved_url).encode()).hexdigest() + ) + + +def test_get_iiif_image_thumbnail_reads_info_document(): + service = ImageService({"id": RESOURCE_ID}) + with patch.object(service, "_get_manifest", return_value=LEVEL_ZERO_INFO) as mock_get: + assert service.get_iiif_image_thumbnail(INFO_URL) == ( + INFO_URL.removesuffix("/info.json") + "/full/924,/0/default.jpg" + ) + mock_get.assert_called_once_with(INFO_URL) diff --git a/backend/tests/tasks/test_worker_fetch_and_cache_image.py b/backend/tests/tasks/test_worker_fetch_and_cache_image.py index c88a2dc..48a7eb0 100644 --- a/backend/tests/tasks/test_worker_fetch_and_cache_image.py +++ b/backend/tests/tasks/test_worker_fetch_and_cache_image.py @@ -4,7 +4,30 @@ from PIL import Image -from app.tasks.worker import _remote_thumbnail_image_hash, fetch_and_cache_image +from app.tasks.worker import ( + _looks_like_manifest_url, + _remote_thumbnail_image_hash, + _resolve_image_url, + fetch_and_cache_image, +) + +UNR_RESOURCE_ID = "unr-74479f22-0e6b-4c13-b376-0195a7461525" +UNR_INFO_URL = ( + f"https://s3.amazonaws.com/ogm-metadata-studio/uploads/{UNR_RESOURCE_ID}/iiif/info.json" +) +UNR_IMAGE_URL = UNR_INFO_URL.removesuffix("/info.json") + "/full/924,/0/default.jpg" +UNR_LEVEL_ZERO_INFO = { + "@id": UNR_INFO_URL.removesuffix("/info.json"), + "profile": ["http://iiif.io/api/image/2/level0.json"], + "width": 7392, + "height": 6270, + "sizes": [ + {"width": 7392, "height": 6270}, + {"width": 3696, "height": 3135}, + {"width": 1848, "height": 1568}, + {"width": 924, "height": 784}, + ], +} def _valid_png_bytes() -> bytes: @@ -126,3 +149,65 @@ def test_fetch_and_cache_image_resizes_large_remote_image_before_caching(): assert max(cached_image.size) <= 512 assert cached_image.format == "JPEG" assert len(cached_bytes) < len(response.content) + + +def test_worker_does_not_treat_dataset_manifest_as_iiif_manifest(): + assert not _looks_like_manifest_url( + "https://example.com/uploads/unr-item/dataset_manifest.json" + ) + + +def test_worker_resolves_iiif_info_before_fetching_image(): + info_url = "https://example.com/iiif/item/info.json" + image_url = "https://example.com/iiif/item/full/924,/0/default.jpg" + + with patch( + "app.services.image_service.ImageService.get_iiif_image_thumbnail", + return_value=image_url, + ) as mock_resolve: + assert _resolve_image_url(info_url) == image_url + + mock_resolve.assert_called_once_with(info_url) + + +def test_unr_level_zero_info_worker_fetches_and_caches_real_rendition(): + """Exercise the complete raw info.json -> rendition -> image-cache worker path.""" + response = MagicMock() + response.status_code = 200 + response.content = _valid_png_bytes() + response.headers = {"Content-Type": "image/png"} + response.raise_for_status = MagicMock() + + with ( + patch( + "app.services.image_service.ImageService._get_manifest", + return_value=UNR_LEVEL_ZERO_INFO, + ), + patch("app.tasks.worker.redis_client") as mock_redis, + patch("app.tasks.worker.requests.get", return_value=response) as mock_get, + patch( + "app.tasks.worker.provider_request_slot", + side_effect=lambda *args, **kwargs: nullcontext(MagicMock(waited_seconds=0.0)), + ), + patch("app.tasks.worker.safe_record_thumbnail_state_sync") as mock_state, + patch("app.tasks.worker.release_thumbnail_queue_slot"), + ): + mock_redis.exists.return_value = False + + assert fetch_and_cache_image(UNR_INFO_URL, UNR_RESOURCE_ID) is True + + mock_get.assert_called_once_with( + UNR_IMAGE_URL, + timeout=30, + headers={"User-Agent": "BTAA-Geospatial-Data-API/1.0 (https://geo.btaa.org/)"}, + ) + image_key, cached_bytes = _cached_image_write(mock_redis) + resolved_hash = _remote_thumbnail_image_hash(UNR_IMAGE_URL) + assert image_key == f"image:{resolved_hash}" + assert Image.open(io.BytesIO(cached_bytes)).format == "PNG" + + success_payload = mock_state.call_args.args[0] + assert success_payload.state == "success" + assert success_payload.resource_id == UNR_RESOURCE_ID + assert success_payload.source_url == UNR_INFO_URL + assert success_payload.source_hash == resolved_hash