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"}