feat(sessions): implement issue #2 contract via TDD

Implements docs/contracts/issues/2.contract.md. Two functions
(create_session, list_sessions), two frozen dataclasses (SessionInfo,
SessionPage), three exception types (AgentNotFound, InvalidCursor,
SessionApiFailed). 19 contract-listed tests cover every TESTS:
entry verbatim per the tracer-bullet vertical-slice ordering.

SessionInfo uses one shape across both endpoints with origin-
conditional defaults per INV-001 (create) and INV-002 (list). create-
origin always sets list-only fields to (name=None, archived=False,
tags=[]); list-origin reads them from the response item with
absent/null treated as those same defaults — keeps the dataclass
uniform without forcing callers to handle two types.

Spotted an internal-inconsistency in the contract at TDD start —
POST-003 and happy_create's test description still said "archived
is None, tags is None" while the freshly-applied Volva amendment
had moved INV-001 to (archived=False, tags=[]). Fixed in-place
before writing any tests so the spec stayed coherent.

SessionApiFailed.body truncates to <= 1024 bytes at construction,
matching the SseConnectFailed / CancelFailed precedent from issue #1.

No code shared with sse_client.py (convention-dependency only per
issue #2's dependencies: block). 62 tests GREEN total (42 sse_client
+ 19 sessions + 1 boundary smoke). Ruff clean.

No refactor pass — the two functions are ~25 LOC each with distinct
error-routing branches that don't naturally share more than they
already do.
This commit is contained in:
vh
2026-05-20 22:01:05 -07:00
parent a6e6c1bbd8
commit 4ba143c563
4 changed files with 475 additions and 5 deletions
+327
View File
@@ -0,0 +1,327 @@
"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md."""
import httpx
import pytest
import respx
from ratatoskr.sessions import (
AgentNotFound,
InvalidCursor,
SessionApiFailed,
SessionPage,
create_session,
list_sessions,
)
class TestCreateSession:
@respx.mock
async def test_happy_create(self) -> None:
"""happy_create [happy,tracer]: full 201 body -> SessionInfo with create-origin defaults."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(client, "mimir")
assert info.session_id == "550e8400-e29b-41d4-a716-446655440000"
assert info.agent_id == "mimir"
assert info.created_at == "2026-04-15T12:00:00+00:00"
assert info.last_active == "2026-04-15T12:00:00+00:00"
assert info.metadata == {}
assert info.message_count == 0
# INV-001 create-origin fixed defaults
assert info.name is None
assert info.archived is False
assert info.tags == []
@respx.mock
async def test_happy_create_with_metadata(self) -> None:
"""happy_create_with_metadata: response carries metadata -> SessionInfo.metadata matches."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s1",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:00:00+00:00",
"metadata": {"model": "glm5-turbo"},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(client, "mimir")
assert info.metadata == {"model": "glm5-turbo"}
@respx.mock
async def test_request_body_shape(self) -> None:
"""request_body_shape [trace]: outbound JSON is exactly {"agent_id": <arg>}."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s1",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await create_session(client, "mimir")
body = _json.loads(route.calls[0].request.content)
assert body == {"agent_id": "mimir"}
@respx.mock
async def test_unknown_agent_id(self) -> None:
"""unknown_agent_id: 404 -> AgentNotFound(agent_id=<arg>)."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AgentNotFound) as exc_info:
await create_session(client, "mimir")
assert exc_info.value.agent_id == "mimir"
@respx.mock
async def test_validation_failed(self) -> None:
"""validation_failed: 422 -> SessionApiFailed(status=422); body truncated."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422,
json={"error_code": "validation_failed", "message": "missing agent_id"},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await create_session(client, "mimir")
assert exc_info.value.status == 422
assert len(exc_info.value.body) <= 1024
@respx.mock
async def test_unexpected_status_truncates(self) -> None:
"""unexpected_status_truncates: 500 + 5000-byte body -> SessionApiFailed; body == 1024."""
big = b"x" * 5000
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(500, content=big)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await create_session(client, "mimir")
assert exc_info.value.status == 500
assert exc_info.value.body == big[:1024]
@respx.mock
async def test_empty_agent_id(self) -> None:
"""empty_agent_id [adversarial]: '' -> AssertionError; no HTTP issued."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await create_session(client, "")
assert route.call_count == 0
def _list_item(
*,
session_id: str = "s1",
agent_id: str = "mimir",
created_at: str = "2026-04-15T12:00:00+00:00",
last_active: str = "2026-04-15T12:05:00+00:00",
metadata: dict[str, object] | None = None,
name: str | None = "Research session",
archived: bool = False,
tags: list[str] | None = None,
) -> dict[str, object]:
"""Build a GET /sessions list-item body for tests."""
item: dict[str, object] = {
"session_id": session_id,
"agent_id": agent_id,
"created_at": created_at,
"last_active": last_active,
"metadata": metadata if metadata is not None else {},
"name": name,
"archived": archived,
"tags": tags if tags is not None else ["work"],
}
return item
class TestListSessions:
@respx.mock
async def test_happy_first_page(self) -> None:
"""happy_first_page [happy,tracer]: one item + next_cursor -> SessionPage shape."""
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200,
json={
"items": [_list_item()],
"next_cursor": "v1.eyJhYmMifQ",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
page = await list_sessions(client)
assert isinstance(page, SessionPage)
assert len(page.items) == 1
assert page.next_cursor == "v1.eyJhYmMifQ"
info = page.items[0]
assert info.session_id == "s1"
assert info.message_count is None # INV-002: not in list response
assert info.name == "Research session"
assert info.archived is False
assert info.tags == ["work"]
@respx.mock
async def test_happy_last_page(self) -> None:
"""happy_last_page: next_cursor=null -> SessionPage(next_cursor=None)."""
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200,
json={"items": [_list_item()], "next_cursor": None},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
page = await list_sessions(client)
assert page.next_cursor is None
@respx.mock
async def test_empty_results(self) -> None:
"""empty_results: {items: [], next_cursor: null} -> SessionPage([], None)."""
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
page = await list_sessions(client)
assert page == SessionPage(items=[], next_cursor=None)
@respx.mock
async def test_include_archived_query(self) -> None:
"""include_archived_query: True -> has param; default -> NO param at all."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200, json={"items": [], "next_cursor": None}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await list_sessions(client, include_archived=True)
await list_sessions(client) # default
url_with = str(route.calls[0].request.url)
url_default = str(route.calls[1].request.url)
assert "include_archived=true" in url_with
assert "include_archived" not in url_default
@respx.mock
async def test_cursor_threaded(self) -> None:
"""cursor_threaded: cursor=opaque -> URL has cursor=opaque."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200, json={"items": [], "next_cursor": None}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await list_sessions(client, cursor="opaque-from-prev-page")
assert "cursor=opaque-from-prev-page" in str(route.calls[0].request.url)
@respx.mock
async def test_limit_query(self) -> None:
"""limit_query: limit=10 -> URL has limit=10."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200, json={"items": [], "next_cursor": None}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await list_sessions(client, limit=10)
assert "limit=10" in str(route.calls[0].request.url)
@respx.mock
async def test_invalid_cursor_server(self) -> None:
"""invalid_cursor_server: 422 cursor_invalid -> InvalidCursor(raw=<passed cursor>)."""
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
422,
json={"error_code": "cursor_invalid", "message": "bad cursor"},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(InvalidCursor) as exc_info:
await list_sessions(client, cursor="bogus")
assert exc_info.value.raw == "bogus"
@respx.mock
async def test_other_validation_failed(self) -> None:
"""other_validation_failed: 422 other error_code -> SessionApiFailed(422); truncated."""
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
422,
json={"error_code": "validation_failed", "message": "limit out of range"},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await list_sessions(client)
assert exc_info.value.status == 422
assert len(exc_info.value.body) <= 1024
@respx.mock
async def test_unexpected_status_truncates(self) -> None:
"""unexpected_status_truncates: 500 + 5000-byte body -> SessionApiFailed; body == 1024."""
big = b"x" * 5000
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(500, content=big)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await list_sessions(client)
assert exc_info.value.status == 500
assert exc_info.value.body == big[:1024]
@respx.mock
async def test_limit_below_one(self) -> None:
"""limit_below_one [adversarial]: limit=0 -> AssertionError; no HTTP."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await list_sessions(client, limit=0)
assert route.call_count == 0
@respx.mock
async def test_limit_above_max(self) -> None:
"""limit_above_max [adversarial]: limit=300 -> AssertionError; no HTTP."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await list_sessions(client, limit=300)
assert route.call_count == 0
@respx.mock
async def test_empty_cursor(self) -> None:
"""empty_cursor [adversarial]: cursor='' -> AssertionError; no HTTP."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await list_sessions(client, cursor="")
assert route.call_count == 0