"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md.""" import httpx import pytest import respx from ratatoskr.sessions import ( SessionApiFailed, create_character, delete_character, endpoint_for_plane, get_capabilities, get_character_state, get_me, get_session_bifrost, list_character_models, ) 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://: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://: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://: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 TestGetMe: """docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me).""" @respx.mock async def test_happy_authenticated(self) -> None: """happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim.""" respx.get("https://w.example/me").mock( return_value=httpx.Response( 200, json={ "user_id": "alice", "scopes": ["conversations.read", "conversations.write"], "tier": "user", "key_id": "a1b2c3d4", "key_label": "alice phone", }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: me = await get_me(client) assert me["user_id"] == "alice" assert me["tier"] == "user" assert me["key_id"] == "a1b2c3d4" assert me["scopes"] == ["conversations.read", "conversations.write"] @respx.mock async def test_anonymous_dev_mode(self) -> None: """anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous.""" respx.get("https://w.example/me").mock( return_value=httpx.Response( 200, json={ "user_id": "anonymous", "scopes": ["conversations.read"], "tier": "anonymous", }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: me = await get_me(client) assert me["tier"] == "anonymous" assert "key_id" not in me # optional fields omitted, not null @respx.mock async def test_401_raises_session_api_failed(self) -> None: """401_raises [error]: bad/absent key → SessionApiFailed(status=401).""" respx.get("https://w.example/me").mock( return_value=httpx.Response(401, json={"detail": "auth_invalid"}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc: await get_me(client) assert exc.value.status == 401 class TestGetCapabilities: """docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me).""" @respx.mock async def test_happy(self) -> None: """happy [happy]: 200 → ephemeral_templates dict verbatim.""" respx.get("https://w.example/capabilities").mock( return_value=httpx.Response( 200, json={ "ephemeral_templates": { "echo": { "allowed_models": ["glm5-turbo", "glm4.7"], "default_model": "glm5-turbo", "system_prompt_max_bytes": 32768, } } }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: caps = await get_capabilities(client) echo = caps["ephemeral_templates"]["echo"] assert echo["default_model"] == "glm5-turbo" assert echo["system_prompt_max_bytes"] == 32768 @respx.mock async def test_non_200_raises(self) -> None: """non_200_raises [error]: 500 → SessionApiFailed(status=500).""" respx.get("https://w.example/capabilities").mock( return_value=httpx.Response(500, content=b"boom") ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc: await get_capabilities(client) assert exc.value.status == 500 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 class TestTransientCharacters: """docs/contracts/issues/2.contract.md — transient-character wrappers (#161).""" @respx.mock async def test_list_models(self) -> None: """list_models [happy,tracer]: 200 → {items:[...]} verbatim.""" respx.get("https://w.example/models/available-for-characters").mock( return_value=httpx.Response(200, json={"items": [{"name": "fast", "thinking": False}]}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: models = await list_character_models(client) assert models["items"][0]["name"] == "fast" @respx.mock async def test_create_body_and_response(self) -> None: """create [happy]: body is {character, state}; 201 → {character_id, ttl_expires_at}.""" import json as _json route = respx.post("https://w.example/characters").mock( return_value=httpx.Response(201, json={"character_id": "char_x", "ttl_expires_at": "t"}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: out = await create_character(client, {"schema_version": "1", "name": "H"}) assert out["character_id"] == "char_x" body = _json.loads(route.calls[0].request.content) assert body == {"character": {"schema_version": "1", "name": "H"}, "state": None} @respx.mock async def test_get_state(self) -> None: """get_state [happy]: 200 → live PAD/emotions snapshot.""" respx.get("https://w.example/characters/char_x/state").mock( return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.4, 0.1, -0.2]}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: state = await get_character_state(client, "char_x") assert state["pad"] == [0.4, 0.1, -0.2] @respx.mock async def test_delete_204(self) -> None: """delete [happy]: 204 → None.""" respx.delete("https://w.example/characters/char_x").mock(return_value=httpx.Response(204)) async with httpx.AsyncClient(base_url="https://w.example") as client: assert await delete_character(client, "char_x") is None @respx.mock async def test_create_403_scope(self) -> None: """create_403 [error]: key lacks character.write → SessionApiFailed(403).""" respx.post("https://w.example/characters").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 create_character(client, {"name": "H"}) assert exc.value.status == 403