release: bump version to 0.61.0
This commit is contained in:
@@ -672,6 +672,77 @@ async def test_ingest_earth_client_log_accepts_public_events():
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_layer_cache_status_requires_super_admin(auth_headers, monkeypatch):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.api.v1.system_control.earth_layer_cache.status",
|
||||
lambda: {
|
||||
"prefix": "earth:layer:v1",
|
||||
"key_count": 2,
|
||||
"memory_bytes": 42,
|
||||
"layers": {"cables": {"keys": 2, "stale_keys": 1, "memory_bytes": 42}},
|
||||
},
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/system/cache/earth-layers", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["prefix"] == "earth:layer:v1"
|
||||
assert data["layers"]["cables"]["stale_keys"] == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_earth_layer_cache_deletes_only_earth_layer_prefix(auth_headers, monkeypatch):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_delete_pattern(pattern="earth:layer:v1:*"):
|
||||
captured["pattern"] = pattern
|
||||
return 3
|
||||
|
||||
monkeypatch.setattr("app.api.v1.system_control.earth_layer_cache.delete_pattern", fake_delete_pattern)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.delete("/api/v1/system/cache/earth-layers", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted"] == 3
|
||||
assert captured["pattern"] == "earth:layer:v1:*"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_id_header_is_echoed_when_provided():
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
254
backend/tests/test_earth_layer_cache.py
Normal file
254
backend/tests/test_earth_layer_cache.py
Normal file
@@ -0,0 +1,254 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user