Coverage for tests/test_courses.py: 100%
79 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"""Course REST query regression tests using normalized offline snapshots."""
3import pytest
4from conftest import make_course, set_courses
5from httpx import ASGITransport, AsyncClient
7from data_api.api.api import app
8from data_api.models.courses import BOOLEAN_FIELDS, INTEGER_FIELDS, CourseFieldName
10INVALID_REGEX_PATTERNS = ["[", "(", "\\", "*", "(?invalid)", "a{99999999999999999999}", "(" * 1000]
13@pytest.fixture
14def courses():
15 return [
16 make_course(
17 id=f"11510{identity}",
18 chinese_title=title,
19 english_title=english,
20 lecturers=[{"label": teacher}],
21 credits=credits,
22 note=note,
23 language_code="en" if identity == "B" else (None if identity == "C" else "zh"),
24 )
25 for identity, teacher, title, english, credits, note in [
26 ("A", "林福仁", "服務學習", "C++ [AI]", 0.5, "X-Class"),
27 ("B", "林福仁", "程式設計", "Programming", 3, ""),
28 ("C", "黃老師", "服務設計", "Design", 1.5, ""),
29 ("D", "孫老師", "跨域學習", "Learning", 2, "X-Class"),
30 ]
31 ]
34@pytest.fixture
35async def client(monkeypatch, courses):
36 set_courses(monkeypatch, courses)
37 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
38 yield client
41def test_course_search_query_model_keeps_flat_optional_parameters():
42 operation = app.openapi()["paths"]["/courses"]["get"]
43 assert "requestBody" not in operation
44 parameters = {parameter["name"]: parameter for parameter in operation["parameters"]}
45 assert set(parameters) == {field.value for field in CourseFieldName} | {"type"}
46 assert all(p["in"] == "query" and not p["required"] for p in parameters.values())
49@pytest.mark.parametrize("field", [field.value for field in CourseFieldName])
50async def test_every_get_field_is_accepted(client, field):
51 value = (
52 "false"
53 if field in BOOLEAN_FIELDS
54 else (
55 "0"
56 if field in INTEGER_FIELDS | {"credits"}
57 else ("zh" if field == "language_code" else "中")
58 )
59 )
60 assert (await client.get("/courses", params={field: value})).status_code == 200
63@pytest.mark.parametrize(
64 "params,ids",
65 [
66 ({}, ["A", "B", "C", "D"]),
67 ({"lecturers.label": ""}, ["A", "B", "C", "D"]),
68 ({"lecturers.label": "林福仁"}, ["A", "B"]),
69 ({"chinese_title": "服務"}, ["A", "C"]),
70 ({"lecturers.label": "林福仁", "chinese_title": "服務"}, ["A"]),
71 ({"type": "microcredits"}, ["A", "C"]),
72 ({"type": "xclass"}, ["A", "D"]),
73 ({"type": "xclass", "lecturers.label": "林福仁"}, ["A"]),
74 ({"type": "microcredits", "lecturers.label": "林福仁"}, ["A"]),
75 ({"type": "xclass", "lecturers.label": "黃"}, []),
76 ({"english_title": r"^C\+\+", "id": "^11510A"}, ["A"]),
77 ({"language_code": "zh|en"}, ["A", "B", "D"]),
78 ({"language_code": "^en$"}, ["B"]),
79 ({"language_code": "中"}, []),
80 ({"credits": "3", "is_cancelled": "false", "reserved_for_freshmen": "0"}, ["B"]),
81 ({"is_cancelled": "true"}, []),
82 ],
83)
84async def test_get_collection_filters(client, params, ids):
85 response = await client.get("/courses", params=params)
86 assert response.status_code == 200, response.text
87 assert [course["id"] for course in response.json()] == [f"11510{i}" for i in ids]
88 assert response.headers["X-Total-Count"] == str(len(ids))
89 assert response.headers["X-Data-Commit-Hash"] == "course-test-version"
90 assert not response.history
93@pytest.mark.parametrize(
94 "params",
95 [
96 {"type": "unknown"},
97 {"type": ""},
98 {"type": "XCLASS"},
99 {"credits": "bad"},
100 {"capacity": "1.5"},
101 {"is_cancelled": "bad"},
102 {"teacher": "old"},
103 {"credit": "3"},
104 ],
105)
106async def test_invalid_get_filters(client, params):
107 assert (await client.get("/courses", params=params)).status_code == 422
110@pytest.mark.parametrize("empty", [False, True])
111@pytest.mark.parametrize("pattern", INVALID_REGEX_PATTERNS)
112@pytest.mark.parametrize("field", ["chinese_title", "lecturers.label", "id", "language_code"])
113async def test_get_invalid_regex(client, monkeypatch, empty, pattern, field):
114 if empty:
115 set_courses(monkeypatch, [])
116 response = await client.get("/courses", params={field: pattern})
117 assert response.status_code == 422
118 assert response.json() == {"detail": f"Invalid regular expression for course field '{field}'."}
121@pytest.mark.parametrize("matcher", ["C++ [AI]", "C++", "["])
122async def test_post_exact_text(client, matcher):
123 response = await client.post(
124 "/courses/query", json={"row_field": "english_title", "matcher": matcher}
125 )
126 assert response.status_code == 200
127 assert [course["id"] for course in response.json()] == (
128 ["11510A"] if matcher == "C++ [AI]" else []
129 )
132@pytest.mark.parametrize("empty", [False, True])
133@pytest.mark.parametrize("nested", [False, True])
134@pytest.mark.parametrize("pattern", INVALID_REGEX_PATTERNS)
135async def test_post_invalid_regex(client, monkeypatch, empty, nested, pattern):
136 if empty:
137 set_courses(monkeypatch, [])
138 body = {"row_field": "lecturers.label", "matcher": pattern, "regex_match": True}
139 if nested:
140 body = [{"row_field": "id", "matcher": "absent"}, "and", [body]]
141 response = await client.post("/courses/query", json=body)
142 assert response.status_code == 422
143 assert any("Invalid regular expression" in e["msg"] for e in response.json()["detail"])
146async def test_post_nested_regex_and_typed_matchers(client):
147 response = await client.post(
148 "/courses/query",
149 json=[
150 {"row_field": "is_cancelled", "matcher": False},
151 "and",
152 [
153 {"row_field": "credits", "matcher": 0.5},
154 "or",
155 {"row_field": "lecturers.label", "matcher": "黃", "regex_match": True},
156 ],
157 ],
158 )
159 assert response.status_code == 200
160 assert [c["id"] for c in response.json()] == ["11510A", "11510C"]
161 assert response.headers["X-Total-Count"] == "2"
162 assert response.headers["X-Data-Commit-Hash"] == "course-test-version"
165async def test_large_integer_credit_matcher_does_not_overflow(client):
166 response = await client.post(
167 "/courses/query", json={"row_field": "credits", "matcher": 10**400}
168 )
169 assert response.status_code == 200
170 assert response.json() == []
173async def test_response_is_full_normalized_shape_and_nullable(client, monkeypatch):
174 course = make_course(credits=None, capacity_unlimited=None, language_code=None)
175 set_courses(monkeypatch, [course], version=None)
176 response = await client.get("/courses")
177 assert response.json() == [course.model_dump(mode="json")]
178 assert "X-Data-Commit-Hash" not in response.headers
179 assert (
180 await client.post("/courses/query", json={"row_field": "credits", "matcher": None})
181 ).json() == response.json()
182 assert (await client.get("/courses?type=microcredits")).json() == []