d30be12deb
Adds GET /agents fetch + ListView picker for bare `--new` (TUI mode without --agent). Three in-place amendments: - ratatoskr.sessions: new `list_agents()` + `AgentInfo` frozen dataclass with omit-when-null/empty defaults mirroring SessionInfo's INV-001/INV-002 origin-conditional pattern. Non-200 responses raise the existing SessionApiFailed (no new exception). - ratatoskr.cli: `_parse_args` softens `--agent` from absolute to mode-conditional — required for `--send --new`, optional for bare `--new`, forbidden with `--session` (unchanged INV-004). - ratatoskr.tui: new `AgentPickerApp(App[str | None])` — separate Textual App (not Screen-within-RatatoskrApp) so list_agents errors land on real stderr before any alt-screen opens (preserves issue #6's INV-001). `_resolve_then_run` gains a pre-create branch: fetch agents → empty list → exit 13; non-200 → exit 20; network error → exit 21; picker dismissed → exit 0; otherwise thread chosen agent_id into create_session. Contract: docs/contracts/issues/8.contract.md (drift-check clean). Tests: +18 (227 total, was 209). Live smoke against personal Worldtree (:8081) returned 12 agents; programmatic picker drive auto-picked lofn and created a real session with `end_user_id="ratatoskr-tui"`.
559 lines
23 KiB
Python
559 lines
23 KiB
Python
"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md."""
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from ratatoskr.sessions import (
|
|
AgentInfo,
|
|
AgentNotFound,
|
|
InvalidCursor,
|
|
SessionApiFailed,
|
|
SessionPage,
|
|
create_session,
|
|
list_agents,
|
|
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
|
|
|
|
@respx.mock
|
|
async def test_happy_create_with_end_user_id(self) -> None:
|
|
"""happy_create_with_end_user_id [happy]: body carries both keys (issue #5)."""
|
|
import json as _json
|
|
|
|
route = respx.post("https://w.example/sessions").mock(
|
|
return_value=httpx.Response(
|
|
201,
|
|
json={
|
|
"session_id": "s-new",
|
|
"agent_id": "lofn",
|
|
"message_count": 0,
|
|
"created_at": "2026-05-22T12:00:00+00:00",
|
|
"last_active": "2026-05-22T12:00:00+00:00",
|
|
"metadata": {},
|
|
},
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
info = await create_session(client, "lofn", end_user_id="alice")
|
|
body = _json.loads(route.calls[0].request.content)
|
|
# INV: body MUST be exactly {"agent_id": ..., "end_user_id": ...} — byte-for-byte
|
|
assert body == {"agent_id": "lofn", "end_user_id": "alice"}
|
|
assert info.session_id == "s-new"
|
|
assert info.agent_id == "lofn"
|
|
|
|
@respx.mock
|
|
async def test_default_omits_end_user_id(self) -> None:
|
|
"""default_omits_end_user_id [trace]: omit kwarg → body has no end_user_id (INV-002)."""
|
|
import json as _json
|
|
|
|
route = respx.post("https://w.example/sessions").mock(
|
|
return_value=httpx.Response(
|
|
201,
|
|
json={
|
|
"session_id": "s-new",
|
|
"agent_id": "mimir",
|
|
"message_count": 0,
|
|
"created_at": "2026-05-22T12:00:00+00:00",
|
|
"last_active": "2026-05-22T12: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)
|
|
# Exact equality — no end_user_id key in the body when the kwarg is omitted
|
|
assert body == {"agent_id": "mimir"}
|
|
assert "end_user_id" not in body
|
|
|
|
@respx.mock
|
|
async def test_empty_end_user_id(self) -> None:
|
|
"""empty_end_user_id [adversarial]: '' → AssertionError before HTTP (PRE-003)."""
|
|
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, "mimir", end_user_id="")
|
|
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=<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
|
|
|
|
|
|
# ---- Issue #8: list_agents + AgentInfo --------------------------------------
|
|
|
|
|
|
class TestListAgents:
|
|
@respx.mock
|
|
async def test_happy_full_shape(self) -> None:
|
|
"""happy_full_shape [happy,tracer]: spec full-shape mimir example → all fields."""
|
|
respx.get("https://w.example/agents").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json=[
|
|
{
|
|
"agent_id": "mimir",
|
|
"name": "Mimir",
|
|
"description": "Keeper of the Well of Knowledge.",
|
|
"version": "0.2.0",
|
|
"capabilities": ["knowledge_base", "semantic_search"],
|
|
"supported_models": ["default", "heavy"],
|
|
"persona_traits": {
|
|
"ocean": {
|
|
"openness": 0.7,
|
|
"conscientiousness": 0.9,
|
|
"extraversion": 0.1,
|
|
"agreeableness": 0.5,
|
|
"neuroticism": 0.3,
|
|
},
|
|
"vibe": "contemplative",
|
|
},
|
|
"ui_hints": {"icon": "well", "color_hint": "#5b8aa3"},
|
|
}
|
|
],
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
agents = await list_agents(client)
|
|
assert len(agents) == 1
|
|
a = agents[0]
|
|
assert isinstance(a, AgentInfo)
|
|
assert a.agent_id == "mimir"
|
|
assert a.name == "Mimir"
|
|
assert a.description == "Keeper of the Well of Knowledge."
|
|
assert a.version == "0.2.0"
|
|
assert a.capabilities == ["knowledge_base", "semantic_search"]
|
|
assert a.supported_models == ["default", "heavy"]
|
|
assert a.persona_traits["vibe"] == "contemplative"
|
|
assert a.ui_hints["icon"] == "well"
|
|
|
|
@respx.mock
|
|
async def test_happy_minimum_shape(self) -> None:
|
|
"""happy_minimum_shape: required-only agent → optional fields default."""
|
|
respx.get("https://w.example/agents").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json=[
|
|
{
|
|
"agent_id": "minimal",
|
|
"name": "Minimal Agent",
|
|
"description": "Just a sketch.",
|
|
}
|
|
],
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
agents = await list_agents(client)
|
|
a = agents[0]
|
|
assert a.agent_id == "minimal"
|
|
assert a.version is None
|
|
assert a.capabilities == []
|
|
assert a.supported_models == []
|
|
assert a.persona_traits == {}
|
|
assert a.ui_hints == {}
|
|
|
|
@respx.mock
|
|
async def test_happy_multi_agent(self) -> None:
|
|
"""happy_multi_agent: 3 agents preserve order."""
|
|
respx.get("https://w.example/agents").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json=[
|
|
{"agent_id": "a", "name": "A", "description": "x"},
|
|
{"agent_id": "b", "name": "B", "description": "y"},
|
|
{"agent_id": "c", "name": "C", "description": "z"},
|
|
],
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
agents = await list_agents(client)
|
|
assert [a.agent_id for a in agents] == ["a", "b", "c"]
|
|
|
|
@respx.mock
|
|
async def test_happy_empty(self) -> None:
|
|
"""happy_empty: 200 with [] returns empty list (no error)."""
|
|
respx.get("https://w.example/agents").mock(
|
|
return_value=httpx.Response(200, json=[])
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
agents = await list_agents(client)
|
|
assert agents == []
|
|
|
|
@respx.mock
|
|
async def test_omit_capabilities_empty_list(self) -> None:
|
|
"""omit_capabilities_empty: explicit [] from server still defaults to []."""
|
|
respx.get("https://w.example/agents").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json=[
|
|
{
|
|
"agent_id": "a",
|
|
"name": "A",
|
|
"description": "x",
|
|
"capabilities": [],
|
|
}
|
|
],
|
|
)
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
agents = await list_agents(client)
|
|
assert agents[0].capabilities == []
|
|
|
|
@respx.mock
|
|
async def test_500_raises_session_api_failed(self) -> None:
|
|
"""500 → SessionApiFailed with status=500."""
|
|
respx.get("https://w.example/agents").mock(
|
|
return_value=httpx.Response(500, content=b"oops")
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(SessionApiFailed) as excinfo:
|
|
await list_agents(client)
|
|
assert excinfo.value.status == 500
|
|
|
|
@respx.mock
|
|
async def test_401_raises_session_api_failed(self) -> None:
|
|
"""401 → SessionApiFailed with status=401."""
|
|
respx.get("https://w.example/agents").mock(
|
|
return_value=httpx.Response(401, content=b'{"error":"unauthorized"}')
|
|
)
|
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
with pytest.raises(SessionApiFailed) as excinfo:
|
|
await list_agents(client)
|
|
assert excinfo.value.status == 401
|