theo-agent-dashboard

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

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