"""Tests for GIBSClient using respx mocks — no real HTTP calls.""" from io import BytesIO import httpx import pytest import respx from PIL import Image from mcgibs.client import GIBSClient, _LRUCache from mcgibs.constants import WMTS_TILE_URL @respx.mock async def test_client_initialize(capabilities_xml): """Loading capabilities populates layer_index with all three sample layers.""" respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( return_value=httpx.Response(200, text=capabilities_xml) ) client = GIBSClient() await client.initialize() assert len(client.layer_index) == 3 assert "MODIS_Terra_CorrectedReflectance_TrueColor" in client.layer_index assert "AMSR2_Sea_Ice_Concentration_12km_Monthly" in client.layer_index assert "AIRS_L3_Surface_Air_Temperature_Daily_Day" in client.layer_index # Spot-check a parsed layer modis = client.layer_index["MODIS_Terra_CorrectedReflectance_TrueColor"] assert modis.title == "Corrected Reflectance (True Color, Terra/MODIS)" assert "image/jpeg" in modis.formats assert modis.time is not None assert modis.time.start == "2000-02-24" assert modis.has_colormap is False await client.close() @respx.mock async def test_client_fetch_layer_metadata(capabilities_xml, layer_metadata_json): """Fetching layer metadata enriches the layer with instrument/platform fields.""" respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( return_value=httpx.Response(200, text=capabilities_xml) ) layer_id = "MODIS_Terra_CorrectedReflectance_TrueColor" respx.get(url__regex=rf".*layer-metadata.*/{layer_id}\.json").mock( return_value=httpx.Response(200, text=layer_metadata_json) ) client = GIBSClient() await client.initialize() data = await client.fetch_layer_metadata(layer_id) assert data["instrument"] == "MODIS" assert data["platform"] == "Terra" assert data["ongoing"] is True # Verify the layer_index entry was enriched layer = client.get_layer(layer_id) assert layer is not None assert layer.instrument == "MODIS" assert layer.platform == "Terra" assert layer.measurement == "Corrected Reflectance" assert layer.day_night == "Day" # Second call should use cache data2 = await client.fetch_layer_metadata(layer_id) assert data2 is data await client.close() @respx.mock async def test_client_fetch_colormap(capabilities_xml, colormap_xml): """Parsing colormap XML produces entries and legend data.""" respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( return_value=httpx.Response(200, text=capabilities_xml) ) respx.get(url__regex=r".*colormaps/v1\.3/.*\.xml").mock( return_value=httpx.Response(200, text=colormap_xml) ) client = GIBSClient() await client.initialize() colormap_set = await client.fetch_colormap("AIRS_L3_Surface_Air_Temperature_Daily_Day") assert colormap_set is not None assert len(colormap_set.maps) == 2 data_map = colormap_set.data_map assert data_map is not None assert data_map.title == "Surface Air Temperature" assert data_map.units == "K" assert len(data_map.entries) == 14 assert data_map.legend_type == "continuous" # Verify the nodata map nodata_map = colormap_set.maps[1] assert any(e.nodata for e in nodata_map.entries) # Second fetch should be cached cached = await client.fetch_colormap("AIRS_L3_Surface_Air_Temperature_Daily_Day") assert cached is colormap_set await client.close() @respx.mock async def test_client_get_wms_image(capabilities_xml): """WMS GetMap returns raw image bytes when content-type is image/*.""" respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( return_value=httpx.Response(200, text=capabilities_xml) ) # Build a tiny valid JPEG to return as the mock response buf = BytesIO() Image.new("RGB", (10, 10), "blue").save(buf, format="JPEG") fake_jpeg = buf.getvalue() respx.get(url__regex=r".*wms\.cgi.*").mock( return_value=httpx.Response( 200, content=fake_jpeg, headers={"content-type": "image/jpeg"}, ) ) client = GIBSClient() await client.initialize() from mcgibs.models import BBox bbox = BBox(west=-120.0, south=30.0, east=-110.0, north=40.0) result = await client.get_wms_image( "MODIS_Terra_CorrectedReflectance_TrueColor", "2025-06-01", bbox, ) assert isinstance(result, bytes) assert len(result) > 0 # Verify the returned bytes are valid JPEG (starts with FFD8) assert result[:2] == b"\xff\xd8" await client.close() def test_client_build_tile_url(): """build_tile_url produces the expected WMTS REST URL format.""" client = GIBSClient() url = client.build_tile_url( layer_id="MODIS_Terra_CorrectedReflectance_TrueColor", date="2025-06-01", zoom=3, row=2, col=4, tile_matrix_set="250m", ext="jpg", epsg="4326", ) assert "MODIS_Terra_CorrectedReflectance_TrueColor" in url assert "2025-06-01" in url assert "/250m/" in url assert "/3/" in url assert "/2/" in url assert "/4.jpg" in url assert "epsg4326" in url # Verify it matches the constant template expected = WMTS_TILE_URL.format( epsg="4326", layer_id="MODIS_Terra_CorrectedReflectance_TrueColor", date="2025-06-01", tile_matrix_set="250m", z=3, row=2, col=4, ext="jpg", ) assert url == expected # --------------------------------------------------------------------------- # _LRUCache (Hamilton fix: bounded cache eviction) # --------------------------------------------------------------------------- def test_lru_cache_basic(): """Cache stores and retrieves values.""" cache = _LRUCache(maxsize=3) cache.put("a", 1) cache.put("b", 2) from mcgibs.client import _SENTINEL assert cache.get_cached("a") == 1 assert cache.get_cached("b") == 2 assert cache.get_cached("missing") is _SENTINEL def test_lru_cache_eviction(): """Oldest entry is evicted when maxsize is exceeded.""" cache = _LRUCache(maxsize=2) cache.put("a", 1) cache.put("b", 2) cache.put("c", 3) # should evict "a" from mcgibs.client import _SENTINEL assert cache.get_cached("a") is _SENTINEL assert cache.get_cached("b") == 2 assert cache.get_cached("c") == 3 def test_lru_cache_access_refreshes(): """Accessing an entry moves it to the end, so the other is evicted first.""" cache = _LRUCache(maxsize=2) cache.put("a", 1) cache.put("b", 2) cache.get_cached("a") # refresh "a" cache.put("c", 3) # should evict "b" (oldest unused) from mcgibs.client import _SENTINEL assert cache.get_cached("a") == 1 # still present assert cache.get_cached("b") is _SENTINEL # evicted assert cache.get_cached("c") == 3 def test_lru_cache_update_existing(): """Putting an existing key updates the value without increasing size.""" cache = _LRUCache(maxsize=2) cache.put("a", 1) cache.put("b", 2) cache.put("a", 10) # update, not insert assert len(cache) == 2 assert cache.get_cached("a") == 10 # --------------------------------------------------------------------------- # WMS non-image response detection (H5 Hamilton fix) # --------------------------------------------------------------------------- @respx.mock async def test_wms_rejects_non_image_response(capabilities_xml): """WMS should raise RuntimeError when server returns XML instead of image.""" respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( return_value=httpx.Response(200, text=capabilities_xml) ) respx.get(url__regex=r".*wms\.cgi.*").mock( return_value=httpx.Response( 200, text='Layer not found', headers={"content-type": "application/xml"}, ) ) client = GIBSClient() await client.initialize() from mcgibs.models import BBox bbox = BBox(west=-120.0, south=30.0, east=-110.0, north=40.0) with pytest.raises(RuntimeError, match="non-image content-type"): await client.get_wms_image("FakeLayer", "2025-01-01", bbox) await client.close() # --------------------------------------------------------------------------- # Image dimension clamping (H6 Hamilton fix) # --------------------------------------------------------------------------- @respx.mock async def test_wms_clamps_oversized_dimensions(capabilities_xml): """Oversized dimensions should be clamped to MAX_IMAGE_DIMENSION.""" respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( return_value=httpx.Response(200, text=capabilities_xml) ) buf = BytesIO() Image.new("RGB", (10, 10), "red").save(buf, format="JPEG") route = respx.get(url__regex=r".*wms\.cgi.*").mock( return_value=httpx.Response( 200, content=buf.getvalue(), headers={"content-type": "image/jpeg"} ) ) client = GIBSClient() await client.initialize() from mcgibs.models import BBox bbox = BBox(west=-120.0, south=30.0, east=-110.0, north=40.0) await client.get_wms_image("Test", "2025-01-01", bbox, width=99999, height=99999) # Verify the request was made with clamped dimensions request = route.calls.last.request assert "WIDTH=4096" in str(request.url) assert "HEIGHT=4096" in str(request.url) await client.close() # --------------------------------------------------------------------------- # Client retry logic (Hamilton fix: exponential backoff) # --------------------------------------------------------------------------- @respx.mock async def test_client_retries_on_capabilities_failure(capabilities_xml): """Client retries capabilities loading on HTTP errors, then succeeds.""" call_count = 0 def capabilities_handler(request): nonlocal call_count call_count += 1 if call_count < 3: return httpx.Response(503, text="Service Unavailable") return httpx.Response(200, text=capabilities_xml) respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(side_effect=capabilities_handler) # Patch retry delays to avoid slow tests import mcgibs.client as client_mod original_delays = client_mod._INIT_RETRY_DELAYS client_mod._INIT_RETRY_DELAYS = [0.01, 0.01, 0.01] try: client = GIBSClient() await client.initialize() assert len(client.layer_index) == 3 assert call_count == 3 await client.close() finally: client_mod._INIT_RETRY_DELAYS = original_delays @respx.mock async def test_client_raises_after_max_retries(): """Client raises RuntimeError after exhausting all retry attempts.""" respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( return_value=httpx.Response(503, text="Service Unavailable") ) import mcgibs.client as client_mod original_delays = client_mod._INIT_RETRY_DELAYS client_mod._INIT_RETRY_DELAYS = [0.01, 0.01, 0.01] try: client = GIBSClient() with pytest.raises(RuntimeError, match="capabilities unavailable"): await client.initialize() await client.close() finally: client_mod._INIT_RETRY_DELAYS = original_delays # --------------------------------------------------------------------------- # Client geocode caching (M7 Hamilton fix) # --------------------------------------------------------------------------- @respx.mock async def test_client_caches_successful_geocode(capabilities_xml): """Successful geocode results are cached, preventing duplicate HTTP calls.""" respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( return_value=httpx.Response(200, text=capabilities_xml) ) route = respx.get(url__regex=r".*nominatim.*").mock( return_value=httpx.Response( 200, json=[ { "display_name": "Tokyo, Japan", "lat": "35.6762", "lon": "139.6503", "boundingbox": ["35.5191", "35.8170", "139.5601", "139.9200"], "osm_type": "relation", "importance": 0.82, } ], ), ) client = GIBSClient() await client.initialize() result1 = await client.resolve_place("Tokyo") result2 = await client.resolve_place("Tokyo") assert result1 is not None assert result2 is result1 # exact same object from cache assert route.call_count == 1 # only one HTTP call await client.close() # --------------------------------------------------------------------------- # Legend SVG → PNG rewriting (live API compatibility fix) # --------------------------------------------------------------------------- @respx.mock async def test_legend_rewrites_svg_to_png(capabilities_xml): """Legend URLs ending in .svg should be rewritten to .png.""" # Modify the capabilities XML so AMSR2's legend URL ends in .svg patched_xml = capabilities_xml.replace( "AMSR2_Sea_Ice_Concentration_12km.png", "AMSR2_Sea_Ice_Concentration_12km.svg", ) respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( return_value=httpx.Response(200, text=patched_xml) ) buf = BytesIO() Image.new("RGB", (200, 20), "white").save(buf, format="PNG") png_route = respx.get(url__regex=r".*legends/.*\.png").mock( return_value=httpx.Response( 200, content=buf.getvalue(), headers={"content-type": "image/png"} ) ) client = GIBSClient() await client.initialize() result = await client.get_legend_image("AMSR2_Sea_Ice_Concentration_12km_Monthly") assert result is not None # Verify the .png URL was requested, not .svg assert png_route.call_count == 1 assert ".png" in str(png_route.calls.last.request.url) await client.close() @respx.mock async def test_legend_rejects_svg_content(capabilities_xml): """If the legend URL returns SVG content, it should be rejected.""" respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( return_value=httpx.Response(200, text=capabilities_xml) ) respx.get(url__regex=r".*legends/.*\.png").mock( return_value=httpx.Response( 200, content=b'not supported", headers={"content-type": "application/xml"}, ) ) client = GIBSClient() await client.initialize() result = await client.get_legend_image("AMSR2_Sea_Ice_Concentration_12km_Monthly") assert result is None await client.close() # --------------------------------------------------------------------------- # Client.http property guard # --------------------------------------------------------------------------- def test_client_http_not_initialized(): """Accessing .http before initialize() raises RuntimeError.""" client = GIBSClient() with pytest.raises(RuntimeError, match="not initialized"): _ = client.http