Coverage for tests/test_courses_semesters.py: 99%

215 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-13 19:28 +0000

1"""Semester discovery, request-local snapshots, failures and REST/MCP consistency.""" 

2 

3import asyncio 

4import hashlib 

5import json 

6from collections import Counter 

7from contextlib import asynccontextmanager 

8from urllib.parse import quote 

9 

10import httpx 

11import pytest 

12from conftest import course_payload 

13from fastmcp import Client 

14from test_data_manager import Clock 

15 

16from data_api.api.api import create_app 

17from data_api.data.nthudata import Freshness, NTHUDataManager 

18from data_api.runtime import Runtime 

19 

20 

21class SemesterPublisher: 

22 def __init__(self): 

23 self.clock = Clock() 

24 self.calls = Counter() 

25 self.errors = {} 

26 self.catalog = ["11420", "11510"] 

27 self.documents = { 

28 "/courses/latest.json": [course_payload(id="11510LATEST")], 

29 "/courses/semesters/11420.json": [ 

30 course_payload( 

31 id="11420OLD", 

32 semester_code="11420", 

33 language_code=None, 

34 lecturers=[{"label": "Old [AI]"}], 

35 credits=0.5, 

36 meetings=[{"classroom": None, "time": "bad", "time_codes": None}], 

37 parsing_issues=["Unrecognized time code"], 

38 ) 

39 ], 

40 "/courses/semesters/11510.json": [ 

41 course_payload(id="11510NEW", lecturers=[{"name_zh": "老師", "name_en": "Teacher"}]) 

42 ], 

43 } 

44 self.version = "a" 

45 

46 async def __call__(self, request): 

47 path = request.url.path 

48 self.calls[path] += 1 

49 if path in self.errors: 

50 error = self.errors[path] 

51 if isinstance(error, Exception): 

52 raise error 

53 return httpx.Response(error) 

54 docs = self.documents | {"/courses/sources.json": {"semesters": self.catalog}} 

55 if path == "/file_details.json": 

56 return httpx.Response( 

57 200, 

58 json={ 

59 "file_details": { 

60 "/": [ 

61 { 

62 "name": name[1:], 

63 "version": self.version + name, 

64 "sha256": hashlib.sha256(json.dumps(data).encode()).hexdigest(), 

65 } 

66 for name, data in docs.items() 

67 ] 

68 } 

69 }, 

70 ) 

71 if path not in docs: 

72 return httpx.Response(404) 

73 return httpx.Response(200, content=json.dumps(docs[path]).encode()) 

74 

75 @asynccontextmanager 

76 async def clients(self): 

77 runtime = Runtime.create(NTHUDataManager(clock=self.clock)) 

78 app = create_app(runtime) 

79 async with runtime.datasets.lifespan( 

80 httpx.AsyncClient(transport=httpx.MockTransport(self)) 

81 ): 

82 async with ( 

83 httpx.AsyncClient( 

84 transport=httpx.ASGITransport(app=app), base_url="http://test" 

85 ) as rest, 

86 Client(app.state.mcp) as mcp, 

87 ): 

88 yield runtime, rest, mcp 

89 

90 

91async def test_discovery_and_default_latest_use_independent_versions(): 

92 publisher = SemesterPublisher() 

93 async with publisher.clients() as (runtime, rest, mcp): 

94 catalog = await rest.get("/courses/semesters") 

95 assert catalog.status_code == 200 

96 assert catalog.json() == ["11510", "11420"] 

97 assert catalog.headers["X-Total-Count"] == "2" 

98 assert catalog.headers["X-Data-Commit-Hash"] == "a/courses/sources.json" 

99 latest = await rest.get("/courses") 

100 named = await rest.get("/courses/11510") 

101 assert latest.json()[0]["id"] == "11510LATEST" 

102 assert named.json()[0]["id"] == "11510NEW" 

103 assert latest.headers["X-Data-Commit-Hash"] == "a/courses/latest.json" 

104 assert named.headers["X-Data-Commit-Hash"] == "a/courses/semesters/11510.json" 

105 for semester, expected in [("11420", "11420OLD"), ("11510", "11510NEW")]: 

106 got = await rest.get(f"/courses/{semester}") 

107 post = await rest.post(f"/courses/{semester}/query", json=[]) 

108 tool = await mcp.call_tool("search_courses", {"semester": semester}) 

109 assert got.json() == post.json() == tool.data["courses"] 

110 assert got.json()[0]["id"] == expected 

111 assert len(got.json()[0]) == 33 

112 assert publisher.calls["/courses/semesters/11420.json"] == 1 

113 assert publisher.calls["/courses/semesters/11510.json"] == 1 

114 assert runtime.datasets.states["/courses/semesters/11420.json"].usable 

115 

116 

117@pytest.mark.parametrize("semester", ["bad", "1151", "115100", "11510", "11510x", "11510\n"]) 

118async def test_invalid_semester_is_rejected_without_upstream_calls(semester): 

119 publisher = SemesterPublisher() 

120 async with publisher.clients() as (runtime, rest, mcp): 

121 for method, suffix, kwargs in [("GET", "", {}), ("POST", "/query", {"json": []})]: 

122 response = await rest.request( 

123 method, f"/courses/{quote(semester, safe='')}{suffix}", **kwargs 

124 ) 

125 assert response.status_code == 422 

126 result = await mcp.call_tool("search_courses", {"semester": semester}, raise_on_error=False) 

127 assert result.is_error 

128 assert not publisher.calls 

129 assert not any("/semesters/" in path for path in runtime.datasets.states) 

130 

131 

132async def test_unknown_and_unavailable_semesters_do_not_fall_back(): 

133 publisher = SemesterPublisher() 

134 async with publisher.clients() as (runtime, rest, mcp): 

135 response = await rest.get("/courses/99999") 

136 assert response.status_code == 404 

137 assert response.json() == {"detail": "Semester is not published."} 

138 result = await mcp.call_tool("search_courses", {"semester": "99999"}, raise_on_error=False) 

139 assert result.is_error 

140 assert "Semester is not published" in result.content[0].text 

141 assert "/courses/semesters/99999.json" not in runtime.datasets.states 

142 publisher.errors["/courses/semesters/11420.json"] = 503 

143 assert (await rest.get("/courses/11420")).status_code == 503 

144 result = await mcp.call_tool("search_courses", {"semester": "11420"}, raise_on_error=False) 

145 assert result.is_error 

146 assert not publisher.calls["/courses/latest.json"] 

147 assert (await rest.get("/courses/11510")).status_code == 200 

148 

149 

150async def test_catalog_failure_does_not_block_latest_or_claim_unknown_is_missing(): 

151 publisher = SemesterPublisher() 

152 async with publisher.clients() as (_, rest, _): 

153 publisher.errors["/courses/sources.json"] = 503 

154 assert (await rest.get("/courses/11420")).status_code == 503 

155 assert (await rest.get("/courses")).status_code == 200 

156 publisher.errors.clear() 

157 async with publisher.clients() as (runtime, rest, _): 

158 assert (await rest.get("/courses/11420")).status_code == 200 

159 publisher.errors["/file_details.json"] = 503 

160 publisher.clock.advance() 

161 assert (await rest.get("/courses/99999")).status_code == 503 

162 assert runtime.courses.catalog_state.freshness == Freshness.UNVERIFIED 

163 assert (await rest.get("/courses/11420")).status_code == 200 

164 

165 

166async def test_cross_semester_concurrency_coalesces_only_same_path_and_keeps_versions(): 

167 publisher = SemesterPublisher() 

168 entered, release = asyncio.Event(), asyncio.Event() 

169 

170 async def handler(request): 

171 if request.url.path == "/courses/semesters/11420.json": 

172 entered.set() 

173 await release.wait() 

174 return await publisher(request) 

175 

176 runtime = Runtime.create(NTHUDataManager(clock=publisher.clock)) 

177 async with runtime.datasets.lifespan(httpx.AsyncClient(transport=httpx.MockTransport(handler))): 

178 async with httpx.AsyncClient( 

179 transport=httpx.ASGITransport(app=create_app(runtime)), base_url="http://test" 

180 ) as rest: 

181 pending = [asyncio.create_task(rest.get("/courses/11420")) for _ in range(20)] 

182 await asyncio.wait_for(entered.wait(), 2) 

183 other = await asyncio.wait_for(rest.get("/courses/11510"), 2) 

184 assert other.json()[0]["id"] == "11510NEW" 

185 release.set() 

186 responses = await asyncio.gather(*pending) 

187 assert all(r.json()[0]["id"] == "11420OLD" for r in responses) 

188 assert all( 

189 r.headers["X-Data-Commit-Hash"] == "a/courses/semesters/11420.json" 

190 for r in responses 

191 ) 

192 assert publisher.calls["/courses/semesters/11420.json"] == 1 

193 

194 

195async def test_failed_refresh_retains_only_same_semester_and_recovers_after_ttl(): 

196 publisher = SemesterPublisher() 

197 async with publisher.clients() as (runtime, rest, _): 

198 before = await rest.get("/courses/11420") 

199 publisher.version = "b" 

200 publisher.documents["/courses/semesters/11420.json"] = [course_payload()] 

201 publisher.clock.advance() 

202 stale = await rest.get("/courses/11420") 

203 assert stale.json() == before.json() 

204 assert stale.headers["X-Data-Commit-Hash"] == before.headers["X-Data-Commit-Hash"] 

205 state = runtime.datasets.states["/courses/semesters/11420.json"] 

206 assert state.freshness == Freshness.STALE 

207 calls = publisher.calls.copy() 

208 assert (await rest.get("/courses/11420")).status_code == 200 

209 assert publisher.calls == calls 

210 publisher.documents["/courses/semesters/11420.json"] = [] 

211 publisher.clock.advance() 

212 empty = await rest.get("/courses/11420") 

213 assert empty.json() == [] 

214 assert empty.headers["X-Total-Count"] == "0" 

215 assert state.freshness == Freshness.CURRENT 

216 assert all(not state.usable for state in runtime.datasets.states.values()) 

217 

218 

219async def test_filters_apply_to_selected_semester_and_teacher_union(): 

220 publisher = SemesterPublisher() 

221 async with publisher.clients() as (_, rest, mcp): 

222 response = await rest.get("/courses/11420", params={"lecturers.label": r"\[AI\]"}) 

223 assert len(response.json()) == 1 

224 assert (await rest.get("/courses/11420?type=microcredits")).json() == response.json() 

225 assert (await rest.get("/courses/11420?credits=3")).json() == [] 

226 assert (await rest.get("/courses/11420?language_code=zh%7Cen")).json() == [] 

227 assert (await rest.get("/courses/11510", params={"language_code": "zh|en"})).json()[0][ 

228 "id" 

229 ] == "11510NEW" 

230 for teacher in ["Old [AI]", "[AI]"]: 

231 result = await mcp.call_tool( 

232 "search_courses", {"semester": "11420", "teacher": teacher} 

233 ) 

234 assert result.data["courses"] == response.json() 

235 for teacher in ["老師", "Teacher"]: 

236 result = await mcp.call_tool( 

237 "search_courses", {"semester": "11510", "teacher": teacher} 

238 ) 

239 assert result.data["count"] == 1 

240 result = await mcp.call_tool("search_courses", {"semester": "11420", "teacher": "missing"}) 

241 assert result.data == {"count": 0, "courses": []} 

242 

243 

244async def test_catalog_refresh_discovers_new_semester_and_rejects_invalid_replacement(): 

245 publisher = SemesterPublisher() 

246 async with publisher.clients() as (runtime, rest, _): 

247 initial = await rest.get("/courses/semesters") 

248 assert (await rest.get("/courses/11610")).status_code == 404 

249 publisher.catalog.append("11610") 

250 publisher.documents["/courses/semesters/11610.json"] = [ 

251 course_payload(id="11610NEW", semester_code="11610") 

252 ] 

253 publisher.version = "b" 

254 publisher.clock.advance() 

255 assert (await rest.get("/courses/11610")).json()[0]["id"] == "11610NEW" 

256 publisher.catalog.append("11610") 

257 publisher.version = "c" 

258 publisher.clock.advance() 

259 catalog = await rest.get("/courses/semesters") 

260 assert catalog.json() == ["11610"] + initial.json() 

261 assert runtime.courses.catalog_state.freshness == Freshness.STALE 

262 assert (await rest.get("/courses/11710")).status_code == 503 

263 

264 

265async def test_cancellation_releases_semester_lock_and_allows_retry(): 

266 publisher = SemesterPublisher() 

267 entered = asyncio.Event() 

268 block = True 

269 

270 async def handler(request): 

271 if request.url.path == "/courses/semesters/11420.json" and block: 

272 entered.set() 

273 await asyncio.Event().wait() 

274 return await publisher(request) 

275 

276 runtime = Runtime.create(NTHUDataManager(clock=publisher.clock)) 

277 async with runtime.datasets.lifespan(httpx.AsyncClient(transport=httpx.MockTransport(handler))): 

278 task = asyncio.create_task(runtime.courses.get_snapshot("11420")) 

279 await asyncio.wait_for(entered.wait(), 2) 

280 task.cancel() 

281 with pytest.raises(asyncio.CancelledError): 

282 await task 

283 state = runtime.datasets.states["/courses/semesters/11420.json"] 

284 assert not state.lock.locked() 

285 block = False 

286 snapshot = await runtime.courses.get_snapshot("11420") 

287 assert snapshot.data[0].id == "11420OLD" 

288 

289 

290async def test_lazy_unavailable_semester_is_reported_by_readiness_without_refresh(): 

291 publisher = SemesterPublisher() 

292 async with publisher.clients() as (_, rest, _): 

293 publisher.errors["/courses/semesters/11420.json"] = 503 

294 assert (await rest.get("/courses/11420")).status_code == 503 

295 calls = publisher.calls.copy() 

296 response = await rest.get("/ping") 

297 assert response.status_code == 503 

298 assert response.json()["datasets"]["/courses/semesters/11420.json"]["usable"] is False 

299 assert publisher.calls == calls