Coverage for tests/test_app_lifespan.py: 98%
209 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
1"""Tests for the exported app's middleware, REST/MCP routes, and lifespans.
3The data layer is stubbed to keep tests offline. Most tests also stub the MCP
4lifespan: its real session manager can only start once per app. One integration
5test exercises the real manager and HTTP transport through the exported app.
6"""
8import asyncio
9import json
10import logging
11from contextlib import asynccontextmanager
12from types import SimpleNamespace
14import pytest
15from conftest import course_payload
16from fastapi import HTTPException
17from fastapi.exceptions import ResponseValidationError
18from fastapi.testclient import TestClient
19from httpx import ASGITransport, AsyncClient
21from data_api.api import api as api_module
22from data_api.api.api import app
23from data_api.core import config
24from data_api.core.exceptions import (
25 DataNotAvailableException,
26 UpstreamResponseException,
27 UpstreamTimeoutException,
28)
29from data_api.data.manager import nthudata
30from data_api.domain.buses import services as buses_services
31from data_api.domain.courses import services as courses_services
34@asynccontextmanager
35async def _noop_lifespan(app):
36 """Stand in for the MCP lifespan, which cannot be started twice."""
37 yield
40class TestCombinedLifespan:
41 """Tests for the startup tasks chained into the combined lifespan."""
43 async def test_runs_startup_tasks_in_order(self, monkeypatch):
44 """Test the combined lifespan pre-fetches data and updates both services."""
45 calls = []
47 async def fake_prefetch(endpoints):
48 calls.append(("prefetch", list(endpoints)))
49 return {endpoint: True for endpoint in endpoints}
51 async def fake_buses_update():
52 calls.append(("buses", None))
54 async def fake_courses_update():
55 calls.append(("courses", None))
57 monkeypatch.setattr(api_module, "mcp_app", SimpleNamespace(lifespan=_noop_lifespan))
58 monkeypatch.setattr(nthudata, "prefetch", fake_prefetch)
59 monkeypatch.setattr(buses_services.buses_service, "update_data", fake_buses_update)
60 monkeypatch.setattr(courses_services.courses_service, "get_snapshot", fake_courses_update)
62 async with api_module.combined_lifespan(app):
63 pass
65 assert [name for name, _ in calls] == ["prefetch", "buses", "courses"]
66 assert calls[0][1] == config.PREFETCH_ENDPOINTS
68 async def test_reports_failed_endpoints_as_ascii(self, monkeypatch, caplog):
69 """Lifecycle logs stay ASCII and summarize partial availability."""
71 async def fake_prefetch(endpoints):
72 return {endpoint: index == 0 for index, endpoint in enumerate(endpoints)}
74 async def noop():
75 return None
77 monkeypatch.setattr(api_module, "mcp_app", SimpleNamespace(lifespan=_noop_lifespan))
78 monkeypatch.setattr(nthudata, "prefetch", fake_prefetch)
79 monkeypatch.setattr(buses_services.buses_service, "update_data", noop)
80 monkeypatch.setattr(courses_services.courses_service, "get_snapshot", noop)
82 with caplog.at_level(logging.INFO):
83 async with api_module.combined_lifespan(app):
84 pass
85 assert f"Data startup: 1/{len(config.PREFETCH_ENDPOINTS)} datasets usable" in caplog.text
86 caplog.text.encode("ascii")
88 @pytest.mark.parametrize("startup_fails", [False, True])
89 async def test_exported_lifespan_orders_and_unwinds_contexts(self, monkeypatch, startup_fails):
90 calls = []
92 @asynccontextmanager
93 async def fake_mcp_lifespan(application):
94 assert application is app
95 calls.append("mcp startup")
96 try:
97 yield
98 finally:
99 calls.append("mcp shutdown")
101 @asynccontextmanager
102 async def fake_data_lifespan(application):
103 assert application is app
104 calls.append("data startup")
105 if startup_fails:
106 raise RuntimeError("startup failed")
107 try:
108 yield
109 finally:
110 calls.append("data shutdown")
112 monkeypatch.setattr(api_module, "mcp_app", SimpleNamespace(lifespan=fake_mcp_lifespan))
113 monkeypatch.setattr(api_module, "lifespan", fake_data_lifespan)
115 if startup_fails:
116 with pytest.raises(RuntimeError, match="startup failed"):
117 async with app.router.lifespan_context(app):
118 pass
119 assert calls == ["mcp startup", "data startup", "mcp shutdown"]
120 else:
121 async with app.router.lifespan_context(app):
122 assert calls == ["mcp startup", "data startup"]
123 assert calls == ["mcp startup", "data startup", "data shutdown", "mcp shutdown"]
126FAKE_COURSE = course_payload(chinese_title="測試課程")
129async def _fake_fetch(url: str, sha256=None):
130 """Serve in-memory data in place of a request to data.nthusa.tw."""
131 if url.endswith("file_details.json"):
132 return {
133 "file_details": {
134 "/": [
135 {"name": name, "version": "testcommithash"}
136 for name in config.PREFETCH_ENDPOINTS
137 ]
138 }
139 }
140 if url.endswith("courses/latest.json"):
141 return [FAKE_COURSE]
142 if url.endswith("courses/sources.json"):
143 return {"semesters": ["11510"]}
144 if url.endswith("buses.json"):
145 return {}
146 return []
149class TestCoursesAfterStartup:
150 """Tests that the app's own lifespan is attached to the app that gets served."""
152 async def test_courses_endpoint_returns_data_after_startup(self, monkeypatch):
153 """Test /courses/ is populated once the app's own lifespan has run.
155 Exercise actual validation and conversion against a fixed upstream response.
156 """
157 monkeypatch.setattr(api_module, "mcp_app", SimpleNamespace(lifespan=_noop_lifespan))
158 monkeypatch.setattr(nthudata.fetcher, "fetch_json", _fake_fetch)
160 async with app.router.lifespan_context(app):
161 assert courses_services.courses_service.state.usable, "courses data was not loaded"
163 async with AsyncClient(
164 transport=ASGITransport(app=app), base_url="http://test", follow_redirects=True
165 ) as client:
166 response = await client.get("/courses")
168 assert response.status_code == 200
169 assert len(response.json()) == 1
170 assert response.json()[0]["chinese_title"] == "測試課程"
173@pytest.mark.parametrize("failure", ["dependency", "response_validation"])
174async def test_unexpected_pre_response_errors_keep_safe_body_and_headers(
175 monkeypatch, caplog, failure
176):
177 private = "Private bus failure at https://upstream.example/credentials"
179 async def update_data():
180 if failure == "dependency":
181 raise RuntimeError(private)
183 monkeypatch.setattr(buses_services.buses_service, "update_data", update_data)
184 if failure == "response_validation":
185 monkeypatch.setattr(
186 buses_services.buses_service,
187 "get_route_info",
188 lambda *_: [{"direction": private}],
189 )
191 async with AsyncClient(
192 transport=ASGITransport(app=app, raise_app_exceptions=True), base_url="http://test"
193 ) as client:
194 response = await client.get("/buses/routes", headers={"Origin": "https://example.com"})
196 assert response.status_code == 500
197 assert response.json() == {"detail": "Internal server error"}
198 assert "Private" not in response.text
199 assert "https://upstream.example" not in response.text
200 assert response.headers["Access-Control-Allow-Origin"] == "*"
201 assert float(response.headers["X-Process-Time"]) >= 0
202 assert "x-process-time" in response.headers["Access-Control-Expose-Headers"].lower()
203 assert private in caplog.text
204 assert any(
205 record.message == "Unexpected request failure" and record.exc_info
206 for record in caplog.records
207 )
208 if failure == "response_validation":
209 assert any(
210 isinstance(record.exc_info[1], ResponseValidationError)
211 for record in caplog.records
212 if record.exc_info
213 )
216@pytest.mark.parametrize(
217 "error,status,detail",
218 [
219 pytest.param(
220 DataNotAvailableException("Private dataset state"),
221 503,
222 "Service temporarily unavailable",
223 id="snapshot",
224 ),
225 pytest.param(
226 UpstreamTimeoutException("Private upstream timeout"),
227 504,
228 "Upstream request timed out",
229 id="upstream-timeout",
230 ),
231 pytest.param(
232 UpstreamResponseException("Private upstream response"),
233 502,
234 "Invalid response from upstream service",
235 id="upstream-response",
236 ),
237 pytest.param(
238 HTTPException(status_code=409, detail="Expected conflict"),
239 409,
240 "Expected conflict",
241 id="http-exception",
242 ),
243 ],
244)
245async def test_expected_dependency_errors_keep_status_and_headers(
246 monkeypatch, error, status, detail
247):
248 async def update_data():
249 raise error
251 monkeypatch.setattr(buses_services.buses_service, "update_data", update_data)
252 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
253 response = await client.get("/buses/routes", headers={"Origin": "https://example.com"})
255 assert response.status_code == status
256 assert response.json() == {"detail": detail}
257 assert response.headers["Access-Control-Allow-Origin"] == "*"
258 assert float(response.headers["X-Process-Time"]) >= 0
261async def test_error_after_stream_starts_is_not_replaced():
262 sent = []
264 async def fail_after_start(scope, receive, send):
265 await send({"type": "http.response.start", "status": 200, "headers": []})
266 await send({"type": "http.response.body", "body": b"partial", "more_body": True})
267 raise RuntimeError("Late stream failure")
269 async def send(message):
270 sent.append(message)
272 async def receive():
273 return {"type": "http.request", "body": b"", "more_body": False}
275 middleware = api_module._SafeUnexpectedErrorsMiddleware(fail_after_start)
276 with pytest.raises(RuntimeError, match="Late stream failure"):
277 await middleware({"type": "http"}, receive, send)
279 assert [message["type"] for message in sent] == ["http.response.start", "http.response.body"]
282async def test_cancelled_request_is_not_formatted():
283 sent = []
285 async def cancel(scope, receive, send):
286 raise asyncio.CancelledError()
288 async def send(message):
289 sent.append(message)
291 async def receive():
292 return {"type": "http.request", "body": b"", "more_body": False}
294 middleware = api_module._SafeUnexpectedErrorsMiddleware(cancel)
295 with pytest.raises(asyncio.CancelledError):
296 await middleware({"type": "http"}, receive, send)
298 assert not sent
301def test_exported_app_serves_rest_and_mcp_with_middleware(monkeypatch):
302 """Start the real ASGI app and MCP manager without external data access."""
304 async def fake_prefetch(endpoints):
305 return {endpoint: True for endpoint in endpoints}
307 async def noop():
308 return None
310 monkeypatch.setattr(nthudata, "prefetch", fake_prefetch)
311 monkeypatch.setattr(nthudata.fetcher, "fetch_json", _fake_fetch)
312 monkeypatch.setattr(buses_services.buses_service, "update_data", noop)
313 origin = "https://example.com"
315 with TestClient(app) as client:
316 response = client.get("/courses", headers={"Origin": origin})
317 assert response.status_code == 200
318 assert [course["id"] for course in response.json()] == [FAKE_COURSE["id"]]
319 assert float(response.headers["X-Process-Time"]) >= 0
320 assert response.headers["Access-Control-Allow-Origin"] == "*"
321 assert response.headers["X-Total-Count"] == "1"
322 assert response.headers["X-Data-Commit-Hash"] == "testcommithash"
323 exposed = {
324 header.strip().lower()
325 for header in response.headers["Access-Control-Expose-Headers"].split(",")
326 }
327 assert {"x-total-count", "x-data-commit-hash", "x-process-time"} <= exposed
329 for path, method in [("/courses", "GET"), ("/mcp", "POST")]:
330 response = client.options(
331 path,
332 headers={
333 "Origin": origin,
334 "Access-Control-Request-Method": method,
335 "Access-Control-Request-Headers": "Content-Type, MCP-Protocol-Version",
336 },
337 )
338 assert response.status_code == 200
339 assert response.headers["Access-Control-Allow-Origin"] == "*"
340 assert method in response.headers["Access-Control-Allow-Methods"]
341 assert (
342 "mcp-protocol-version" in response.headers["Access-Control-Allow-Headers"].lower()
343 )
344 assert float(response.headers["X-Process-Time"]) >= 0
346 response = client.get("/openapi.json")
347 assert response.status_code == 200
348 assert "/courses" in response.json()["paths"]
350 for method, params in [
351 (
352 "initialize",
353 {
354 "protocolVersion": "2025-03-26",
355 "capabilities": {},
356 "clientInfo": {"name": "phase-a-test", "version": "1.0"},
357 },
358 ),
359 ("tools/list", {}),
360 ("tools/call", {"name": "search_courses", "arguments": {"limit": 1}}),
361 ]:
362 response = client.post(
363 "/mcp",
364 headers={
365 "Origin": origin,
366 "Accept": "application/json, text/event-stream",
367 },
368 json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
369 )
370 assert response.status_code == 200
371 assert float(response.headers["X-Process-Time"]) >= 0
372 assert response.headers["Access-Control-Allow-Origin"] == "*"
373 messages = [
374 json.loads(line.removeprefix("data: "))
375 for line in response.text.splitlines()
376 if line.startswith("data: ")
377 ]
378 assert len(messages) == 1
379 assert "error" not in messages[0]
380 result = messages[0]["result"]
381 if method == "initialize":
382 assert "serverInfo" in result
383 elif method == "tools/list":
384 assert "search_courses" in {tool["name"] for tool in result["tools"]}
385 else:
386 assert not result.get("isError", False)
387 content = json.loads(result["content"][0]["text"])
388 assert content["count"] == 1
389 assert content["courses"][0]["id"] == FAKE_COURSE["id"]