from __future__ import annotations import json import pytest from fastapi import Response from app.api.v1 import visualization from app.services.earth_layer_cache import ( EarthLayerCachePolicy, apply_payload_budget, earth_layer_cache, format_bbox_key, quantize_bbox, resolve_layer_payload, ) class FakeRedis: def __init__(self, *, fail: bool = False) -> None: self.store: dict[str, str] = {} self.fail = fail self.lock_claimed = False def _maybe_fail(self) -> None: if self.fail: raise RuntimeError("redis unavailable") def get(self, key: str): self._maybe_fail() return self.store.get(key) def set(self, key: str, value: str, nx: bool = False, ex: int | None = None): self._maybe_fail() if nx and key in self.store: return False self.store[key] = value return True def setex(self, key: str, _seconds: int, value: str): self._maybe_fail() self.store[key] = value return True def delete(self, *keys: str): self._maybe_fail() deleted = 0 for key in keys: deleted += 1 if self.store.pop(key, None) is not None else 0 return deleted def scan_iter(self, match: str): self._maybe_fail() prefix = match.rstrip("*") for key in list(self.store): if key.startswith(prefix): yield key def memory_usage(self, key: str): value = self.store.get(key, "") return len(value.encode("utf-8")) @pytest.fixture(autouse=True) def fake_cache_client(): previous = earth_layer_cache._client fake = FakeRedis() earth_layer_cache._client = fake try: yield fake finally: earth_layer_cache._client = previous def test_quantized_bbox_key_is_stable_for_small_movements(): first = format_bbox_key(quantize_bbox((10.01, 59.04, 10.96, 60.02))) second = format_bbox_key(quantize_bbox((10.04, 59.01, 10.99, 60.04))) assert first == second assert first == "10.0,59.0,11.0,60.0" def test_payload_budget_truncates_features(): payload = { "type": "FeatureCollection", "features": [{"id": index} for index in range(5)], } policy = EarthLayerCachePolicy(60, 120, max_features=2, max_bytes=1024) result = apply_payload_budget(payload, policy) assert len(result["features"]) == 2 assert result["diagnostics"]["truncated"] is True assert result["diagnostics"]["limit_reason"] == "feature_budget" assert result["diagnostics"]["original_feature_count"] == 5 @pytest.mark.asyncio async def test_resolve_layer_payload_writes_fresh_and_stale(fake_cache_client): calls = 0 async def builder(): nonlocal calls calls += 1 return {"type": "FeatureCollection", "features": [{"id": "a"}]} key = earth_layer_cache.key("satellites", limit="all") policy = EarthLayerCachePolicy(60, 120) first = await resolve_layer_payload(key=key, policy=policy, builder=builder) second = await resolve_layer_payload(key=key, policy=policy, builder=builder) assert first.state == "refresh" assert second.state == "hit" assert calls == 1 assert key in fake_cache_client.store assert f"{key}:stale" in fake_cache_client.store @pytest.mark.asyncio async def test_resolve_layer_payload_returns_stale_when_builder_fails(fake_cache_client): key = earth_layer_cache.key("bgp-incidents", status="active") fake_cache_client.store[f"{key}:stale"] = json.dumps({"type": "FeatureCollection", "features": []}) async def builder(): raise RuntimeError("db exploded") result = await resolve_layer_payload( key=key, policy=EarthLayerCachePolicy(60, 120), builder=builder, ) assert result.state == "stale" assert result.payload["features"] == [] @pytest.mark.asyncio async def test_resolve_layer_payload_uses_stale_during_lock_contention(fake_cache_client): key = earth_layer_cache.key("cables") fake_cache_client.store[earth_layer_cache.lock_key(key)] = "1" fake_cache_client.store[f"{key}:stale"] = json.dumps( {"type": "FeatureCollection", "features": [{"id": "stale-cable"}]} ) calls = 0 async def builder(): nonlocal calls calls += 1 return {"type": "FeatureCollection", "features": [{"id": "fresh-cable"}]} result = await resolve_layer_payload( key=key, policy=EarthLayerCachePolicy(60, 120), builder=builder, ) assert result.state == "stale" assert result.payload["features"][0]["id"] == "stale-cable" assert calls == 0 @pytest.mark.asyncio async def test_resolve_layer_payload_bypasses_redis_failure(): previous = earth_layer_cache._client earth_layer_cache._client = FakeRedis(fail=True) try: async def builder(): return {"type": "FeatureCollection", "features": [{"id": "safe"}]} result = await resolve_layer_payload( key=earth_layer_cache.key("cables"), policy=EarthLayerCachePolicy(60, 120), builder=builder, ) assert result.state == "bypass" assert result.payload["features"][0]["id"] == "safe" finally: earth_layer_cache._client = previous @pytest.mark.asyncio async def test_visualization_endpoint_sets_cache_headers(fake_cache_client, monkeypatch): calls = 0 async def fake_build_satellites_geojson(*, limit, db): nonlocal calls calls += 1 return {"type": "FeatureCollection", "features": [{"id": f"sat-{limit}"}], "count": 1} monkeypatch.setattr(visualization, "_build_satellites_geojson", fake_build_satellites_geojson) first_response = Response() first = await visualization.get_satellites_geojson(limit=25, db=object(), response=first_response) second_response = Response() second = await visualization.get_satellites_geojson(limit=25, db=object(), response=second_response) assert first == second assert calls == 1 assert first_response.headers["X-Planet-Cache"] == "refresh" assert second_response.headers["X-Planet-Cache"] == "hit" @pytest.mark.asyncio async def test_vessel_snapshot_uses_short_cache(fake_cache_client, monkeypatch): calls = 0 async def fake_load_raw_vessel_snapshot_features(db, *, bbox, limit, observed_since): nonlocal calls calls += 1 return ( [ { "type": "Feature", "geometry": {"type": "Point", "coordinates": [10.1, 59.1]}, "properties": {"mmsi": 123, "vessel_type_name": "Cargo"}, } ], {"raw_feature_count": 1}, ) monkeypatch.setattr( visualization, "_load_raw_vessel_snapshot_features", fake_load_raw_vessel_snapshot_features, ) first_response = Response() first = await visualization.build_vessel_snapshot_response( object(), bbox=(10.01, 59.04, 10.96, 60.02), zoom=12, type_filter=None, limit=1000, since_minutes=60, response=first_response, ) second_response = Response() second = await visualization.build_vessel_snapshot_response( object(), bbox=(10.04, 59.01, 10.99, 60.04), zoom=12, type_filter=None, limit=1000, since_minutes=60, response=second_response, ) assert first["count"] == 1 assert second == first assert calls == 1 assert first_response.headers["X-Planet-Cache"] == "refresh" assert second_response.headers["X-Planet-Cache"] == "hit"