mcgibs/tests/test_client.py
Ryan Malloy d13ba744d3 Add Hamilton fix test coverage and graceful shutdown
Tests: 79 total (32 new), covering LRU cache eviction, BBox
validation, _parse_rgb error handling, colormap ID extraction,
client retry logic, WMS response validation, dimension clamping,
geocode caching, classification colormaps, and scientific notation
interval parsing.

Shutdown: register atexit handler to close httpx connection pool
when the MCP server exits, since FastMCP Middleware has no
on_shutdown hook.
2026-02-18 18:19:21 -07:00

420 lines
13 KiB
Python

"""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='<?xml version="1.0"?><ServiceException>Layer not found</ServiceException>',
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()
# ---------------------------------------------------------------------------
# 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