test_topics.py (9329B)
1 """T048 — Tests for topic CRUD endpoints.""" 2 import os 3 from pathlib import Path 4 5 import pytest 6 from httpx import ASGITransport, AsyncClient 7 from app.db import Database 8 from app.routes.topics import set_db 9 10 @pytest.fixture(autouse=True) 11 def _isolate_auth_config(tmp_path): 12 """Each test gets a clean config file.""" 13 config_path = str(tmp_path / "config.json") 14 os.environ["THEO_CONFIG_PATH"] = config_path 15 import app.routes.auth as auth_module 16 old = auth_module._CONFIG_PATH 17 auth_module._CONFIG_PATH = config_path 18 yield 19 auth_module._CONFIG_PATH = old 20 os.environ.pop("THEO_CONFIG_PATH", None) 21 22 23 @pytest.fixture 24 async def client(tmp_path: Path) -> AsyncClient: 25 """Fresh async client with isolated DB for each test.""" 26 db_path = str(tmp_path / "test.db") 27 db = Database(db_path) 28 await db.connect() 29 migrations_dir = Path(__file__).parent.parent / "app" / "migrations" 30 await db.run_migrations(migrations_dir) 31 set_db(db) 32 from app.main import app 33 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: 34 yield c 35 await db.close() 36 37 38 async def _auth_headers(client: AsyncClient) -> None: 39 """Set up auth and inject JWT cookie.""" 40 resp = await client.post("/api/auth/setup", json={ 41 "password": "testpass123", 42 "api_key": "sk-test", 43 }) 44 token = resp.json()["token"] 45 client.cookies.set("theo_token", token) 46 47 48 class TestTopicCreate: 49 """POST /api/topics""" 50 51 async def test_create_topic_returns_200(self, client: AsyncClient) -> None: 52 """Creating a topic returns TopicResponse with id and name.""" 53 await _auth_headers(client) 54 resp = await client.post("/api/topics", json={"name": "Work"}) 55 assert resp.status_code == 200 56 body = resp.json() 57 assert body["name"] == "Work" 58 assert isinstance(body["id"], str) 59 assert len(body["id"]) > 0 60 assert body["color"] == "#6366f1" 61 assert isinstance(body["sort_order"], int) 62 assert body["sort_order"] == 0 63 assert isinstance(body["created_at"], int) 64 assert body["created_at"] > 0 65 66 async def test_create_topic_with_color(self, client: AsyncClient) -> None: 67 """Creating a topic with a custom color stores it.""" 68 await _auth_headers(client) 69 resp = await client.post("/api/topics", json={"name": "Personal", "color": "#ef4444"}) 70 assert resp.status_code == 200 71 body = resp.json() 72 assert body["name"] == "Personal" 73 assert body["color"] == "#ef4444" 74 assert isinstance(body["id"], str) 75 assert len(body["id"]) > 0 76 77 78 class TestTopicList: 79 """GET /api/topics""" 80 81 async def test_list_empty(self, client: AsyncClient) -> None: 82 """Empty database returns empty list.""" 83 await _auth_headers(client) 84 resp = await client.get("/api/topics") 85 assert resp.status_code == 200 86 assert resp.json() == [] 87 88 async def test_list_returns_topics_with_session_count(self, client: AsyncClient) -> None: 89 """Topics include session_count field.""" 90 await _auth_headers(client) 91 await client.post("/api/topics", json={"name": "Alpha"}) 92 resp = await client.get("/api/topics") 93 assert resp.status_code == 200 94 topics = resp.json() 95 assert len(topics) == 1 96 assert topics[0]["session_count"] == 0 97 assert topics[0]["name"] == "Alpha" 98 assert topics[0]["color"] == "#6366f1" 99 assert topics[0]["sort_order"] == 0 100 101 102 class TestTopicUpdate: 103 """PATCH /api/topics/{id}""" 104 105 async def test_update_name(self, client: AsyncClient) -> None: 106 """PATCH updates topic name.""" 107 await _auth_headers(client) 108 create = await client.post("/api/topics", json={"name": "Old"}) 109 topic_id = create.json()["id"] 110 resp = await client.patch(f"/api/topics/{topic_id}", json={"name": "New"}) 111 assert resp.status_code == 200 112 body = resp.json() 113 assert body["name"] == "New" 114 assert body["id"] == topic_id 115 116 async def test_update_color(self, client: AsyncClient) -> None: 117 """PATCH updates topic color.""" 118 await _auth_headers(client) 119 create = await client.post("/api/topics", json={"name": "T"}) 120 topic_id = create.json()["id"] 121 resp = await client.patch(f"/api/topics/{topic_id}", json={"color": "#10b981"}) 122 assert resp.status_code == 200 123 body = resp.json() 124 assert body["color"] == "#10b981" 125 assert body["id"] == topic_id 126 127 async def test_update_sort_order(self, client: AsyncClient) -> None: 128 """PATCH updates topic sort_order.""" 129 await _auth_headers(client) 130 create = await client.post("/api/topics", json={"name": "T"}) 131 topic_id = create.json()["id"] 132 resp = await client.patch(f"/api/topics/{topic_id}", json={"sort_order": 5}) 133 assert resp.status_code == 200 134 body = resp.json() 135 assert body["sort_order"] == 5 136 assert body["id"] == topic_id 137 138 139 class TestTopicDelete: 140 """DELETE /api/topics/{id}""" 141 142 async def test_delete_removes_topic(self, client: AsyncClient) -> None: 143 """DELETE removes topic from list.""" 144 await _auth_headers(client) 145 create = await client.post("/api/topics", json={"name": "Temp"}) 146 topic_id = create.json()["id"] 147 resp = await client.delete(f"/api/topics/{topic_id}") 148 assert resp.status_code == 200 149 body = resp.json() 150 assert body == {"ok": "deleted"} 151 list_resp = await client.get("/api/topics") 152 assert list_resp.json() == [] 153 154 async def test_delete_with_sessions_moves_to_unsorted(self, client: AsyncClient) -> None: 155 """Deleting a topic removes topic_sessions rows (sessions become unsorted).""" 156 await _auth_headers(client) 157 create = await client.post("/api/topics", json={"name": "Gone"}) 158 topic_id = create.json()["id"] 159 await client.post(f"/api/topics/{topic_id}/sessions", json={"session_id": "sess-1"}) 160 resp = await client.delete(f"/api/topics/{topic_id}") 161 assert resp.status_code == 200 162 assert resp.json() == {"ok": "deleted"} 163 list_resp = await client.get("/api/topics") 164 assert list_resp.json() == [] 165 166 167 class TestTopicSessionAssign: 168 """POST /api/topics/{id}/sessions""" 169 170 async def test_assign_session_to_topic(self, client: AsyncClient) -> None: 171 """POST assigns a session to a topic.""" 172 await _auth_headers(client) 173 create = await client.post("/api/topics", json={"name": "Proj"}) 174 topic_id = create.json()["id"] 175 resp = await client.post( 176 f"/api/topics/{topic_id}/sessions", 177 json={"session_id": "sess-abc"}, 178 ) 179 assert resp.status_code == 200 180 assert resp.json() == {"ok": "assigned"} 181 list_resp = await client.get("/api/topics") 182 topics = list_resp.json() 183 assert topics[0]["session_count"] == 1 184 185 186 class TestTopicSessionUnassign: 187 """DELETE /api/topics/{id}/sessions/{session_id}""" 188 189 async def test_unassign_session(self, client: AsyncClient) -> None: 190 """DELETE removes session assignment.""" 191 await _auth_headers(client) 192 create = await client.post("/api/topics", json={"name": "Proj"}) 193 topic_id = create.json()["id"] 194 await client.post( 195 f"/api/topics/{topic_id}/sessions", 196 json={"session_id": "sess-xyz"}, 197 ) 198 resp = await client.delete(f"/api/topics/{topic_id}/sessions/sess-xyz") 199 assert resp.status_code == 200 200 assert resp.json() == {"ok": "unassigned"} 201 list_resp = await client.get("/api/topics") 202 assert list_resp.json()[0]["session_count"] == 0 203 204 205 class TestTopicNotFound: 206 """404 for non-existent topics.""" 207 208 async def test_update_nonexistent_returns_404(self, client: AsyncClient) -> None: 209 """PATCH on non-existent topic returns 404.""" 210 await _auth_headers(client) 211 resp = await client.patch("/api/topics/nonexistent", json={"name": "X"}) 212 assert resp.status_code == 404 213 body = resp.json() 214 assert "detail" in body 215 assert "not found" in body["detail"].lower() 216 217 async def test_delete_nonexistent_returns_404(self, client: AsyncClient) -> None: 218 """DELETE on non-existent topic returns 404.""" 219 await _auth_headers(client) 220 resp = await client.delete("/api/topics/nonexistent") 221 assert resp.status_code == 404 222 body = resp.json() 223 assert "detail" in body 224 assert "not found" in body["detail"].lower() 225 226 async def test_assign_nonexistent_returns_404(self, client: AsyncClient) -> None: 227 """POST assign on non-existent topic returns 404.""" 228 await _auth_headers(client) 229 resp = await client.post( 230 "/api/topics/nonexistent/sessions", 231 json={"session_id": "sess-1"}, 232 ) 233 assert resp.status_code == 404 234 235 async def test_unassign_nonexistent_returns_404(self, client: AsyncClient) -> None: 236 """DELETE unassign on non-existent topic returns 404.""" 237 await _auth_headers(client) 238 resp = await client.delete("/api/topics/nonexistent/sessions/sess-1") 239 assert resp.status_code == 404