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
« 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."""
3import asyncio
4import hashlib
5import json
6from collections import Counter
7from contextlib import asynccontextmanager
8from urllib.parse import quote
10import httpx
11import pytest
12from conftest import course_payload
13from fastmcp import Client
14from test_data_manager import Clock
16from data_api.api.api import create_app
17from data_api.data.nthudata import Freshness, NTHUDataManager
18from data_api.runtime import Runtime
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"
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())
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
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
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)
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
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
166async def test_cross_semester_concurrency_coalesces_only_same_path_and_keeps_versions():
167 publisher = SemesterPublisher()
168 entered, release = asyncio.Event(), asyncio.Event()
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)
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
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())
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": []}
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
265async def test_cancellation_releases_semester_lock_and_allows_retry():
266 publisher = SemesterPublisher()
267 entered = asyncio.Event()
268 block = True
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)
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"
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