theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

test_hermes_client.py (16879B)


      1 """Tests for Hermes client — mocking aiohttp (the layer below), not _request."""
      2 
      3 import asyncio
      4 from unittest.mock import AsyncMock, MagicMock, patch
      5 
      6 import pytest
      7 from aiohttp import ClientError, ClientResponseError, RequestInfo
      8 
      9 from app.hermes_client import (
     10     HermesClient,
     11     _normalize_message,
     12     _normalize_model,
     13     _normalize_session,
     14     _unwrap_hermes_response,
     15 )
     16 
     17 @pytest.fixture
     18 def client() -> HermesClient:
     19     return HermesClient(base_url="http://localhost:8642/", api_key="sk-test-key")
     20 
     21 
     22 def _mock_response(status: int = 200, json_data=None):
     23     """Build a mock aiohttp response."""
     24     resp = AsyncMock()
     25     resp.status = status
     26     resp.json = AsyncMock(return_value=json_data or {})
     27     resp.raise_for_status = MagicMock()
     28     if status >= 400:
     29         resp.raise_for_status = MagicMock(
     30             side_effect=ClientResponseError(
     31                 request_info=MagicMock(), history=(), status=status
     32             )
     33         )
     34     resp.__aenter__ = AsyncMock(return_value=resp)
     35     resp.__aexit__ = AsyncMock(return_value=False)
     36     return resp
     37 
     38 
     39 def _mock_session(response):
     40     """Build a mock aiohttp ClientSession that returns the given response."""
     41     session = AsyncMock()
     42     session.request = MagicMock(return_value=response)
     43     session.__aenter__ = AsyncMock(return_value=session)
     44     session.__aexit__ = AsyncMock(return_value=False)
     45     return session
     46 
     47 # ── __init__ ──────────────────────────────────────────────────
     48 
     49 class TestClientInit:
     50     def test_strips_trailing_slash(self, client: HermesClient):
     51         assert client.base_url == "http://localhost:8642"
     52 
     53     def test_stores_api_key(self, client: HermesClient):
     54         assert client.api_key == "sk-test-key"
     55 
     56     def test_default_timeout_is_30s(self, client: HermesClient):
     57         assert client.timeout.total == 30
     58 
     59     def test_custom_timeout(self):
     60         c = HermesClient(base_url="http://localhost:8642", api_key="sk", timeout=60)
     61         assert c.timeout.total == 60
     62 
     63 
     64 # ── _request — the core method ────────────────────────────────
     65 
     66 class TestRequest:
     67     async def test_sends_bearer_auth_header(self, client: HermesClient):
     68         resp = _mock_response(200, {"ok": True})
     69         session = _mock_session(resp)
     70         with patch("aiohttp.ClientSession", return_value=session):
     71             await client._request("GET", "/health")
     72         session.request.assert_called_once()
     73         call_kwargs = session.request.call_args
     74         assert call_kwargs[0][0] == "GET"
     75         assert call_kwargs[0][1] == "http://localhost:8642/health"
     76         assert call_kwargs[1]["headers"]["Authorization"] == "Bearer sk-test-key"
     77 
     78     async def test_constructs_correct_url(self, client: HermesClient):
     79         resp = _mock_response(200, [])
     80         session = _mock_session(resp)
     81         with patch("aiohttp.ClientSession", return_value=session):
     82             await client._request("GET", "/sessions")
     83         url = session.request.call_args[0][1]
     84         assert url == "http://localhost:8642/sessions"
     85 
     86     async def test_returns_json_for_200(self, client: HermesClient):
     87         resp = _mock_response(200, [{"id": "s1"}])
     88         session = _mock_session(resp)
     89         with patch("aiohttp.ClientSession", return_value=session):
     90             result = await client._request("GET", "/sessions")
     91         assert result == [{"id": "s1"}]
     92 
     93     async def test_returns_none_for_204(self, client: HermesClient):
     94         resp = _mock_response(204)
     95         session = _mock_session(resp)
     96         with patch("aiohttp.ClientSession", return_value=session):
     97             result = await client._request("DELETE", "/sessions/s1")
     98         assert result is None
     99 
    100     async def test_passes_json_payload(self, client: HermesClient):
    101         resp = _mock_response(200, {"id": "s2"})
    102         session = _mock_session(resp)
    103         with patch("aiohttp.ClientSession", return_value=session):
    104             await client._request("POST", "/sessions", json={"name": "test"})
    105         call_kwargs = session.request.call_args[1]
    106         assert call_kwargs["json"] == {"name": "test"}
    107 
    108     async def test_raises_on_4xx(self, client: HermesClient):
    109         resp = _mock_response(404)
    110         session = _mock_session(resp)
    111         with patch("aiohttp.ClientSession", return_value=session):
    112             with pytest.raises(ClientResponseError):
    113                 await client._request("GET", "/sessions/nonexistent")
    114 
    115 
    116 # ── High-level methods — verify they call _request correctly ──
    117 
    118 class TestListSessions:
    119     async def test_calls_get_sessions(self, client: HermesClient):
    120         resp = _mock_response(200, {"object": "list", "data": [{"id": "s1"}]})
    121         session = _mock_session(resp)
    122         with patch("aiohttp.ClientSession", return_value=session):
    123             result = await client.list_sessions()
    124         # Returns raw Hermes response (normalization happens in route handler)
    125         assert result == {"object": "list", "data": [{"id": "s1"}]}
    126         assert session.request.call_args[0][0] == "GET"
    127         assert session.request.call_args[0][1] == "http://localhost:8642/api/sessions"
    128 
    129 
    130 class TestGetSession:
    131     async def test_calls_get_session_by_id(self, client: HermesClient):
    132         resp = _mock_response(200, {"object": "hermes.session", "session": {"id": "s1", "title": "Test"}})
    133         session = _mock_session(resp)
    134         with patch("aiohttp.ClientSession", return_value=session):
    135             result = await client.get_session("s1")
    136         assert result["session"]["id"] == "s1"
    137         assert session.request.call_args[0][1] == "http://localhost:8642/api/sessions/s1"
    138         assert session.request.call_args[0][0] == "GET"
    139 
    140 
    141 class TestCreateSession:
    142     async def test_posts_with_title(self, client: HermesClient):
    143         """create_session maps 'name' to 'title' for the Hermes API."""
    144         resp = _mock_response(201, {"object": "hermes.session", "session": {"id": "s2"}})
    145         session = _mock_session(resp)
    146         with patch("aiohttp.ClientSession", return_value=session):
    147             result = await client.create_session(name="New")
    148         assert result == {"object": "hermes.session", "session": {"id": "s2"}}
    149         assert session.request.call_args[0][0] == "POST"
    150         assert session.request.call_args[1]["json"] == {"title": "New"}
    151         assert session.request.call_args[0][1] == "http://localhost:8642/api/sessions"
    152 
    153     async def test_posts_empty_when_no_name(self, client: HermesClient):
    154         resp = _mock_response(201, {"object": "hermes.session", "session": {"id": "s3"}})
    155         session = _mock_session(resp)
    156         with patch("aiohttp.ClientSession", return_value=session):
    157             await client.create_session()
    158         assert session.request.call_args[1]["json"] == {}
    159         assert session.request.call_args[0][0] == "POST"
    160         assert session.request.call_args[0][1] == "http://localhost:8642/api/sessions"
    161 
    162 
    163 class TestUpdateSession:
    164     async def test_patches_session_maps_name_to_title(self, client: HermesClient):
    165         """update_session maps 'name' → 'title' for the Hermes API."""
    166         resp = _mock_response(200, {"object": "hermes.session", "session": {"id": "s1", "title": "Updated"}})
    167         session = _mock_session(resp)
    168         with patch("aiohttp.ClientSession", return_value=session):
    169             result = await client.update_session("s1", name="Updated")
    170         assert session.request.call_args[0][0] == "PATCH"
    171         assert session.request.call_args[0][1] == "http://localhost:8642/api/sessions/s1"
    172         assert session.request.call_args[1]["json"] == {"title": "Updated"}
    173 
    174     async def test_patches_session_maps_status_to_end_reason(self, client: HermesClient):
    175         """update_session maps 'status' → 'end_reason' for the Hermes API."""
    176         resp = _mock_response(200, {"object": "hermes.session", "session": {"id": "s1"}})
    177         session = _mock_session(resp)
    178         with patch("aiohttp.ClientSession", return_value=session):
    179             await client.update_session("s1", status="archived")
    180         assert session.request.call_args[1]["json"] == {"end_reason": "archived"}
    181 
    182 
    183 class TestDeleteSession:
    184     async def test_deletes_session(self, client: HermesClient):
    185         resp = _mock_response(204)
    186         session = _mock_session(resp)
    187         with patch("aiohttp.ClientSession", return_value=session):
    188             result = await client.delete_session("s1")
    189         assert result is None
    190         assert session.request.call_args[0][0] == "DELETE"
    191         assert session.request.call_args[0][1] == "http://localhost:8642/api/sessions/s1"
    192 
    193 
    194 class TestGetMessages:
    195     async def test_gets_messages_for_session(self, client: HermesClient):
    196         msgs = {"object": "list", "data": [{"role": "user", "content": "hi"}]}
    197         resp = _mock_response(200, msgs)
    198         session = _mock_session(resp)
    199         with patch("aiohttp.ClientSession", return_value=session):
    200             result = await client.get_messages("s1")
    201         assert result == msgs
    202         assert session.request.call_args[0][1] == "http://localhost:8642/api/sessions/s1/messages"
    203         assert session.request.call_args[0][0] == "GET"
    204 
    205 
    206 class TestForkSession:
    207     async def test_posts_fork(self, client: HermesClient):
    208         resp = _mock_response(201, {"object": "hermes.session", "session": {"id": "s3"}})
    209         session = _mock_session(resp)
    210         with patch("aiohttp.ClientSession", return_value=session):
    211             result = await client.fork_session("s1")
    212         assert result == {"object": "hermes.session", "session": {"id": "s3"}}
    213         assert session.request.call_args[0][0] == "POST"
    214         assert session.request.call_args[0][1] == "http://localhost:8642/api/sessions/s1/fork"
    215 
    216 
    217 class TestListModels:
    218     async def test_gets_models(self, client: HermesClient):
    219         resp = _mock_response(200, {"object": "list", "data": [{"id": "m1", "object": "model"}]})
    220         session = _mock_session(resp)
    221         with patch("aiohttp.ClientSession", return_value=session):
    222             result = await client.list_models()
    223         assert result == {"object": "list", "data": [{"id": "m1", "object": "model"}]}
    224         assert session.request.call_args[0][1] == "http://localhost:8642/v1/models"
    225         assert session.request.call_args[0][0] == "GET"
    226 
    227 
    228 class TestHealthCheck:
    229     async def test_gets_health(self, client: HermesClient):
    230         resp = _mock_response(200, {"status": "ok"})
    231         session = _mock_session(resp)
    232         with patch("aiohttp.ClientSession", return_value=session):
    233             result = await client.health_check()
    234         assert result == {"status": "ok"}
    235         assert session.request.call_args[0][1] == "http://localhost:8642/health"
    236         assert session.request.call_args[0][0] == "GET"
    237 
    238 
    239 class TestSearchMessages:
    240     async def test_gets_search_results(self, client: HermesClient):
    241         resp = _mock_response(200, {"results": [{"session_id": "s1"}]})
    242         session = _mock_session(resp)
    243         with patch("aiohttp.ClientSession", return_value=session):
    244             result = await client.search_messages("test query")
    245         assert result == {"results": [{"session_id": "s1"}]}
    246         url = session.request.call_args[0][1]
    247         assert url == "http://localhost:8642/api/search?q=test query"
    248         assert session.request.call_args[0][0] == "GET"
    249 
    250 
    251 # ── Error handling ────────────────────────────────────────────
    252 
    253 class TestSafeRequest:
    254     async def test_returns_data_on_success(self, client: HermesClient):
    255         resp = _mock_response(200, {"status": "ok"})
    256         session = _mock_session(resp)
    257         with patch("aiohttp.ClientSession", return_value=session):
    258             result = await client._safe_request("GET", "/health")
    259         assert result == {"status": "ok"}
    260 
    261     async def test_returns_none_on_client_error(self, client: HermesClient):
    262         resp = _mock_response(200)
    263         resp.raise_for_status = MagicMock(side_effect=ClientError("refused"))
    264         session = _mock_session(resp)
    265         with patch("aiohttp.ClientSession", return_value=session):
    266             result = await client._safe_request("GET", "/sessions")
    267         assert result is None
    268 
    269     async def test_returns_none_on_timeout(self, client: HermesClient):
    270         resp = _mock_response(200)
    271         session = _mock_session(resp)
    272         session.request = MagicMock(side_effect=asyncio.TimeoutError("timeout"))
    273         with patch("aiohttp.ClientSession", return_value=session):
    274             result = await client._safe_request("GET", "/sessions")
    275         assert result is None
    276 
    277 
    278 class TestSafeCall:
    279     async def test_returns_result_on_success(self, client: HermesClient):
    280         async def _ok():
    281             return {"status": "ok"}
    282 
    283         result = await client.safe_call(_ok())
    284         assert result == {"status": "ok"}
    285 
    286     async def test_returns_none_on_client_error(self, client: HermesClient):
    287         async def _fail():
    288             raise ClientError("refused")
    289 
    290         result = await client.safe_call(_fail())
    291         assert result is None
    292 
    293     async def test_returns_none_on_timeout(self, client: HermesClient):
    294         async def _timeout():
    295             raise asyncio.TimeoutError("timeout")
    296 
    297         result = await client.safe_call(_timeout())
    298         assert result is None
    299 
    300     async def test_does_not_catch_attribute_error(self, client: HermesClient):
    301         async def _bad():
    302             raise AttributeError("bad")
    303 
    304         with pytest.raises(AttributeError):
    305             await client.safe_call(_bad())
    306 
    307 
    308 # ── Normalization helpers ─────────────────────────────────────
    309 
    310 class TestNormalizeSession:
    311     def test_maps_hermes_fields(self):
    312         raw = {
    313             "id": "s1",
    314             "title": "Test Session",
    315             "started_at": 1234567890,
    316             "last_active": 1234567900,
    317             "end_reason": None,
    318             "message_count": 5,
    319             "preview": "Hello world",
    320             "source": "cli",
    321         }
    322         result = _normalize_session(raw)
    323         assert result["id"] == "s1"
    324         assert result["name"] == "Test Session"
    325         assert result["created_at"] == 1234567890
    326         assert result["last_activity"] == 1234567900
    327         assert result["status"] is None
    328         assert result["message_count"] == 5
    329         assert result["preview"] == "Hello world"
    330 
    331     def test_falls_back_to_frontend_fields(self):
    332         """If Hermes already uses frontend field names, those work too."""
    333         raw = {"id": "s1", "name": "Test", "created_at": 100, "last_activity": 200}
    334         result = _normalize_session(raw)
    335         assert result["name"] == "Test"
    336         assert result["created_at"] == 100
    337         assert result["last_activity"] == 200
    338 
    339 
    340 class TestNormalizeMessage:
    341     def test_maps_hermes_fields(self):
    342         raw = {
    343             "id": "m1",
    344             "session_id": "s1",
    345             "role": "assistant",
    346             "content": "Hello!",
    347             "timestamp": 1234567890,
    348             "tool_calls": [
    349                 {"id": "tc1", "name": "web_search", "arguments": '{"q":"test"}'},
    350             ],
    351         }
    352         result = _normalize_message(raw)
    353         assert result["id"] == "m1"
    354         assert result["role"] == "assistant"
    355         assert result["content"] == "Hello!"
    356         assert result["timestamp"] == 1234567890
    357         assert len(result["toolCalls"]) == 1
    358         assert result["toolCalls"][0]["id"] == "tc1"
    359         assert result["toolCalls"][0]["name"] == "web_search"
    360 
    361     def test_handles_no_tool_calls(self):
    362         raw = {"id": "m1", "role": "user", "content": "hi", "timestamp": 100}
    363         result = _normalize_message(raw)
    364         assert result["toolCalls"] == []
    365 
    366 
    367 class TestNormalizeModel:
    368     def test_maps_hermes_model(self):
    369         raw = {"id": "gpt-4", "object": "model", "created": 100, "owned_by": "hermes"}
    370         result = _normalize_model(raw)
    371         assert result == {"id": "gpt-4", "name": "gpt-4"}
    372 
    373 
    374 class TestUnwrapHermesResponse:
    375     def test_unwraps_session_object(self):
    376         data = {"object": "hermes.session", "session": {"id": "s1"}}
    377         result = _unwrap_hermes_response(data, key="session")
    378         assert result == {"id": "s1"}
    379 
    380     def test_unwraps_list_data(self):
    381         data = {"object": "list", "data": [{"id": "s1"}]}
    382         result = _unwrap_hermes_response(data)
    383         assert result == [{"id": "s1"}]
    384 
    385     def test_passes_through_non_dict(self):
    386         result = _unwrap_hermes_response([1, 2, 3])
    387         assert result == [1, 2, 3]
    388 
    389     def test_passes_through_when_key_missing(self):
    390         data = {"other": "data"}
    391         result = _unwrap_hermes_response(data, key="session")
    392         assert result == {"other": "data"}