719e4d605b
The bind dropdown offered only memory/affect single-plane binds; #18's composite endpoint (:8392, both planes in one session) was never reachable from the SPA. Add 'combined' as the default-selected option, keeping memory-only / affect-only for single-plane isolation diagnostics. - endpoint_for_plane: combined -> :8392 (sessions.py) - web server: accept bifrost_plane="combined" (server.py) - dropdown: combined (:8392) default-selected, single-plane retained (index.html) - #17 contract: endpoint_for_plane FN + plane-selector spec updated to combined - tests: endpoint_for_plane combined, server combined bind -> :8392, dropdown default Suite 506 green. Live-verified on :8765 (current code).
1065 lines
46 KiB
Python
1065 lines
46 KiB
Python
"""Tests for ratatoskr.web.server per docs/contracts/issues/16.contract.md.
|
|
|
|
The TestClient drives the Starlette app with a respx-mocked upstream
|
|
httpx client. No live network. See INV-002: create_app takes a
|
|
client_factory callable; tests pass a factory returning a respx-mocked
|
|
AsyncClient.
|
|
"""
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
from starlette.testclient import TestClient
|
|
|
|
|
|
def _mock_client_factory() -> "object":
|
|
"""A client_factory that returns a no-base-url AsyncClient suitable
|
|
for respx-mocking absolute URLs. Endpoints that hit upstream use the
|
|
same factory in tests as in prod; respx intercepts at the transport
|
|
layer.
|
|
"""
|
|
def factory() -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(base_url="https://w.example")
|
|
return factory
|
|
|
|
|
|
class TestVersionEndpoint:
|
|
"""version_endpoint FN — tracer per contract issue #16."""
|
|
|
|
def test_happy_returns_current_version(self) -> None:
|
|
"""happy [tracer]: GET /version → 200, body == {"ratatoskr": "<current-version>"}.
|
|
|
|
Validates: Starlette app boots, route registers, JSON shape correct,
|
|
version derived from package metadata (importlib.metadata).
|
|
"""
|
|
from importlib.metadata import version
|
|
|
|
from ratatoskr.web.server import create_app
|
|
|
|
app = create_app(_mock_client_factory())
|
|
client = TestClient(app)
|
|
resp = client.get("/version")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ratatoskr": version("ratatoskr")}
|
|
|
|
|
|
class TestAgentsEndpoint:
|
|
"""agents_endpoint FN — proxy upstream /agents + merge with local tier3 index."""
|
|
|
|
@respx.mock
|
|
def test_happy_merges_upstream_and_local(self, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
|
"""happy [tracer]: respx mock /agents 200 → response merges upstream + local index."""
|
|
# Isolate local agents index to tmp
|
|
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
|
|
respx.get("https://w.example/agents").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json=[
|
|
{"agent_id": "mimir", "name": "Mimir", "description": "k"},
|
|
],
|
|
)
|
|
)
|
|
# Add one local tier3 agent to the index
|
|
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
|
|
|
|
add_local_agent(
|
|
LocalAgentEntry(
|
|
agent_id="ratatoskr:sindra",
|
|
agent_name="sindra",
|
|
model="artemis-31b-v1i",
|
|
description="(tier 3) IDENTITY",
|
|
defined_at="2026-05-28T00:00:00+00:00",
|
|
)
|
|
)
|
|
|
|
from ratatoskr.web.server import create_app
|
|
|
|
app = create_app(_mock_client_factory())
|
|
client = TestClient(app)
|
|
resp = client.get("/api/agents")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
ids = [a["agent_id"] for a in body]
|
|
assert "mimir" in ids
|
|
assert "ratatoskr:sindra" in ids
|
|
|
|
@respx.mock
|
|
def test_upstream_500_returns_500_envelope(self, monkeypatch, tmp_path) -> None:
|
|
"""upstream_500 [error]: respx 500 → 500 with error_code envelope."""
|
|
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
|
|
respx.get("https://w.example/agents").mock(return_value=httpx.Response(500, content=b"boom"))
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).get("/api/agents")
|
|
assert resp.status_code == 500
|
|
assert resp.json()["error_code"] == "session_api_failed"
|
|
|
|
@respx.mock
|
|
def test_network_error_returns_502(self, monkeypatch, tmp_path) -> None:
|
|
"""network_error [error]: connection refused → 502 with network_error envelope."""
|
|
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
|
|
respx.get("https://w.example/agents").mock(side_effect=httpx.ConnectError("refused"))
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).get("/api/agents")
|
|
assert resp.status_code == 502
|
|
assert resp.json()["error_code"] == "network_error"
|
|
|
|
@respx.mock
|
|
def test_local_dedup(self, monkeypatch, tmp_path) -> None:
|
|
"""local_dedup [scenario]: local entry with same agent_id as upstream → no duplicate."""
|
|
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
|
|
respx.get("https://w.example/agents").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json=[{"agent_id": "ratatoskr:sindra", "name": "Sindra-from-server", "description": ""}],
|
|
)
|
|
)
|
|
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
|
|
add_local_agent(
|
|
LocalAgentEntry(
|
|
agent_id="ratatoskr:sindra", agent_name="sindra", model="m",
|
|
description="local-tier3", defined_at="2026-05-28T00:00:00+00:00",
|
|
)
|
|
)
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).get("/api/agents")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
ids = [a["agent_id"] for a in body]
|
|
assert ids.count("ratatoskr:sindra") == 1
|
|
# Upstream entry wins (it's first in the merge); local is deduped
|
|
assert body[0]["name"] == "Sindra-from-server"
|
|
|
|
|
|
_CREATE_OK = {
|
|
"session_id": "s-1",
|
|
"agent_id": "mimir",
|
|
"message_count": 0,
|
|
"created_at": "2026-05-28T00:00:00+00:00",
|
|
"last_active": "2026-05-28T00:00:00+00:00",
|
|
"metadata": {},
|
|
}
|
|
|
|
|
|
class TestCreateSessionEndpoint:
|
|
"""create_session_endpoint FN — proxy POST /sessions to upstream."""
|
|
|
|
@respx.mock
|
|
def test_happy_returns_201(self) -> None:
|
|
"""happy [tracer]: respx mock 201 → endpoint returns 201 with session JSON."""
|
|
respx.post("https://w.example/sessions").mock(return_value=httpx.Response(201, json=_CREATE_OK))
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).post("/api/sessions", json={"agent_id": "mimir"})
|
|
assert resp.status_code == 201
|
|
assert resp.json()["session_id"] == "s-1"
|
|
|
|
@respx.mock
|
|
def test_unknown_agent_returns_404(self) -> None:
|
|
"""unknown_agent [error]: respx 404 → 404 with agent_not_found envelope."""
|
|
respx.post("https://w.example/sessions").mock(
|
|
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
|
|
)
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).post("/api/sessions", json={"agent_id": "ghost"})
|
|
assert resp.status_code == 404
|
|
assert resp.json()["error_code"] == "agent_not_found"
|
|
|
|
def test_missing_agent_id_returns_400(self) -> None:
|
|
"""missing_agent_id [adversarial]: body without agent_id → 400."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).post("/api/sessions", json={})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
_SNAPSHOT = {
|
|
"agent_id": "mimir",
|
|
"pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5},
|
|
"dominant_emotion": "curiosity",
|
|
}
|
|
|
|
|
|
class TestPersonaStateEndpoint:
|
|
"""persona_state_endpoint FN — proxy upstream GET /agents/{id}/persona_state."""
|
|
|
|
@respx.mock
|
|
def test_happy_returns_snapshot(self) -> None:
|
|
"""happy [tracer]: respx 200 → 200 with snapshot."""
|
|
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
|
return_value=httpx.Response(200, json=_SNAPSHOT)
|
|
)
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).get("/api/agents/mimir/persona_state")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["dominant_emotion"] == "curiosity"
|
|
|
|
@respx.mock
|
|
def test_persona_not_configured(self) -> None:
|
|
"""persona_not_configured [error]: 404 + persona_not_configured → 404 envelope."""
|
|
respx.get("https://w.example/agents/domari/persona_state").mock(
|
|
return_value=httpx.Response(404, json={"error_code": "persona_not_configured"})
|
|
)
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).get("/api/agents/domari/persona_state")
|
|
assert resp.status_code == 404
|
|
assert resp.json()["error_code"] == "persona_not_configured"
|
|
|
|
@respx.mock
|
|
def test_agent_not_available(self) -> None:
|
|
"""agent_not_available [error]: 404 + agent_not_available → 404 envelope."""
|
|
respx.get("https://w.example/agents/bogus/persona_state").mock(
|
|
return_value=httpx.Response(404, json={"error_code": "agent_not_available"})
|
|
)
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).get("/api/agents/bogus/persona_state")
|
|
assert resp.status_code == 404
|
|
assert resp.json()["error_code"] == "agent_not_available"
|
|
|
|
@respx.mock
|
|
def test_auth_scope_denied(self) -> None:
|
|
"""auth_scope_denied [error]: 403 + auth_scope_denied → 403 envelope."""
|
|
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
|
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
|
|
)
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).get("/api/agents/mimir/persona_state")
|
|
assert resp.status_code == 403
|
|
assert resp.json()["error_code"] == "auth_scope_denied"
|
|
|
|
|
|
class TestSubmitTurnEndpoint:
|
|
"""submit_turn_endpoint FN — allocate turn_id, register in turn_registry."""
|
|
|
|
def test_happy_returns_turn_id(self) -> None:
|
|
"""happy [tracer]: POST {"content": "hi"} → 200 with turn_id; registry populated."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).post("/api/turns/s-1", json={"content": "hello"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert isinstance(body["turn_id"], int)
|
|
assert body["turn_id"] > 0
|
|
# Registry has the entry
|
|
handle = app.state.turn_registry[("s-1", body["turn_id"])]
|
|
assert handle.content == "hello"
|
|
assert handle.status == "queued"
|
|
|
|
def test_missing_content_returns_400(self) -> None:
|
|
"""missing_content [adversarial]: body without content → 400."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).post("/api/turns/s-1", json={})
|
|
assert resp.status_code == 400
|
|
|
|
def test_monotonic_turn_ids(self) -> None:
|
|
"""monotonic_turn_ids [trace]: two submits → second turn_id > first."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
c = TestClient(app)
|
|
first = c.post("/api/turns/s-1", json={"content": "a"}).json()["turn_id"]
|
|
second = c.post("/api/turns/s-2", json={"content": "b"}).json()["turn_id"]
|
|
assert second > first
|
|
|
|
|
|
_DONE_BODY = {
|
|
"type": "done",
|
|
"phase": "succeeded",
|
|
"response": "hello",
|
|
"model": "m",
|
|
"duration_ms": 1,
|
|
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "cached_input_tokens": 0},
|
|
}
|
|
|
|
|
|
def _sse_chunk(sse_id: str, body: dict) -> bytes:
|
|
import json as _j
|
|
return f"id: {sse_id}\ndata: {_j.dumps(body)}\n\n".encode()
|
|
|
|
|
|
def _sse_resp(stream: bytes) -> httpx.Response:
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=stream)
|
|
|
|
|
|
def _sse_resp_stream(body: httpx.AsyncByteStream) -> httpx.Response:
|
|
"""SSE response backed by a live AsyncByteStream (for gated/hanging
|
|
streams in disconnect tests)."""
|
|
return httpx.Response(
|
|
200, headers={"content-type": "text/event-stream"}, stream=body
|
|
)
|
|
|
|
|
|
def _parse_browser_sse(raw: bytes) -> list[dict]:
|
|
"""Parse a server-to-browser SSE stream into [{"event": str, "data": dict}, ...]."""
|
|
import json as _j
|
|
events: list[dict] = []
|
|
for block in raw.decode().split("\n\n"):
|
|
block = block.strip()
|
|
if not block:
|
|
continue
|
|
event_type = None
|
|
data_str = None
|
|
for line in block.splitlines():
|
|
if line.startswith("event: "):
|
|
event_type = line[len("event: "):]
|
|
elif line.startswith("data: "):
|
|
data_str = line[len("data: "):]
|
|
if event_type and data_str is not None:
|
|
events.append({"event": event_type, "data": _j.loads(data_str)})
|
|
return events
|
|
|
|
|
|
class TestStreamTurnEndpoint:
|
|
"""stream_turn_endpoint FN — open upstream SSE, proxy events to browser."""
|
|
|
|
@respx.mock
|
|
def test_happy_text_done(self) -> None:
|
|
"""happy [tracer]: respx mock one text+done → SSE stream yields text + done events."""
|
|
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk("42:2", _DONE_BODY)
|
|
respx.post("https://w.example/sessions/s-1/messages").mock(return_value=_sse_resp(stream))
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
c = TestClient(app)
|
|
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
|
|
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={turn_id}") as resp:
|
|
assert resp.status_code == 200
|
|
raw = b"".join(resp.iter_bytes())
|
|
events = _parse_browser_sse(raw)
|
|
types = [e["event"] for e in events]
|
|
assert "text" in types
|
|
assert "done" in types
|
|
# Registry cleaned up
|
|
assert ("s-1", turn_id) not in app.state.turn_registry
|
|
|
|
def test_unknown_turn_returns_404(self) -> None:
|
|
"""unknown_turn [error]: GET with turn_id not in registry → 404."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).get("/api/turns/s-x/stream?turn_id=999")
|
|
assert resp.status_code == 404
|
|
|
|
@respx.mock
|
|
def test_upstream_error_synthetic_event(self) -> None:
|
|
"""upstream_error [error]: respx 500 → synthetic error SSE event."""
|
|
respx.post("https://w.example/sessions/s-1/messages").mock(
|
|
return_value=httpx.Response(500, content=b"boom")
|
|
)
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
c = TestClient(app)
|
|
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
|
|
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={turn_id}") as resp:
|
|
raw = b"".join(resp.iter_bytes())
|
|
events = _parse_browser_sse(raw)
|
|
types = [e["event"] for e in events]
|
|
assert "error" in types
|
|
# Surfaces upstream's exception type for the operator
|
|
err = next(e for e in events if e["event"] == "error")
|
|
assert err["data"]["exception"] == "SseConnectFailed"
|
|
|
|
|
|
_CANCEL_OK = {"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None}
|
|
|
|
|
|
class TestCancelTurnEndpoint:
|
|
"""cancel_turn_endpoint FN — proxy upstream cancel for registered turn."""
|
|
|
|
@respx.mock
|
|
def test_happy_cancels(self) -> None:
|
|
"""happy [tracer]: registered turn (upstream started) → POST cancel
|
|
→ 200, upstream cancel called against the upstream turn_id.
|
|
"""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
c = TestClient(app)
|
|
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
|
|
# Simulate that the upstream stream has started (turn_id 42 upstream).
|
|
app.state.turn_registry[("s-1", turn_id)].status = "streaming"
|
|
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
|
|
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
|
return_value=httpx.Response(200, json={**_CANCEL_OK, "turn_id": 42})
|
|
)
|
|
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["cancelled"] is True
|
|
assert ("s-1", turn_id) not in app.state.turn_registry
|
|
|
|
def test_unknown_turn_returns_404(self) -> None:
|
|
"""unknown_turn [error]: not in registry → 404."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).post("/api/turns/s-x/cancel?turn_id=999")
|
|
assert resp.status_code == 404
|
|
|
|
@respx.mock
|
|
def test_already_completed_race(self) -> None:
|
|
"""already_completed [race]: upstream 409 → 200 reason=race_or_completed."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
c = TestClient(app)
|
|
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
|
|
app.state.turn_registry[("s-1", turn_id)].status = "streaming"
|
|
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
|
|
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
|
return_value=httpx.Response(409)
|
|
)
|
|
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["cancelled"] is False
|
|
assert resp.json()["reason"] == "race_or_completed"
|
|
|
|
@respx.mock
|
|
def test_cancel_failed_500(self) -> None:
|
|
"""cancel_failed [error]: upstream 500 → 500 with cancel_failed envelope."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
c = TestClient(app)
|
|
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
|
|
app.state.turn_registry[("s-1", turn_id)].status = "streaming"
|
|
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
|
|
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
|
return_value=httpx.Response(500, content=b"boom")
|
|
)
|
|
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
|
|
assert resp.status_code == 500
|
|
assert resp.json()["error_code"] == "cancel_failed"
|
|
assert ("s-1", turn_id) not in app.state.turn_registry
|
|
|
|
|
|
class TestStaticServing:
|
|
"""root_endpoint FN + /static mount — index.html + static asset serving."""
|
|
|
|
def test_root_returns_html(self) -> None:
|
|
"""happy [tracer]: GET / → 200, content-type text/html, body contains '<html'."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
resp = TestClient(app).get("/")
|
|
assert resp.status_code == 200
|
|
assert "text/html" in resp.headers["content-type"]
|
|
assert "<html" in resp.text
|
|
|
|
|
|
class TestLifespanShutdown:
|
|
"""lifespan_shutdown FN — INV-006: drain turn_registry within 5s budget."""
|
|
|
|
@respx.mock
|
|
def test_happy_drains_registry(self) -> None:
|
|
"""happy [tracer]: 2 in-flight turns + shutdown → upstream cancels called."""
|
|
from ratatoskr.web.server import TurnHandle, create_app
|
|
|
|
cancel_routes = []
|
|
for tid in (101, 102):
|
|
cancel_routes.append(
|
|
respx.post(f"https://w.example/sessions/s-1/turns/{tid}/cancel").mock(
|
|
return_value=httpx.Response(200, json={
|
|
"turn_id": tid, "cancelled": True, "reason": None,
|
|
"partial_message_id": None,
|
|
})
|
|
)
|
|
)
|
|
app = create_app(_mock_client_factory())
|
|
with TestClient(app) as client:
|
|
# Two turns in-flight (status=streaming, upstream_turn_id set =
|
|
# local tid here for test simplicity).
|
|
for tid in (101, 102):
|
|
app.state.turn_registry[("s-1", tid)] = TurnHandle(
|
|
session_id="s-1", turn_id=tid, content="x",
|
|
status="streaming", upstream_turn_id=tid,
|
|
)
|
|
# The exit of the `with` triggers lifespan shutdown
|
|
# After lifespan shutdown:
|
|
for route in cancel_routes:
|
|
assert route.called, "upstream cancel should have been issued for each in-flight turn"
|
|
assert app.state.turn_registry == {}
|
|
|
|
|
|
class TestUpstreamTurnIdCancel:
|
|
"""v0.16.0 — cancel paths must target the UPSTREAM turn_id, not the
|
|
browser-local turn_id. The local _TURN_COUNTER allocates 1,2,3…; the
|
|
real upstream turn_id only arrives via the first SSE event's
|
|
sse_id.turn_id. Heid panel (Hulda) load-bearing finding.
|
|
"""
|
|
|
|
@respx.mock
|
|
def test_cancel_targets_upstream_turn_id(self) -> None:
|
|
"""A registered handle whose local turn_id (1) differs from its
|
|
captured upstream_turn_id (42) → POST cancel hits the UPSTREAM URL
|
|
/sessions/s-1/turns/42/cancel, not /turns/1/cancel.
|
|
"""
|
|
from ratatoskr.web.server import TurnHandle, create_app
|
|
|
|
# Only the upstream-id cancel URL is mocked. If the code uses the
|
|
# local id (1), it'll miss this route → the test catches the bug.
|
|
upstream_route = respx.post(
|
|
"https://w.example/sessions/s-1/turns/42/cancel"
|
|
).mock(return_value=httpx.Response(200, json={
|
|
"turn_id": 42, "cancelled": True, "reason": None,
|
|
"partial_message_id": None,
|
|
}))
|
|
app = create_app(_mock_client_factory())
|
|
app.state.turn_registry[("s-1", 1)] = TurnHandle(
|
|
session_id="s-1", turn_id=1, content="x",
|
|
status="streaming", upstream_turn_id=42,
|
|
)
|
|
resp = TestClient(app).post("/api/turns/s-1/cancel?turn_id=1")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["cancelled"] is True
|
|
assert upstream_route.called, "cancel must target the upstream turn_id"
|
|
assert ("s-1", 1) not in app.state.turn_registry
|
|
|
|
def test_cancel_before_upstream_started_is_noop(self) -> None:
|
|
"""A handle with upstream_turn_id still None (turn never opened the
|
|
upstream stream) → cancel is a no-op: 200 {cancelled: false,
|
|
reason: not_started}, no upstream call, registry cleaned.
|
|
"""
|
|
from ratatoskr.web.server import TurnHandle, create_app
|
|
|
|
app = create_app(_mock_client_factory())
|
|
app.state.turn_registry[("s-1", 1)] = TurnHandle(
|
|
session_id="s-1", turn_id=1, content="x",
|
|
status="queued", upstream_turn_id=None,
|
|
)
|
|
resp = TestClient(app).post("/api/turns/s-1/cancel?turn_id=1")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["cancelled"] is False
|
|
assert resp.json()["reason"] == "not_started"
|
|
assert ("s-1", 1) not in app.state.turn_registry
|
|
|
|
@respx.mock
|
|
def test_stream_captures_upstream_turn_id(self) -> None:
|
|
"""The stream generator captures upstream turn_id from the first
|
|
event's sse_id. After a happy text+done stream against upstream
|
|
turn 42 (local turn 1), the cancel mid-stream would have targeted 42.
|
|
Verified indirectly: drive the stream, assert events carry turn 42.
|
|
"""
|
|
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk("42:2", _DONE_BODY)
|
|
respx.post("https://w.example/sessions/s-1/messages").mock(return_value=_sse_resp(stream))
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
c = TestClient(app)
|
|
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
|
|
assert turn_id == 1 or turn_id > 0 # local counter
|
|
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={turn_id}") as resp:
|
|
raw = b"".join(resp.iter_bytes())
|
|
events = _parse_browser_sse(raw)
|
|
# Every browser-facing event carries the upstream turn_id (42), not local
|
|
text_ev = next(e for e in events if e["event"] == "text")
|
|
assert text_ev["data"]["sse_id"].startswith("42:")
|
|
|
|
|
|
class TestServerSideEndUserId:
|
|
"""v0.16.0 — end_user_id is server-configured (RATATOSKR_END_USER_ID via
|
|
create_app), NOT accepted from the browser body. Heid panel finding +
|
|
contract FN main STEPS 2.
|
|
"""
|
|
|
|
@respx.mock
|
|
def test_create_session_uses_server_end_user_id(self) -> None:
|
|
"""create_app(end_user_id=...) → POST /api/sessions threads that id
|
|
into the upstream POST body even when the browser sends none.
|
|
"""
|
|
import json as _j
|
|
|
|
route = respx.post("https://w.example/sessions").mock(
|
|
return_value=httpx.Response(201, json={**_CREATE_OK, "agent_id": "lofn"})
|
|
)
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory(), end_user_id="ratatoskr-tui")
|
|
resp = TestClient(app).post("/api/sessions", json={"agent_id": "lofn"})
|
|
assert resp.status_code == 201
|
|
sent = _j.loads(route.calls[0].request.content)
|
|
assert sent == {"agent_id": "lofn", "end_user_id": "ratatoskr-tui"}
|
|
|
|
@respx.mock
|
|
def test_create_session_ignores_body_end_user_id(self) -> None:
|
|
"""A browser-supplied end_user_id is IGNORED — the server's
|
|
configured value wins. Prevents a client from impersonating an
|
|
arbitrary end-user partition.
|
|
"""
|
|
import json as _j
|
|
|
|
route = respx.post("https://w.example/sessions").mock(
|
|
return_value=httpx.Response(201, json={**_CREATE_OK, "agent_id": "lofn"})
|
|
)
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory(), end_user_id="ratatoskr-tui")
|
|
TestClient(app).post(
|
|
"/api/sessions", json={"agent_id": "lofn", "end_user_id": "attacker"}
|
|
)
|
|
sent = _j.loads(route.calls[0].request.content)
|
|
assert sent.get("end_user_id") == "ratatoskr-tui"
|
|
|
|
@respx.mock
|
|
def test_create_session_no_end_user_id_when_unset(self) -> None:
|
|
"""When create_app gets no end_user_id, the upstream body omits it
|
|
(matches create_session's default-omit shape)."""
|
|
import json as _j
|
|
|
|
route = respx.post("https://w.example/sessions").mock(
|
|
return_value=httpx.Response(201, json=_CREATE_OK)
|
|
)
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory()) # no end_user_id
|
|
TestClient(app).post("/api/sessions", json={"agent_id": "mimir"})
|
|
sent = _j.loads(route.calls[0].request.content)
|
|
assert "end_user_id" not in sent
|
|
|
|
|
|
class TestCreateAppShape:
|
|
"""create_app FN — route registration + state wiring (contract TESTS)."""
|
|
|
|
def test_routes_registered(self) -> None:
|
|
"""routes_registered [tracer]: app.routes contains all path patterns,
|
|
including the #18 affect-read proxy."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
paths = {getattr(r, "path", None) for r in app.routes}
|
|
for expected in (
|
|
"/", "/version", "/api/agents", "/api/sessions",
|
|
"/api/agents/{agent_id}/persona_state",
|
|
"/api/affect/{agent_id}",
|
|
"/api/turns/{session_id}", "/api/turns/{session_id}/stream",
|
|
"/api/turns/{session_id}/cancel",
|
|
):
|
|
assert expected in paths, f"missing route {expected}"
|
|
# /static is a Mount — its path is "/static"
|
|
assert "/static" in paths
|
|
|
|
def test_state_attached(self) -> None:
|
|
"""state_attached [trace]: app.state.turn_registry is empty dict."""
|
|
from ratatoskr.web.server import create_app
|
|
app = create_app(_mock_client_factory())
|
|
assert app.state.turn_registry == {}
|
|
|
|
def test_factory_stored(self) -> None:
|
|
"""factory_stored [trace]: app.state.client_factory is the same callable."""
|
|
from ratatoskr.web.server import create_app
|
|
f = _mock_client_factory()
|
|
app = create_app(f)
|
|
assert app.state.client_factory is f
|
|
|
|
|
|
class TestStreamFullEventVocab:
|
|
"""stream_turn_endpoint full_event_vocab — one of each Event type proxied."""
|
|
|
|
def _drive_stream(self, app, stream: bytes) -> list[str]:
|
|
respx.post("https://w.example/sessions/s-1/messages").mock(
|
|
return_value=_sse_resp(stream)
|
|
)
|
|
c = TestClient(app)
|
|
tid = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
|
|
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={tid}") as resp:
|
|
raw = b"".join(resp.iter_bytes())
|
|
return [e["event"] for e in _parse_browser_sse(raw)]
|
|
|
|
@respx.mock
|
|
def test_full_event_vocab(self) -> None:
|
|
"""full_event_vocab [scenario]: a stream with the non-terminal Event
|
|
types (incl. AffectUpdate) + Done → each serialized to its fixture-
|
|
shaped browser event. Error / Cancelled are terminal and exclusive
|
|
with Done, so they get dedicated tests below.
|
|
"""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
stream = b"".join([
|
|
_sse_chunk("42:1", {"type": "affect_update", "status": "current", "turn_id": 42,
|
|
"snapshot": {"agent_id": "mimir", "pad": {}, "dominant_emotion": "x"}}),
|
|
_sse_chunk("42:2", {"type": "worker_phase", "phase": "BuildingPrompt", "turn_id": 42}),
|
|
_sse_chunk("42:3", {"type": "thinking", "content": "hmm"}),
|
|
_sse_chunk("42:4", {"type": "text", "content": "hi"}),
|
|
_sse_chunk("42:5", {"type": "text_boundary", "kind": "sentence", "char_offset": 2, "ts": "t"}),
|
|
_sse_chunk("42:6", {"type": "tool_start", "name": "s", "arguments": {"q": "x"}}),
|
|
_sse_chunk("42:7", {"type": "tool_result", "name": "s", "result": {"n": 1}, "duration_ms": 3}),
|
|
_sse_chunk("42:8", {"type": "awaiting_llm_first_token", "turn_id": 42,
|
|
"elapsed_ms_since_building_prompt": 5000.0}),
|
|
_sse_chunk("42:9", _DONE_BODY),
|
|
])
|
|
types = self._drive_stream(create_app(_mock_client_factory()), stream)
|
|
for expected in ("affect_update", "worker_phase", "thinking", "text", "text_boundary",
|
|
"tool_start", "tool_result", "awaiting_llm_first_token", "done"):
|
|
assert expected in types, f"missing browser event {expected}"
|
|
|
|
@respx.mock
|
|
def test_error_terminal_event(self) -> None:
|
|
"""error terminal [scenario]: an SSE `error` event (distinct from the
|
|
synthetic connection-error event) proxies to a browser `error` event.
|
|
"""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
|
|
"42:2", {"type": "error", "phase": "failed",
|
|
"error_code": "llm_output_invalid", "message": "boom"},
|
|
)
|
|
types = self._drive_stream(create_app(_mock_client_factory()), stream)
|
|
assert "error" in types
|
|
|
|
@respx.mock
|
|
def test_cancelled_terminal_event(self) -> None:
|
|
"""cancelled terminal [scenario]: an SSE `cancelled` event proxies to
|
|
a browser `cancelled` event."""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
|
|
"42:2", {"type": "cancelled", "phase": "cancelled", "turn_id": 42,
|
|
"reason": "user_cancel", "partial_message_id": None},
|
|
)
|
|
types = self._drive_stream(create_app(_mock_client_factory()), stream)
|
|
assert "cancelled" in types
|
|
|
|
|
|
class TestDisconnectCancel:
|
|
"""stream_turn_endpoint INV-005 — browser disconnect mid-stream triggers
|
|
upstream cancel against the UPSTREAM turn_id. The load-bearing test that
|
|
would have caught the v0.15.x turn_id bug.
|
|
|
|
Drives the endpoint's StreamingResponse body_iterator directly and
|
|
cancels the consuming task to simulate the disconnect. This avoids the
|
|
ASGITransport stream-context-exit deadlock against a gated upstream
|
|
generator, while exercising the real `except asyncio.CancelledError`
|
|
cleanup path inside the generator.
|
|
"""
|
|
|
|
@respx.mock
|
|
async def test_disconnect_triggers_upstream_cancel(self) -> None:
|
|
import asyncio
|
|
|
|
from starlette.requests import Request
|
|
|
|
from ratatoskr.web.server import TurnHandle, _stream_turn_endpoint, create_app
|
|
|
|
gate = asyncio.Event()
|
|
|
|
class _GatedAfterFirst(httpx.AsyncByteStream):
|
|
async def __aiter__(self):
|
|
yield _sse_chunk("42:1", {"type": "text", "content": "x"})
|
|
await gate.wait()
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
respx.post("https://w.example/sessions/s-1/messages").mock(
|
|
return_value=_sse_resp_stream(_GatedAfterFirst())
|
|
)
|
|
cancel_route = respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
|
return_value=httpx.Response(200, json={
|
|
"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None,
|
|
})
|
|
)
|
|
app = create_app(_mock_client_factory())
|
|
app.state.turn_registry[("s-1", 1)] = TurnHandle(
|
|
session_id="s-1", turn_id=1, content="x",
|
|
)
|
|
request = Request({
|
|
"type": "http", "method": "GET", "path": "/api/turns/s-1/stream",
|
|
"path_params": {"session_id": "s-1"},
|
|
"query_string": b"turn_id=1", "headers": [], "app": app,
|
|
})
|
|
response = await _stream_turn_endpoint(request)
|
|
body_iter = response.body_iterator # type: ignore[attr-defined]
|
|
|
|
async def consume() -> None:
|
|
async for _chunk in body_iter:
|
|
pass
|
|
|
|
task = asyncio.create_task(consume())
|
|
# Wait until the generator has captured the upstream turn_id (first
|
|
# event consumed). The handle is popped in finally, so check it
|
|
# before cancelling.
|
|
for _ in range(100):
|
|
h = app.state.turn_registry.get(("s-1", 1))
|
|
if h is not None and h.upstream_turn_id == 42:
|
|
break
|
|
await asyncio.sleep(0.02)
|
|
else:
|
|
gate.set()
|
|
raise AssertionError("upstream_turn_id was never captured")
|
|
|
|
# Simulate browser disconnect: cancel the consuming task.
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
for _ in range(50):
|
|
if cancel_route.called:
|
|
break
|
|
await asyncio.sleep(0.02)
|
|
gate.set()
|
|
assert cancel_route.called, "browser disconnect must cancel the UPSTREAM turn (42)"
|
|
|
|
|
|
class TestWebBifrostBind:
|
|
"""Issue #17 slice 3c — web bind split: the browser selects the PLANE; the
|
|
consumer key + visible host are SERVER-HELD and never reach the browser
|
|
(INV-008/INV-009)."""
|
|
|
|
@respx.mock
|
|
def test_bound_create_server_constructs_binding_key_never_leaks(self) -> None:
|
|
"""tracer: a plane from the browser → the server builds the binding with
|
|
its OWN consumer key + host, sends the bifrost body + consumer-key bearer
|
|
upstream, and returns bound-state WITHOUT the key."""
|
|
import json as _json
|
|
|
|
from ratatoskr.web.server import create_app
|
|
|
|
route = respx.post("https://w.example/sessions").mock(
|
|
return_value=httpx.Response(201, json=_CREATE_OK)
|
|
)
|
|
app = create_app(
|
|
_mock_client_factory(),
|
|
bifrost_consumer_key="server-ck",
|
|
bifrost_visible_host="10.100.10.50",
|
|
)
|
|
resp = TestClient(app).post(
|
|
"/api/sessions", json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "memory"}
|
|
)
|
|
assert resp.status_code == 201
|
|
# bound-state echoed for the UI indicator — plane + endpoint, NO key
|
|
assert resp.json()["bifrost"] == {
|
|
"plane": "memory",
|
|
"endpoint": "http://10.100.10.50:8391",
|
|
"status": "bound",
|
|
}
|
|
assert "server-ck" not in resp.text # the key never reaches the browser
|
|
# upstream got the bifrost body + the consumer-key bearer override
|
|
upstream = route.calls[0].request
|
|
body = _json.loads(upstream.content)
|
|
assert body["bifrost"] == {
|
|
"endpoint_url": "http://10.100.10.50:8391", "scope": None
|
|
}
|
|
assert upstream.headers["Authorization"] == "Bearer server-ck"
|
|
|
|
@respx.mock
|
|
def test_combined_plane_binds_to_8392(self) -> None:
|
|
"""combined [#18 composite]: a 'combined' plane from the browser → the server
|
|
binds the :8392 both-plane endpoint; bound-state echoes plane='combined'."""
|
|
import json as _json
|
|
|
|
from ratatoskr.web.server import create_app
|
|
|
|
route = respx.post("https://w.example/sessions").mock(
|
|
return_value=httpx.Response(201, json=_CREATE_OK)
|
|
)
|
|
app = create_app(
|
|
_mock_client_factory(),
|
|
bifrost_consumer_key="server-ck",
|
|
bifrost_visible_host="10.100.10.50",
|
|
)
|
|
resp = TestClient(app).post(
|
|
"/api/sessions",
|
|
json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "combined"},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["bifrost"] == {
|
|
"plane": "combined",
|
|
"endpoint": "http://10.100.10.50:8392",
|
|
"status": "bound",
|
|
}
|
|
upstream = route.calls[0].request
|
|
body = _json.loads(upstream.content)
|
|
assert body["bifrost"] == {
|
|
"endpoint_url": "http://10.100.10.50:8392",
|
|
"scope": None,
|
|
}
|
|
|
|
def test_dropdown_offers_combined_as_default(self) -> None:
|
|
"""(a)+default: the SPA plane dropdown offers a 'combined' (:8392) option,
|
|
it is the DEFAULT-selected one, and single-plane memory/affect remain."""
|
|
from pathlib import Path
|
|
|
|
import ratatoskr.web as web_pkg
|
|
|
|
html = (Path(web_pkg.__file__).parent / "static" / "index.html").read_text()
|
|
assert '<option value="combined" selected>' in html
|
|
assert 'value="memory"' in html and 'value="affect"' in html
|
|
|
|
@respx.mock
|
|
def test_plane_without_server_config_is_400(self) -> None:
|
|
"""A plane requested but no server-held key/host → bifrost_not_configured."""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
app = create_app(_mock_client_factory()) # no bifrost config
|
|
resp = TestClient(app).post(
|
|
"/api/sessions", json={"agent_id": "a", "bifrost_plane": "memory"}
|
|
)
|
|
assert resp.status_code == 400
|
|
assert resp.json()["error_code"] == "bifrost_not_configured"
|
|
|
|
def test_invalid_plane_is_400(self) -> None:
|
|
from ratatoskr.web.server import create_app
|
|
|
|
app = create_app(
|
|
_mock_client_factory(),
|
|
bifrost_consumer_key="ck",
|
|
bifrost_visible_host="h",
|
|
)
|
|
resp = TestClient(app).post(
|
|
"/api/sessions", json={"agent_id": "a", "bifrost_plane": "persona"}
|
|
)
|
|
assert resp.status_code == 400
|
|
assert resp.json()["error_code"] == "invalid_bifrost_plane"
|
|
|
|
@respx.mock
|
|
def test_handshake_failure_is_502(self) -> None:
|
|
from ratatoskr.web.server import create_app
|
|
|
|
respx.post("https://w.example/sessions").mock(
|
|
return_value=httpx.Response(
|
|
502,
|
|
json={
|
|
"error_code": "bifrost_handshake_failed",
|
|
"detail": {"bifrost_error": "bifrost.auth_rejected"},
|
|
},
|
|
)
|
|
)
|
|
app = create_app(
|
|
_mock_client_factory(),
|
|
bifrost_consumer_key="ck",
|
|
bifrost_visible_host="h",
|
|
)
|
|
resp = TestClient(app).post(
|
|
"/api/sessions", json={"agent_id": "a", "bifrost_plane": "memory"}
|
|
)
|
|
assert resp.status_code == 502
|
|
assert resp.json()["error_code"] == "bifrost_handshake_failed"
|
|
assert resp.json()["bifrost_error"] == "bifrost.auth_rejected"
|
|
|
|
@respx.mock
|
|
def test_no_plane_is_unbound_no_bifrost_in_response(self) -> None:
|
|
"""regression: no bifrost_plane → pre-#17 unbound create, no bifrost key."""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
respx.post("https://w.example/sessions").mock(
|
|
return_value=httpx.Response(201, json=_CREATE_OK)
|
|
)
|
|
app = create_app(
|
|
_mock_client_factory(),
|
|
bifrost_consumer_key="ck",
|
|
bifrost_visible_host="h",
|
|
)
|
|
resp = TestClient(app).post("/api/sessions", json={"agent_id": "mimir"})
|
|
assert resp.status_code == 201
|
|
assert "bifrost" not in resp.json()
|
|
|
|
|
|
class TestAffectStateEndpoint:
|
|
"""affect_state_endpoint FN — #18 Deliverable 2: web proxy to the provider PAD read."""
|
|
|
|
@respx.mock
|
|
def test_happy_proxies_and_supplies_server_end_user_id(self) -> None:
|
|
"""tracer: GET /api/affect/{id} → proxies to the configured provider read URL,
|
|
supplying end_user_id SERVER-SIDE (INV-002); colon-id round-trips (INV-008)."""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
snap = {
|
|
"agent_id": "ratatoskr:sindra",
|
|
"pad": {"pleasure": 0.15, "arousal": 0.08, "dominance": -0.01},
|
|
"valence": [{"entity_id": "ratatoskr", "familiarity": 0.59, "regard": 0.15}],
|
|
"emitted_at": "2026-06-18T15:58:12+00:00",
|
|
}
|
|
route = respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
|
|
return_value=httpx.Response(200, json=snap)
|
|
)
|
|
app = create_app(
|
|
_mock_client_factory(),
|
|
end_user_id="vuong",
|
|
affect_read_url="http://prov:8390",
|
|
)
|
|
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == snap
|
|
assert route.calls.last.request.url.params["end_user_id"] == "vuong"
|
|
# INV-008: the colon-id round-trips into the provider path — whether the wire
|
|
# keeps %3A or normalizes it, it must unquote back to the exact agent_id.
|
|
from urllib.parse import unquote
|
|
seg = str(route.calls.last.request.url).split("/affect/state/")[1].split("?")[0]
|
|
assert unquote(seg) == "ratatoskr:sindra"
|
|
|
|
@respx.mock
|
|
def test_browser_supplied_end_user_id_is_ignored(self) -> None:
|
|
"""INV-002: a browser-supplied end_user_id query is IGNORED; the server's
|
|
configured partition is used."""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
route = respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
|
|
return_value=httpx.Response(200, json={"agent_id": "ratatoskr:sindra"})
|
|
)
|
|
app = create_app(
|
|
_mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
|
|
)
|
|
TestClient(app).get("/api/affect/ratatoskr:sindra?end_user_id=attacker")
|
|
assert route.calls.last.request.url.params["end_user_id"] == "vuong"
|
|
|
|
def test_unconfigured_returns_400(self) -> None:
|
|
"""PRE-001: no affect_read_url → 400 affect_not_configured (no silent attempt)."""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
app = create_app(_mock_client_factory(), end_user_id="vuong") # no affect_read_url
|
|
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
|
|
assert resp.status_code == 400
|
|
assert resp.json()["error_code"] == "affect_not_configured"
|
|
|
|
def test_no_end_user_configured_returns_400(self) -> None:
|
|
"""PRE-001: affect_read_url set but server end_user_id unset → 400 (INV-003
|
|
fail-visible, never a silent empty)."""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
app = create_app(_mock_client_factory(), affect_read_url="http://prov:8390")
|
|
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
|
|
assert resp.status_code == 400
|
|
assert resp.json()["error_code"] == "affect_not_configured"
|
|
|
|
@respx.mock
|
|
def test_provider_unreachable_returns_502(self) -> None:
|
|
"""POST-003: a network error reaching the provider → 502 affect_provider_unreachable."""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
|
|
side_effect=httpx.ConnectError("refused")
|
|
)
|
|
app = create_app(
|
|
_mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
|
|
)
|
|
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
|
|
assert resp.status_code == 502
|
|
assert resp.json()["error_code"] == "affect_provider_unreachable"
|
|
|
|
@respx.mock
|
|
def test_provider_404_passes_through(self) -> None:
|
|
"""POST-002: provider no_affect_snapshot 404 surfaces to the browser verbatim."""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
|
|
return_value=httpx.Response(404, json={"error_code": "no_affect_snapshot"})
|
|
)
|
|
app = create_app(
|
|
_mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
|
|
)
|
|
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
|
|
assert resp.status_code == 404
|
|
assert resp.json()["error_code"] == "no_affect_snapshot"
|
|
|
|
@respx.mock
|
|
def test_provider_400_passes_through(self) -> None:
|
|
"""POST-002: a provider 400 (e.g. missing_end_user_id — unreachable in normal
|
|
flow since the proxy always supplies it) still passes through verbatim."""
|
|
from ratatoskr.web.server import create_app
|
|
|
|
respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
|
|
return_value=httpx.Response(400, json={"error_code": "missing_end_user_id"})
|
|
)
|
|
app = create_app(
|
|
_mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
|
|
)
|
|
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
|
|
assert resp.status_code == 400
|
|
assert resp.json()["error_code"] == "missing_end_user_id"
|