Files
planet/backend/tests/test_earth_layer_cache.py
rayd1o 5c65ee24d6
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
release: bump version to 0.61.0
2026-05-18 02:37:19 +08:00

255 lines
7.5 KiB
Python

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"