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"]