deab7627eb
Slice-5 of the worldtree-sdk cutover: migrate the remaining consumer READS + transient-character CRUD off the hand-rolled httpx wrappers onto the `ratatoskr.wt` adapter over the SDK, and delete the retired path. Adapter (`wt.py`): add `get_me` / `get_capabilities` / `list_character_models` / `create_character` / `get_character_state` / `delete_character` over `client.me` / `client.capabilities` / `client.models` / `client.characters.*`. All six are open-world reads/acks returned verbatim; none carries a discriminated SDK error, so each maps any `ApiError` → the `SessionApiFailed` default (INV-CUT-2) — exact parity with the retired path. No new Error-map rows. Decisions (contract § slice-5 notes): `create_character` omits `state` when None (SDK-idiomatic inline literal, server-equivalent to the retired explicit null); `delete_character` returns the SDK's open ACK verbatim (`-> Mapping|None`, not normalized to None). CLI rewire (`cli.py`): `--whoami` (me + capabilities) and `--characters` (models → create → state → delete) build a `wt.build_client` over the injected probe transport and catch `wt.SessionApiFailed` + `ConnectFailed`. Open-world degrade-not-crash carried (cumulative cutover foot-gun): `_characters_probe` reads `items` null-safe and extracts `character_id` defensively (clean abort, no hard-index KeyError); `_format_whoami` widened to `Mapping`. Deleted the six hand-rolled `sessions.py` wrappers (net -5 mypy no-any-return); `endpoint_for_plane` + `get_session_bifrost` (slice-6) + the exception classes stay. Retired the corresponding `test_sessions.py` classes; added the slice-5 adapter tests + a CLI malformed-create-abort test. LIVE SMOKE (:8081, b128) — INV-CUT-5 / DEC-4 cleared: `--whoami` rendered real identity + capabilities; `--characters` drove the full lifecycle end-to-end (char-rp catalog → created char_8c00006e… → PAD read-back → deleted). Suite 483 green; ruff clean; mypy at the 2 pre-existing baseline errors. Patch bump 0.21.15 → 0.21.16 (the cutover MINOR is deferred to slice-7, DEC-6).
95 lines
4.2 KiB
Python
95 lines
4.2 KiB
Python
"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md."""
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from ratatoskr.sessions import (
|
|
SessionApiFailed,
|
|
endpoint_for_plane,
|
|
get_session_bifrost,
|
|
)
|
|
|
|
|
|
class TestEndpointForPlane:
|
|
"""Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL."""
|
|
|
|
def test_memory_plane_maps_to_8391(self) -> None:
|
|
"""memory [tracer]: 'memory' → http://<host>:8391 (POST-001)."""
|
|
assert endpoint_for_plane("memory", "10.100.10.50") == "http://10.100.10.50:8391"
|
|
|
|
def test_affect_plane_maps_to_8390(self) -> None:
|
|
"""affect: 'affect' → http://<host>:8390 (POST-001)."""
|
|
assert endpoint_for_plane("affect", "10.100.10.50") == "http://10.100.10.50:8390"
|
|
|
|
def test_combined_plane_maps_to_8392(self) -> None:
|
|
"""combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001)."""
|
|
assert endpoint_for_plane("combined", "10.100.10.50") == "http://10.100.10.50:8392"
|
|
|
|
def test_unknown_plane_raises_value_error(self) -> None:
|
|
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
|
|
with pytest.raises(ValueError):
|
|
endpoint_for_plane("persona", "10.100.10.50")
|
|
|
|
|
|
class TestGetSessionBifrost:
|
|
"""#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176)."""
|
|
|
|
@respx.mock
|
|
async def test_happy_uses_admin_bearer(self) -> None:
|
|
"""happy [happy,tracer]: 200 → binding dict; request carries the ADMIN bearer (override)."""
|
|
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"endpoint_url": "https://bifrost.example/mcp",
|
|
"consumer_id": "alice",
|
|
"connected": True,
|
|
"capabilities_granted": ["tools:call", "tools:read"],
|
|
"tools": [{"name": "bifrost.alice.echo", "description": "echo"}],
|
|
},
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(
|
|
base_url="https://w.example",
|
|
headers={"Authorization": "Bearer consumer-key"},
|
|
) as client:
|
|
state = await get_session_bifrost(client, "s1", admin_key="admin-xyz")
|
|
assert state["connected"] is True
|
|
assert state["tools"][0]["name"] == "bifrost.alice.echo"
|
|
# the request overrode the client's default consumer bearer with the admin key
|
|
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
|
|
|
|
@respx.mock
|
|
async def test_403_scope_denied(self) -> None:
|
|
"""403 [error]: admin key lacks admin.sessions.read → SessionApiFailed(403)."""
|
|
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
|
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(SessionApiFailed) as exc:
|
|
await get_session_bifrost(client, "s1", admin_key="k")
|
|
assert exc.value.status == 403
|
|
|
|
@respx.mock
|
|
async def test_404_not_bound(self) -> None:
|
|
"""404 [error]: session_not_bifrost_bound → SessionApiFailed(404)."""
|
|
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
|
return_value=httpx.Response(404, json={"error_code": "session_not_bifrost_bound"})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(SessionApiFailed) as exc:
|
|
await get_session_bifrost(client, "s1", admin_key="k")
|
|
assert exc.value.status == 404
|
|
|
|
@respx.mock
|
|
async def test_empty_admin_key_asserts(self) -> None:
|
|
"""empty_admin_key [adversarial]: '' → AssertionError; no HTTP issued."""
|
|
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
|
return_value=httpx.Response(200, json={})
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(AssertionError):
|
|
await get_session_bifrost(client, "s1", admin_key="")
|
|
assert route.call_count == 0
|