test_sessions.py (32699B)
1 """T067 — Tests for session proxy routes.""" 2 3 from __future__ import annotations 4 from unittest.mock import AsyncMock, MagicMock, patch 5 import pytest 6 from fastapi import FastAPI 7 from fastapi.testclient import TestClient 8 9 # ── helpers ────────────────────────────────────────────────────────────── 10 11 12 def _make_hermes_session(session_id: str = "s1", title: str = "Test session") -> dict: 13 """Return a session dict in the Hermes API format.""" 14 return {"id": session_id, "title": title, "source": "cli", "message_count": 0} 15 16 17 def _make_hermes_list_response(sessions: list[dict] | None = None) -> dict: 18 """Return a Hermes list sessions envelope.""" 19 return {"object": "list", "data": sessions or [_make_hermes_session()]} 20 21 22 def _make_hermes_session_response(session: dict | None = None) -> dict: 23 """Return a Hermes single session envelope.""" 24 return {"object": "hermes.session", "session": session or _make_hermes_session()} 25 26 27 def _make_hermes_messages_response(messages: list[dict] | None = None) -> dict: 28 """Return a Hermes messages envelope.""" 29 return {"object": "list", "session_id": "s1", "data": messages or []} 30 31 32 def _mock_client(**overrides: AsyncMock) -> MagicMock: 33 """Build a mock HermesClient that returns Hermes-format responses.""" 34 client = MagicMock() 35 client.list_sessions = AsyncMock(return_value=_make_hermes_list_response()) 36 client.get_session = AsyncMock(return_value=_make_hermes_session_response()) 37 client.create_session = AsyncMock( 38 return_value=_make_hermes_session_response(_make_hermes_session("s2", "New")) 39 ) 40 client.update_session = AsyncMock( 41 return_value=_make_hermes_session_response(_make_hermes_session("s1", "Updated")) 42 ) 43 client.delete_session = AsyncMock(return_value=None) 44 client.get_messages = AsyncMock(return_value=_make_hermes_messages_response([ 45 {"id": "m1", "role": "user", "content": "hi", "timestamp": 100} 46 ])) 47 client.fork_session = AsyncMock( 48 return_value=_make_hermes_session_response(_make_hermes_session("s3", "Fork of s1")) 49 ) 50 for name, mock in overrides.items(): 51 setattr(client, name, mock) 52 return client 53 54 55 def _test_app(db: MagicMock | None = None) -> FastAPI: 56 """Build a minimal app with sessions router, no auth middleware.""" 57 from app.routes.sessions import router as sessions_router 58 59 app = FastAPI() 60 app.include_router(sessions_router) 61 if db is not None: 62 app.state.db = db 63 return app 64 65 def _mock_db(rows: list[tuple] | None = None) -> MagicMock: 66 """Build a mock DB that returns given rows for topic queries.""" 67 db = MagicMock() 68 db.fetch_all = AsyncMock(return_value=rows or []) 69 db.execute = AsyncMock() 70 return db 71 72 # ── tests ──────────────────────────────────────────────────────────────── 73 74 75 class TestListSessions: 76 """GET /api/sessions proxies to Hermes and normalizes.""" 77 78 def test_returns_normalized_session_list(self) -> None: 79 mock_client = _mock_client() 80 app = _test_app() 81 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 82 with TestClient(app) as tc: 83 resp = tc.get("/api/sessions") 84 assert resp.status_code == 200 85 body = resp.json() 86 assert "sessions" in body 87 sessions = body["sessions"] 88 assert len(sessions) == 1 89 assert sessions[0]["id"] == "s1" 90 assert sessions[0]["name"] == "Test session" 91 mock_client.list_sessions.assert_awaited_once() 92 93 def test_unwraps_hermes_list_response(self) -> None: 94 """Hermes returns {object: 'list', data: [...]} — unwrap and normalize.""" 95 wrapped = _make_hermes_list_response([_make_hermes_session()]) 96 mock_client = _mock_client(list_sessions=AsyncMock(return_value=wrapped)) 97 app = _test_app() 98 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 99 with TestClient(app) as tc: 100 resp = tc.get("/api/sessions") 101 assert resp.status_code == 200 102 sessions = resp.json()["sessions"] 103 assert len(sessions) == 1 104 assert sessions[0]["id"] == "s1" 105 assert sessions[0]["name"] == "Test session" 106 107 def test_enriches_with_topic_from_db(self) -> None: 108 """Sessions enriched with topic info from topic_sessions table.""" 109 db = _mock_db(rows=[("s1", "t1", "Work")]) 110 mock_client = _mock_client() 111 app = _test_app(db=db) 112 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 113 with TestClient(app) as tc: 114 resp = tc.get("/api/sessions") 115 sessions = resp.json()["sessions"] 116 assert sessions[0]["topic_id"] == "t1" 117 assert sessions[0]["topic_name"] == "Work" 118 assert sessions[0]["id"] == "s1" 119 assert sessions[0]["name"] == "Test session" 120 121 def test_no_db_still_returns_sessions(self) -> None: 122 """Without a DB, sessions are returned unenriched.""" 123 mock_client = _mock_client() 124 app = _test_app() 125 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 126 with TestClient(app) as tc: 127 resp = tc.get("/api/sessions") 128 assert resp.status_code == 200 129 body = resp.json() 130 assert "sessions" in body 131 sessions = body["sessions"] 132 assert len(sessions) == 1 133 assert sessions[0]["id"] == "s1" 134 assert "topic_id" not in sessions[0] 135 136 def test_returns_empty_when_hermes_returns_none(self) -> None: 137 """When Hermes returns None, list sessions returns empty list.""" 138 mock_client = _mock_client( 139 list_sessions=AsyncMock(return_value=None), 140 ) 141 app = _test_app() 142 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 143 with TestClient(app) as tc: 144 resp = tc.get("/api/sessions") 145 assert resp.status_code == 200 146 assert resp.json() == {"sessions": []} 147 148 def test_returns_empty_when_hermes_returns_empty_list(self) -> None: 149 """When Hermes returns an empty list, sessions are empty.""" 150 mock_client = _mock_client( 151 list_sessions=AsyncMock(return_value=[]), 152 ) 153 app = _test_app() 154 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 155 with TestClient(app) as tc: 156 resp = tc.get("/api/sessions") 157 assert resp.status_code == 200 158 assert resp.json() == {"sessions": []} 159 160 def test_returns_empty_when_hermes_returns_empty_data(self) -> None: 161 """When Hermes returns {data: []}, sessions are empty.""" 162 mock_client = _mock_client( 163 list_sessions=AsyncMock(return_value={"object": "list", "data": []}), 164 ) 165 app = _test_app() 166 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 167 with TestClient(app) as tc: 168 resp = tc.get("/api/sessions") 169 assert resp.status_code == 200 170 assert resp.json() == {"sessions": []} 171 172 173 class TestCreateSession: 174 """POST /api/sessions proxies to Hermes and normalizes.""" 175 176 def test_creates_session(self) -> None: 177 mock_client = _mock_client() 178 app = _test_app() 179 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 180 with TestClient(app) as tc: 181 resp = tc.post("/api/sessions", json={"name": "New session"}) 182 assert resp.status_code == 200 183 body = resp.json() 184 assert body["id"] == "s2" 185 assert body["name"] == "New" 186 mock_client.create_session.assert_awaited_once_with(name="New session") 187 188 def test_creates_session_without_name(self) -> None: 189 mock_client = _mock_client() 190 app = _test_app() 191 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 192 with TestClient(app) as tc: 193 resp = tc.post("/api/sessions", json={}) 194 assert resp.status_code == 200 195 body = resp.json() 196 assert body["id"] == "s2" 197 mock_client.create_session.assert_awaited_once_with(name=None) 198 199 def test_creates_session_with_topic(self) -> None: 200 """Session creation inserts into topic_sessions.""" 201 db = _mock_db() 202 mock_client = _mock_client() 203 app = _test_app(db=db) 204 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 205 with TestClient(app) as tc: 206 resp = tc.post( 207 "/api/sessions", json={"name": "New", "topic_id": "t1"}, 208 ) 209 assert resp.status_code == 200 210 body = resp.json() 211 assert body["id"] == "s2" 212 db.execute.assert_awaited_once() 213 call_args = db.execute.call_args 214 sql = call_args[0][0] 215 assert "topic_sessions" in sql 216 assert "INSERT" in sql 217 assert call_args[0][1] == ("t1", "s2") 218 219 220 class TestUpdateSession: 221 """PATCH /api/sessions/{id} proxies to Hermes with field mapping.""" 222 223 def test_updates_session_maps_name_to_title(self) -> None: 224 """Dashboard sends 'name' → client maps to 'title'.""" 225 mock_client = _mock_client() 226 app = _test_app() 227 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 228 with TestClient(app) as tc: 229 resp = tc.patch("/api/sessions/s1", json={"name": "Updated"}) 230 assert resp.status_code == 200 231 body = resp.json() 232 assert body["name"] == "Updated" 233 assert body["id"] == "s1" 234 # Client was called with name= (the client maps name→title internally) 235 mock_client.update_session.assert_awaited_once_with("s1", name="Updated") 236 237 def test_updates_session_status(self) -> None: 238 """Dashboard sends 'status' → client maps to 'end_reason'.""" 239 mock_client = _mock_client() 240 app = _test_app() 241 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 242 with TestClient(app) as tc: 243 resp = tc.patch("/api/sessions/s1", json={"status": "archived"}) 244 assert resp.status_code == 200 245 mock_client.update_session.assert_awaited_once_with("s1", status="archived") 246 247 248 class TestDeleteSession: 249 """DELETE /api/sessions/{id} proxies to Hermes.""" 250 251 def test_deletes_session(self) -> None: 252 mock_client = _mock_client() 253 app = _test_app() 254 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 255 with TestClient(app) as tc: 256 resp = tc.delete("/api/sessions/s1") 257 assert resp.status_code == 204 258 mock_client.delete_session.assert_awaited_once_with("s1") 259 260 261 class TestGetMessages: 262 """GET /api/sessions/{id}/messages returns normalized messages.""" 263 264 def test_returns_normalized_messages(self) -> None: 265 mock_client = _mock_client() 266 app = _test_app() 267 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 268 with TestClient(app) as tc: 269 resp = tc.get("/api/sessions/s1/messages") 270 assert resp.status_code == 200 271 body = resp.json() 272 assert "messages" in body 273 messages = body["messages"] 274 assert len(messages) == 1 275 assert messages[0]["id"] == "m1" 276 assert messages[0]["role"] == "user" 277 assert messages[0]["content"] == "hi" 278 assert messages[0]["timestamp"] == 100 279 # toolCalls should be normalized (empty list) 280 assert messages[0]["toolCalls"] == [] 281 mock_client.get_messages.assert_awaited_once() 282 283 def test_unwraps_hermes_messages_envelope(self) -> None: 284 """Hermes returns {object: 'list', data: [...]} — unwrap and normalize.""" 285 hermes_resp = { 286 "object": "list", 287 "session_id": "s1", 288 "data": [ 289 {"id": "m1", "role": "user", "content": "hi", "timestamp": 100}, 290 {"id": "m2", "role": "assistant", "content": "hello", "timestamp": 200}, 291 ], 292 } 293 mock_client = _mock_client( 294 get_messages=AsyncMock(return_value=hermes_resp) 295 ) 296 app = _test_app() 297 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 298 with TestClient(app) as tc: 299 resp = tc.get("/api/sessions/s1/messages") 300 assert resp.status_code == 200 301 messages = resp.json()["messages"] 302 assert len(messages) == 2 303 assert messages[0]["id"] == "m1" 304 assert messages[1]["id"] == "m2" 305 306 307 class TestHermesError: 308 """Hermes failures return 502.""" 309 310 def test_returns_502_on_hermes_error(self) -> None: 311 mock_client = _mock_client( 312 list_sessions=AsyncMock(side_effect=Exception("Hermes down")), 313 ) 314 app = _test_app() 315 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 316 with TestClient(app) as tc: 317 resp = tc.get("/api/sessions") 318 assert resp.status_code == 502 319 body = resp.json() 320 assert "detail" in body 321 assert "Hermes" in body["detail"] 322 assert "Hermes down" in body["detail"] 323 324 325 class TestForkSession: 326 """POST /api/sessions/{id}/fork creates a fork.""" 327 328 def test_fork_returns_normalized_session(self) -> None: 329 mock_client = _mock_client() 330 app = _test_app() 331 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 332 with TestClient(app) as tc: 333 resp = tc.post("/api/sessions/s1/fork") 334 assert resp.status_code == 200 335 body = resp.json() 336 assert body["id"] == "s3" 337 assert body["name"] == "Fork of s1" 338 mock_client.fork_session.assert_awaited_once_with("s1") 339 340 def test_fork_stores_branch_in_db(self) -> None: 341 db = _mock_db() 342 mock_client = _mock_client() 343 app = _test_app(db=db) 344 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 345 with TestClient(app) as tc: 346 resp = tc.post("/api/sessions/s1/fork") 347 assert resp.status_code == 200 348 body = resp.json() 349 assert body["id"] == "s3" 350 db.execute.assert_awaited_once() 351 call_args = db.execute.call_args 352 sql = call_args[0][0] 353 assert "branches" in sql 354 assert "INSERT" in sql 355 params = call_args[0][1] 356 assert params[0] == "s3" 357 assert params[1] == "s1" 358 359 def test_fork_returns_502_on_hermes_error(self) -> None: 360 mock_client = _mock_client( 361 fork_session=AsyncMock(side_effect=Exception("Hermes down")) 362 ) 363 app = _test_app() 364 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 365 with TestClient(app) as tc: 366 resp = tc.post("/api/sessions/s1/fork") 367 assert resp.status_code == 502 368 body = resp.json() 369 assert "detail" in body 370 assert "Hermes" in body["detail"] 371 372 def test_fork_returns_502_when_hermes_returns_none(self) -> None: 373 mock_client = _mock_client( 374 fork_session=AsyncMock(return_value=None) 375 ) 376 app = _test_app() 377 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 378 with TestClient(app) as tc: 379 resp = tc.post("/api/sessions/s1/fork") 380 assert resp.status_code == 502 381 body = resp.json() 382 assert "detail" in body 383 assert "Hermes returned no data" in body["detail"] 384 385 386 class TestListBranches: 387 """GET /api/sessions/{id}/branches returns child sessions.""" 388 389 def test_returns_branches(self) -> None: 390 db = _mock_db(rows=[("s3", "s1", 0), ("s4", "s1", 2)]) 391 app = _test_app(db=db) 392 with TestClient(app) as tc: 393 resp = tc.get("/api/sessions/s1/branches") 394 assert resp.status_code == 200 395 body = resp.json() 396 assert "branches" in body 397 branches = body["branches"] 398 assert len(branches) == 2 399 assert branches[0]["session_id"] == "s3" 400 assert branches[0]["parent_session_id"] == "s1" 401 assert branches[0]["fork_message_index"] == 0 402 assert branches[1]["session_id"] == "s4" 403 assert branches[1]["parent_session_id"] == "s1" 404 assert branches[1]["fork_message_index"] == 2 405 406 def test_returns_empty_when_no_db(self) -> None: 407 app = _test_app() 408 with TestClient(app) as tc: 409 resp = tc.get("/api/sessions/s1/branches") 410 assert resp.status_code == 200 411 assert resp.json() == {"branches": []} 412 413 def test_returns_empty_on_db_error(self) -> None: 414 db = MagicMock() 415 db.fetch_all = AsyncMock(side_effect=Exception("DB error")) 416 app = _test_app(db=db) 417 with TestClient(app) as tc: 418 resp = tc.get("/api/sessions/s1/branches") 419 assert resp.status_code == 200 420 assert resp.json() == {"branches": []} 421 422 423 class TestCreateSessionWithNoneResult: 424 """POST /api/sessions returns 502 when Hermes returns None.""" 425 426 def test_returns_502_on_none_result(self) -> None: 427 mock_client = _mock_client( 428 create_session=AsyncMock(return_value=None) 429 ) 430 app = _test_app() 431 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 432 with TestClient(app) as tc: 433 resp = tc.post("/api/sessions", json={"name": "New"}) 434 assert resp.status_code == 502 435 body = resp.json() 436 assert "detail" in body 437 assert "Hermes returned no data" in body["detail"] 438 439 440 class TestUpdateSessionWithNoneResult: 441 """PATCH /api/sessions/{id} returns 502 when Hermes returns None.""" 442 443 def test_returns_502_on_none_result(self) -> None: 444 mock_client = _mock_client( 445 update_session=AsyncMock(return_value=None) 446 ) 447 app = _test_app() 448 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 449 with TestClient(app) as tc: 450 resp = tc.patch("/api/sessions/s1", json={"name": "Updated"}) 451 assert resp.status_code == 502 452 body = resp.json() 453 assert "detail" in body 454 assert "Hermes returned no data" in body["detail"] 455 456 457 class TestGetMessagesEmpty: 458 """GET /api/sessions/{id}/messages returns empty when Hermes returns None.""" 459 460 def test_returns_empty_messages_on_none(self) -> None: 461 mock_client = _mock_client( 462 get_messages=AsyncMock(return_value=None) 463 ) 464 app = _test_app() 465 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 466 with TestClient(app) as tc: 467 resp = tc.get("/api/sessions/s1/messages") 468 assert resp.status_code == 200 469 assert resp.json() == {"messages": [], "attachments": []} 470 471 472 class TestGetMessagesAttachments: 473 """GET /api/sessions/{id}/messages includes attachment metadata.""" 474 475 def test_returns_attachments_for_session(self) -> None: 476 """When attachments exist for the session, they are included in the response.""" 477 mock_client = _mock_client() 478 mock_db = _mock_db() 479 mock_db.fetch_all = AsyncMock(return_value=[ 480 ("att-1", "photo.png", "image/png", 1024, 1000), 481 ("att-2", "doc.pdf", "application/pdf", 2048, 1010), 482 ]) 483 app = _test_app(db=mock_db) 484 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 485 with TestClient(app) as tc: 486 resp = tc.get("/api/sessions/s1/messages") 487 assert resp.status_code == 200 488 body = resp.json() 489 assert "attachments" in body 490 assert len(body["attachments"]) == 2 491 assert body["attachments"][0]["id"] == "att-1" 492 assert body["attachments"][0]["filename"] == "photo.png" 493 assert body["attachments"][0]["content_type"] == "image/png" 494 assert body["attachments"][0]["created_at"] == 1000 495 assert body["attachments"][1]["id"] == "att-2" 496 497 def test_returns_empty_attachments_when_none(self) -> None: 498 """When no attachments exist, returns empty list.""" 499 mock_client = _mock_client() 500 mock_db = _mock_db(rows=[]) 501 app = _test_app(db=mock_db) 502 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 503 with TestClient(app) as tc: 504 resp = tc.get("/api/sessions/s1/messages") 505 assert resp.status_code == 200 506 body = resp.json() 507 assert body["attachments"] == [] 508 509 def test_returns_empty_attachments_when_no_db(self) -> None: 510 """When DB is not available, returns empty attachments list.""" 511 mock_client = _mock_client() 512 app = _test_app() # no db 513 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 514 with TestClient(app) as tc: 515 resp = tc.get("/api/sessions/s1/messages") 516 assert resp.status_code == 200 517 body = resp.json() 518 assert body["attachments"] == [] 519 520 521 class TestEnrichSessions: 522 """_enrich_sessions edge cases.""" 523 524 def test_load_topic_map_returns_empty_on_db_error(self) -> None: 525 db = MagicMock() 526 db.fetch_all = AsyncMock(side_effect=Exception("DB down")) 527 app = _test_app(db=db) 528 mock_client = _mock_client() 529 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 530 with TestClient(app) as tc: 531 resp = tc.get("/api/sessions") 532 assert resp.status_code == 200 533 body = resp.json() 534 assert "sessions" in body 535 assert len(body["sessions"]) == 1 536 assert body["sessions"][0]["id"] == "s1" 537 538 def test_assign_topic_skips_when_no_topic_id(self) -> None: 539 db = _mock_db() 540 mock_client = _mock_client() 541 app = _test_app(db=db) 542 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 543 with TestClient(app) as tc: 544 resp = tc.post("/api/sessions", json={"name": "New"}) 545 assert resp.status_code == 200 546 body = resp.json() 547 assert body["id"] == "s2" 548 db.execute.assert_not_awaited() 549 550 551 class TestProxyCall: 552 """_proxy_call wraps coroutine errors as HTTP 502.""" 553 554 async def test_returns_result_on_success(self) -> None: 555 from app.routes.sessions import _proxy_call 556 from unittest.mock import AsyncMock, MagicMock 557 558 client = MagicMock() 559 coro = AsyncMock(return_value={"id": "s1"})() 560 result = await _proxy_call(client, coro) 561 assert result == {"id": "s1"} 562 563 async def test_raises_502_on_exception(self) -> None: 564 from app.routes.sessions import _proxy_call 565 from unittest.mock import AsyncMock, MagicMock 566 from fastapi import HTTPException 567 568 client = MagicMock() 569 570 async def _fail(): 571 raise Exception("Hermes down") 572 573 with pytest.raises(HTTPException) as exc_info: 574 await _proxy_call(client, _fail()) 575 assert exc_info.value.status_code == 502 576 assert "Hermes" in exc_info.value.detail 577 assert "Hermes down" in exc_info.value.detail 578 579 580 class TestLoadTopicMap: 581 """_load_topic_map queries DB and builds mapping.""" 582 583 async def test_returns_mapping(self) -> None: 584 from app.routes.sessions import _load_topic_map 585 db = MagicMock() 586 db.fetch_all = AsyncMock(return_value=[("s1", "t1", "Work"), ("s2", "t2", "Personal")]) 587 result = await _load_topic_map(db) 588 assert result == {"s1": ("t1", "Work"), "s2": ("t2", "Personal")} 589 590 async def test_returns_empty_on_error(self) -> None: 591 from app.routes.sessions import _load_topic_map 592 db = MagicMock() 593 db.fetch_all = AsyncMock(side_effect=Exception("DB down")) 594 result = await _load_topic_map(db) 595 assert result == {} 596 597 598 class TestAttachTopic: 599 """_attach_topic adds topic fields to session dict.""" 600 601 def test_attaches_topic_when_found(self) -> None: 602 from app.routes.sessions import _attach_topic 603 session = {"id": "s1"} 604 topic_map = {"s1": ("t1", "Work")} 605 result = _attach_topic(session, topic_map) 606 assert result["topic_id"] == "t1" 607 assert result["topic_name"] == "Work" 608 609 def test_no_topic_when_not_found(self) -> None: 610 from app.routes.sessions import _attach_topic 611 session = {"id": "s1"} 612 result = _attach_topic(session, {}) 613 assert "topic_id" not in result 614 615 def test_no_topic_when_no_id(self) -> None: 616 from app.routes.sessions import _attach_topic 617 session = {} 618 result = _attach_topic(session, {"s1": ("t1", "Work")}) 619 assert "topic_id" not in result 620 621 622 class TestAssignTopic: 623 """_assign_topic inserts into topic_sessions.""" 624 625 async def test_inserts_when_both_ids_present(self) -> None: 626 from app.routes.sessions import _assign_topic 627 from unittest.mock import AsyncMock, MagicMock 628 from fastapi import Request 629 630 db = MagicMock() 631 db.execute = AsyncMock() 632 request = MagicMock() 633 request.app.state.db = db 634 await _assign_topic(request, "s1", "t1") 635 db.execute.assert_awaited_once() 636 637 async def test_skips_when_no_session_id(self) -> None: 638 from app.routes.sessions import _assign_topic 639 from unittest.mock import MagicMock 640 641 db = MagicMock() 642 request = MagicMock() 643 request.app.state.db = db 644 await _assign_topic(request, None, "t1") 645 db.execute.assert_not_called() 646 647 async def test_skips_when_no_topic_id(self) -> None: 648 from app.routes.sessions import _assign_topic 649 from unittest.mock import MagicMock 650 651 db = MagicMock() 652 request = MagicMock() 653 request.app.state.db = db 654 await _assign_topic(request, "s1", None) 655 db.execute.assert_not_called() 656 657 658 class TestStoreBranch: 659 """_store_branch inserts into branches table.""" 660 661 async def test_inserts_branch(self) -> None: 662 from app.routes.sessions import _store_branch 663 from unittest.mock import AsyncMock, MagicMock 664 665 db = MagicMock() 666 db.execute = AsyncMock() 667 request = MagicMock() 668 request.app.state.db = db 669 await _store_branch(request, "s3", "s1") 670 db.execute.assert_awaited_once() 671 672 async def test_skips_when_no_child_id(self) -> None: 673 from app.routes.sessions import _store_branch 674 from unittest.mock import MagicMock 675 676 db = MagicMock() 677 request = MagicMock() 678 request.app.state.db = db 679 await _store_branch(request, None, "s1") 680 db.execute.assert_not_called() 681 682 async def test_no_crash_when_no_db(self) -> None: 683 from app.routes.sessions import _store_branch 684 from unittest.mock import MagicMock 685 686 request = MagicMock() 687 request.app.state.db = None 688 await _store_branch(request, "s3", "s1") 689 690 691 class TestLoadArchivedSet: 692 """_load_archived_set queries DB and returns set of IDs.""" 693 694 async def test_returns_set(self) -> None: 695 from app.routes.sessions import _load_archived_set 696 db = MagicMock() 697 db.fetch_all = AsyncMock(return_value=[("s1",), ("s3",)]) 698 result = await _load_archived_set(db) 699 assert result == {"s1", "s3"} 700 701 async def test_returns_empty_on_error(self) -> None: 702 from app.routes.sessions import _load_archived_set 703 db = MagicMock() 704 db.fetch_all = AsyncMock(side_effect=Exception("DB down")) 705 result = await _load_archived_set(db) 706 assert result == set() 707 708 709 class TestAttachArchived: 710 """_attach_archived adds archived flag to session dict.""" 711 712 def test_sets_archived_true_when_in_set(self) -> None: 713 from app.routes.sessions import _attach_archived 714 session = {"id": "s1"} 715 result = _attach_archived(session, {"s1", "s3"}) 716 assert result["archived"] is True 717 718 def test_sets_archived_false_when_not_in_set(self) -> None: 719 from app.routes.sessions import _attach_archived 720 session = {"id": "s2"} 721 result = _attach_archived(session, {"s1", "s3"}) 722 assert result["archived"] is False 723 724 def test_sets_archived_false_when_empty_set(self) -> None: 725 from app.routes.sessions import _attach_archived 726 session = {"id": "s1"} 727 result = _attach_archived(session, set()) 728 assert result["archived"] is False 729 730 731 class TestArchiveEnrichment: 732 """Sessions are enriched with archived: true/false from DB.""" 733 734 def test_enriches_archived_sessions(self) -> None: 735 """Archived session IDs in DB result in archived: true on sessions.""" 736 mock_client = _mock_client() 737 # First call: topic map (empty), second call: archived set 738 db = MagicMock() 739 db.fetch_all = AsyncMock(side_effect=[ 740 [], # topic_sessions 741 [("s1",)], # archived_sessions 742 ]) 743 app = _test_app(db=db) 744 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 745 with TestClient(app) as tc: 746 resp = tc.get("/api/sessions") 747 assert resp.status_code == 200 748 sessions = resp.json()["sessions"] 749 assert len(sessions) == 1 750 assert sessions[0]["id"] == "s1" 751 assert sessions[0]["archived"] is True 752 753 def test_non_archived_sessions_marked_false(self) -> None: 754 """Sessions not in archived_sessions get archived: false.""" 755 mock_client = _mock_client() 756 db = MagicMock() 757 db.fetch_all = AsyncMock(side_effect=[ 758 [], # topic_sessions 759 [], # archived_sessions (empty) 760 ]) 761 app = _test_app(db=db) 762 with patch("app.routes.sessions.get_hermes_client", return_value=mock_client): 763 with TestClient(app) as tc: 764 resp = tc.get("/api/sessions") 765 sessions = resp.json()["sessions"] 766 assert sessions[0]["archived"] is False 767 768 769 class TestArchiveSession: 770 """POST /api/sessions/{id}/archive marks session as archived.""" 771 772 def test_archives_session(self) -> None: 773 db = _mock_db() 774 app = _test_app(db=db) 775 with TestClient(app) as tc: 776 resp = tc.post("/api/sessions/s1/archive") 777 assert resp.status_code == 200 778 body = resp.json() 779 assert body["session_id"] == "s1" 780 assert body["archived"] is True 781 db.execute.assert_awaited_once() 782 sql = db.execute.call_args[0][0] 783 assert "INSERT" in sql 784 assert "archived_sessions" in sql 785 assert db.execute.call_args[0][1] == ("s1",) 786 787 def test_returns_500_when_no_db(self) -> None: 788 app = _test_app() # no db 789 with TestClient(app) as tc: 790 resp = tc.post("/api/sessions/s1/archive") 791 assert resp.status_code == 500 792 793 def test_returns_500_on_db_error(self) -> None: 794 db = MagicMock() 795 db.execute = AsyncMock(side_effect=Exception("DB error")) 796 app = _test_app(db=db) 797 with TestClient(app) as tc: 798 resp = tc.post("/api/sessions/s1/archive") 799 assert resp.status_code == 500 800 801 802 class TestUnarchiveSession: 803 """DELETE /api/sessions/{id}/archive removes archived status.""" 804 805 def test_unarchives_session(self) -> None: 806 db = _mock_db() 807 app = _test_app(db=db) 808 with TestClient(app) as tc: 809 resp = tc.delete("/api/sessions/s1/archive") 810 assert resp.status_code == 204 811 db.execute.assert_awaited_once() 812 sql = db.execute.call_args[0][0] 813 assert "DELETE" in sql 814 assert "archived_sessions" in sql 815 assert db.execute.call_args[0][1] == ("s1",) 816 817 def test_returns_500_when_no_db(self) -> None: 818 app = _test_app() # no db 819 with TestClient(app) as tc: 820 resp = tc.delete("/api/sessions/s1/archive") 821 assert resp.status_code == 500 822 823 def test_returns_500_on_db_error(self) -> None: 824 db = MagicMock() 825 db.execute = AsyncMock(side_effect=Exception("DB error")) 826 app = _test_app(db=db) 827 with TestClient(app) as tc: 828 resp = tc.delete("/api/sessions/s1/archive") 829 assert resp.status_code == 500