state-hub/tests/test_edge_read_cache.py
tegwick 1cf949bda4
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 51s
feat(edge): add offline read cache for allowlisted State Hub GET routes
The edge relay now persists successful GET responses and serves them with
stale markers when upstream is unreachable. Extend Forgejo image workflow
path filters so api changes trigger registry publishes.
2026-07-09 01:04:15 +02:00

114 lines
No EOL
3.6 KiB
Python

import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from api.edge.read_cache import ReadCacheStore, build_cache_key, is_cacheable_get
from api.edge.relay import create_app
class FailingAsyncClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def request(self, *args, **kwargs):
raise httpx.ConnectError("upstream down")
async def get(self, *args, **kwargs):
raise httpx.ConnectError("upstream down")
class SuccessAsyncClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def request(self, method, path, **kwargs):
request = httpx.Request(method, f"http://upstream{path}")
return httpx.Response(
200,
json={"path": path, "ok": True},
request=request,
headers={"content-type": "application/json"},
)
def test_build_cache_key_sorts_query_params() -> None:
key = build_cache_key("GET", "/state/summary", [("b", "2"), ("a", "1")])
assert key == "GET:/state/summary?a=1&b=2"
def test_is_cacheable_get_allows_activity_core_reads() -> None:
assert is_cacheable_get("/state/summary")
assert is_cacheable_get("/workplans/index")
assert is_cacheable_get("/tasks/")
assert is_cacheable_get("/edge/health") is False
def test_read_cache_store_round_trip(tmp_path) -> None:
store = ReadCacheStore(tmp_path / "cache.sqlite3")
store.put(
cache_key="GET:/state/summary",
status_code=200,
content=b'{"ok": true}',
content_type="application/json",
)
entry = store.get("GET:/state/summary")
assert entry is not None
assert entry.status_code == 200
assert entry.content == b'{"ok": true}'
@pytest.mark.asyncio
async def test_relay_caches_successful_get_and_serves_stale_on_outage(tmp_path, monkeypatch):
from api.edge import relay
monkeypatch.setattr(relay.httpx, "AsyncClient", SuccessAsyncClient)
cache_path = tmp_path / "read-cache.sqlite3"
app = create_app(
upstream_url="http://upstream",
outbox_path=str(tmp_path / "outbox.sqlite3"),
read_cache_path=str(cache_path),
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://edge") as client:
warm = await client.get("/state/summary")
assert warm.status_code == 200
assert warm.headers.get("x-statehub-edge-cache") == "hit"
monkeypatch.setattr(relay.httpx, "AsyncClient", FailingAsyncClient)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://edge") as client:
stale = await client.get("/state/summary")
assert stale.status_code == 200
assert stale.headers.get("x-statehub-edge-cache") == "stale"
assert stale.json()["ok"] is True
assert int(stale.headers.get("x-statehub-edge-cache-age", "0")) >= 0
@pytest.mark.asyncio
async def test_relay_returns_503_for_uncached_get_when_upstream_unreachable(tmp_path, monkeypatch):
from api.edge import relay
monkeypatch.setattr(relay.httpx, "AsyncClient", FailingAsyncClient)
app = create_app(
upstream_url="http://upstream",
outbox_path=str(tmp_path / "outbox.sqlite3"),
read_cache_path=str(tmp_path / "read-cache.sqlite3"),
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://edge") as client:
response = await client.get("/state/summary")
assert response.status_code == 503
assert "not queueable" in response.json()["error"]