Coverage for tests/test_runtime_lifecycle.py: 99%
111 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"""Production REST/MCP wiring, startup, shutdown and runtime refresh."""
3import httpx
4import pytest
5from conftest import course_payload
6from fastmcp import Client
7from test_data_manager import Clock, Publisher
9from data_api.api import api as api_module
10from data_api.data.manager import nthudata
11from data_api.data.nthudata import Freshness
12from data_api.domain.courses.services import courses_service
13from data_api.mcp.server import mcp
16async def test_rest_and_mcp_observe_runtime_course_refresh(monkeypatch):
17 clock = Clock()
18 publisher = Publisher(
19 [course_payload(id="11510A", english_title="Version A")], "courses/latest.json"
20 )
21 monkeypatch.setattr(nthudata, "clock", clock)
22 async with nthudata.lifespan(publisher.client()):
23 async with (
24 httpx.AsyncClient(
25 transport=httpx.ASGITransport(app=api_module.app), base_url="http://test"
26 ) as rest,
27 Client(mcp) as tools,
28 ):
29 first = await rest.get("/courses")
30 assert first.json()[0]["id"] == "11510A"
31 assert first.headers["X-Data-Commit-Hash"] == "a"
32 publisher.version, publisher.payload = "b", [
33 course_payload(id="11510B", english_title="Version B")
34 ]
35 clock.advance()
36 second = await rest.get("/courses?english_title=Version")
37 assert second.json()[0]["id"] == "11510B"
38 assert second.headers["X-Data-Commit-Hash"] == "b"
39 result = await tools.call_tool("search_courses", {})
40 assert result.data["courses"][0]["id"] == "11510B"
41 assert courses_service.state is nthudata.states["/courses/latest.json"]
42 assert (
43 await courses_service.get_snapshot()
44 ).data is courses_service.state.snapshot.data
45 assert publisher.calls.count("/courses/latest.json") == 2
47 publisher.version, publisher.payload = "c", [course_payload(id="11510C")]
48 clock.advance()
49 result = await tools.call_tool("search_courses", {})
50 assert result.data["courses"][0]["id"] == "11510C"
51 assert (await rest.get("/courses")).json()[0]["id"] == "11510C"
52 publisher.version, publisher.data_error = "d", 503
53 clock.advance()
54 fallback = await rest.get("/courses")
55 assert fallback.status_code == 200
56 assert fallback.json()[0]["id"] == "11510C"
57 assert fallback.headers["X-Data-Commit-Hash"] == "c"
58 assert courses_service.state.freshness == Freshness.STALE
61@pytest.mark.parametrize("empty", [True, False])
62async def test_cold_start_empty_is_not_unavailable(empty):
63 publisher = Publisher([], "courses/latest.json")
64 if not empty:
65 publisher.data_error = 503
66 async with nthudata.lifespan(publisher.client()):
67 async with (
68 httpx.AsyncClient(
69 transport=httpx.ASGITransport(app=api_module.app), base_url="http://test"
70 ) as rest,
71 Client(mcp) as tools,
72 ):
73 response = await rest.get("/courses")
74 result = await tools.call_tool("search_courses", {}, raise_on_error=False)
75 if empty:
76 assert response.status_code == 200
77 assert response.json() == []
78 assert not result.is_error
79 assert result.data == {"count": 0, "courses": []}
80 else:
81 assert response.status_code == 503
82 assert response.json() == {"detail": "Service temporarily unavailable"}
83 assert result.is_error
84 assert "temporarily unavailable" in result.content[0].text
85 assert "Traceback" not in result.content[0].text
86 assert "http" not in result.content[0].text
89@pytest.mark.parametrize(
90 "endpoint,path,payload,tool,args",
91 [
92 ("dining.json", "/dining", [], "find_dining", {}),
93 (
94 "dining.json",
95 "/dining?schedule=today",
96 [],
97 "find_dining",
98 {"check_open": "today"},
99 ),
100 (
101 "dining.json",
102 "/dining?schedule=today&fuzzy=false",
103 [],
104 "find_dining",
105 {"check_open": "today"},
106 ),
107 ("announcements.json", "/announcements", [], "get_announcements", {}),
108 ("newsletters.json", "/newsletters", [], "get_newsletters", {}),
109 ("buses.json", "/buses/routes", {}, "get_bus_schedule", {}),
110 ("buses.json", "/buses/stops", {}, "get_bus_schedule", {}),
111 (
112 "buses.json",
113 "/buses/schedule?route=main&day=weekday&direction=up",
114 {},
115 "get_bus_schedule",
116 {},
117 ),
118 (
119 "buses.json",
120 "/buses/schedule?stop=北校門口&route=main&day=weekday&direction=up&details=true",
121 {},
122 "get_bus_schedule",
123 {},
124 ),
125 (
126 "buses.json",
127 "/buses/schedule?stop=台積館",
128 {},
129 "get_bus_schedule",
130 {"stop": "台積館"},
131 ),
132 ],
133)
134async def test_other_datasets_empty_unversioned_and_unavailable(
135 endpoint, path, payload, tool, args
136):
137 publisher = Publisher(payload, endpoint)
138 publisher.version = None
139 async with nthudata.lifespan(publisher.client()):
140 async with (
141 httpx.AsyncClient(
142 transport=httpx.ASGITransport(app=api_module.app), base_url="http://test"
143 ) as rest,
144 Client(mcp) as tools,
145 ):
146 response = await rest.get(path)
147 assert response.status_code == 200
148 assert "X-Data-Commit-Hash" not in response.headers
149 assert not (await tools.call_tool(tool, args)).is_error
150 publisher.data_error = 503
151 async with nthudata.lifespan(publisher.client()):
152 async with (
153 httpx.AsyncClient(
154 transport=httpx.ASGITransport(app=api_module.app), base_url="http://test"
155 ) as rest,
156 Client(mcp) as tools,
157 ):
158 assert (await rest.get(path)).status_code == 503
159 result = await tools.call_tool(tool, args, raise_on_error=False)
160 assert result.is_error
161 assert "temporarily unavailable" in result.content[0].text
164@pytest.mark.parametrize("upstream_available", [True, False])
165async def test_real_data_lifespan_client_cleanup(monkeypatch, upstream_available):
166 publisher = Publisher([course_payload()], "courses/latest.json")
167 if not upstream_available:
168 publisher.data_error = 503
170 async def handler(self, request):
171 return await publisher(request)
173 monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", handler)
174 async with api_module.lifespan(api_module.app):
175 client = nthudata.fetcher.client
176 assert client is not None
177 assert not client.is_closed
178 assert api_module.app.state.datasets is nthudata
179 assert courses_service.state.usable is upstream_available
180 if upstream_available:
181 assert client.timeout.read == 15
182 async with httpx.AsyncClient(
183 transport=httpx.ASGITransport(app=api_module.app), base_url="http://test"
184 ) as rest:
185 assert (await rest.get("/courses")).status_code == (200 if upstream_available else 503)
186 assert client.is_closed
187 assert nthudata.fetcher.client is None
188 assert not courses_service.state.usable
191async def test_app_startup_exception_closes_client(monkeypatch):
192 client = None
194 async def fail(endpoints):
195 nonlocal client
196 client = nthudata.fetcher.client
197 raise RuntimeError("unexpected startup bug")
199 monkeypatch.setattr(nthudata, "prefetch", fail)
200 with pytest.raises(RuntimeError, match="startup bug"):
201 async with api_module.lifespan(api_module.app):
202 pass
203 assert client is not None
204 assert client.is_closed
205 assert nthudata.fetcher.client is None