"""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": }.""" 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=).""" 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_explicit_null_list_defaults(self) -> None: """INV-002: explicit-null archived -> False; explicit-null tags -> [].""" raw_item = { "session_id": "s1", "agent_id": "mimir", "created_at": "2026-04-15T12:00:00+00:00", "last_active": "2026-04-15T12:05:00+00:00", "metadata": {}, "name": None, "archived": None, "tags": None, } respx.get("https://w.example/sessions").mock( return_value=httpx.Response( 200, json={"items": [raw_item], "next_cursor": None} ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: page = await list_sessions(client) info = page.items[0] assert info.archived is False, "explicit-null archived must default to False" assert info.tags == [], "explicit-null tags must default to []" assert info.name is None @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=).""" 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