Coverage for tests/test_dining.py: 100%
131 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 dining endpoints."""
3from copy import deepcopy
4from datetime import datetime, timezone
5from itertools import product
7import pytest
8from httpx import ASGITransport, AsyncClient
10from data_api.api import schemas
11from data_api.api.api import app
12from data_api.data.manager import nthudata
13from data_api.domain.dining import services
15pytestmark = pytest.mark.usefixtures("dataset_runtime")
18@pytest.fixture
19def dining_candidates(monkeypatch):
20 data = [
21 {
22 "building": building,
23 "restaurants": [
24 {
25 "area": building,
26 "name": f"{name}-{day}",
27 "note": note,
28 "phone": "",
29 "image": None,
30 "schedule": {},
31 }
32 for name in ["Pizza", "Sushi"]
33 for day, note in [
34 ("always", ""),
35 ("weekday", "平日休息"),
36 ("saturday", "週六休息"),
37 ("sunday", "週日休息"),
38 ]
39 ],
40 }
41 for building in ["小吃部", "水木生活中心"]
42 ] + [{"building": "其他餐廳", "restaurants": []}]
43 original = deepcopy(data)
44 calls = []
46 async def fake_get(endpoint):
47 calls.append(endpoint)
48 return "testhash", data
50 class Saturday(datetime):
51 @classmethod
52 def now(cls, tz=None):
53 assert str(tz) == "Asia/Taipei"
54 return cls(2026, 9, 25, 16, tzinfo=timezone.utc).astimezone(tz)
56 monkeypatch.setattr(nthudata, "get", fake_get)
57 monkeypatch.setattr(services, "datetime", Saturday)
58 yield data, calls
59 assert data == original
62class TestDiningEndpoints:
63 """Tests for dining endpoints."""
65 @pytest.fixture
66 async def client(self):
67 """Create async test client."""
68 async with AsyncClient(
69 transport=ASGITransport(app=app), base_url="http://test", follow_redirects=True
70 ) as client:
71 yield client
73 async def test_get_all_dining(self, client: AsyncClient):
74 """Test getting all dining information."""
75 response = await client.get("/dining")
76 assert response.status_code == 200
78 @pytest.mark.parametrize(
79 "building_name",
80 [_.value for _ in schemas.dining.DiningBuildingName],
81 )
82 async def test_get_dining_by_building(self, client: AsyncClient, building_name: str):
83 """Test getting dining information filtered by building."""
84 params = {"building_name": building_name}
85 response = await client.get("/dining", params=params)
86 assert response.status_code == 200
88 @pytest.mark.parametrize(
89 "schedule",
90 [_.value for _ in schemas.dining.DiningScheduleName],
91 )
92 async def test_get_open_restaurants(self, client: AsyncClient, schedule: str):
93 """Test getting open restaurants by schedule."""
94 params = {"schedule": schedule}
95 response = await client.get("/dining", params=params)
96 assert response.status_code == 200
98 @pytest.mark.parametrize("schedule", [None, "today", "weekday", "saturday", "sunday"])
99 @pytest.mark.parametrize("fuzzy", [True, False])
100 @pytest.mark.parametrize("building_filter,name_filter", list(product([False, True], repeat=2)))
101 async def test_combined_filters(
102 self, client, dining_candidates, schedule, fuzzy, building_filter, name_filter
103 ):
104 data, calls = dining_candidates
105 params = {"fuzzy": fuzzy}
106 if schedule is not None:
107 params["schedule"] = schedule
108 if building_filter:
109 params["building_name"] = "水木生活中心"
110 if name_filter:
111 params["restaurant_name"] = "Sushi"
112 response = await client.get("/dining", params=params)
113 assert response.status_code == 200
114 day = "saturday" if schedule == "today" else schedule
115 # Preserve the existing heuristic: Sunday's single-character "日" also matches "平日".
116 excluded = {
117 None: (),
118 "weekday": ("weekday",),
119 "saturday": ("saturday",),
120 "sunday": ("weekday", "sunday"),
121 }[day]
122 expected = []
123 for building in data:
124 if building_filter and building["building"] != "水木生活中心":
125 continue
126 restaurants = [
127 restaurant
128 for restaurant in building["restaurants"]
129 if (not name_filter or restaurant["name"].startswith("Sushi"))
130 and not restaurant["name"].endswith(excluded)
131 ]
132 if restaurants or (schedule is None and fuzzy and not name_filter):
133 expected.append({**building, "restaurants": restaurants})
134 assert response.json() == expected
135 assert response.headers["X-Data-Commit-Hash"] == "testhash"
136 assert calls == ["dining.json"]
138 @pytest.mark.parametrize("fuzzy", [True, False])
139 @pytest.mark.parametrize(
140 "filters",
141 [
142 {"restaurant_name": "ZZZZZZ"},
143 {"building_name": "其他餐廳"},
144 ],
145 )
146 async def test_no_schedule_matches(self, client, dining_candidates, fuzzy, filters):
147 response = await client.get(
148 "/dining", params={**filters, "schedule": "saturday", "fuzzy": fuzzy}
149 )
150 assert response.status_code == 200
151 assert response.json() == []
152 assert dining_candidates[1] == ["dining.json"]
154 @pytest.mark.parametrize("fuzzy", [True, False])
155 async def test_all_restaurants_closed(self, client, dining_candidates, monkeypatch, fuzzy):
156 data = deepcopy(dining_candidates[0])
157 for building in data:
158 for restaurant in building["restaurants"]:
159 restaurant["note"] = "週六休息"
160 original = deepcopy(data)
162 async def fake_get(endpoint):
163 return "testhash", data
165 monkeypatch.setattr(nthudata, "get", fake_get)
166 response = await client.get("/dining", params={"schedule": "saturday", "fuzzy": fuzzy})
167 assert response.status_code == 200
168 assert response.json() == []
169 assert data == original
171 @pytest.mark.parametrize("fuzzy", [True, False])
172 async def test_schedule_respects_search_mode(self, client, dining_candidates, fuzzy):
173 response = await client.get(
174 "/dining",
175 params={"schedule": "saturday", "restaurant_name": "Susi", "fuzzy": fuzzy},
176 )
177 assert response.status_code == 200
178 names = [r["name"] for b in response.json() for r in b["restaurants"]]
179 assert names == (["Sushi-always", "Sushi-weekday", "Sushi-sunday"] * 2 if fuzzy else [])
181 @pytest.mark.parametrize("schedule", ["", "monday", "now"])
182 async def test_invalid_schedule(self, client, dining_candidates, schedule):
183 response = await client.get("/dining", params={"schedule": schedule})
184 assert response.status_code == 422
185 assert dining_candidates[1] == []
187 @pytest.mark.parametrize("fuzzy", [True, False])
188 async def test_unversioned_data(self, client, dining_candidates, monkeypatch, fuzzy):
189 data, calls = dining_candidates
191 async def fake_get(endpoint):
192 calls.append(endpoint)
193 return None, data
195 monkeypatch.setattr(nthudata, "get", fake_get)
196 response = await client.get("/dining", params={"schedule": "saturday", "fuzzy": fuzzy})
197 assert response.status_code == 200
198 assert response.json()
199 assert "X-Data-Commit-Hash" not in response.headers
200 assert calls == ["dining.json"]
202 async def test_open_route_removed(self, client):
203 response = await client.get("/dining/open", params={"schedule": "today"})
204 assert response.status_code == 404
207def test_dining_openapi_contract():
208 schema = app.openapi()
209 assert "/dining/open" not in schema["paths"]
210 operation = schema["paths"]["/dining"]["get"]
211 assert operation["operationId"] == "getDiningData"
212 parameter = next(p for p in operation["parameters"] if p["name"] == "schedule")
213 assert parameter["required"] is False
214 assert parameter["in"] == "query"
215 assert {"$ref": "#/components/schemas/DiningScheduleName"} in parameter["schema"]["anyOf"]
216 assert schema["components"]["schemas"]["DiningScheduleName"]["enum"] == [
217 "today",
218 "weekday",
219 "saturday",
220 "sunday",
221 ]
222 response = operation["responses"]["200"]["content"]["application/json"]["schema"]
223 assert response["type"] == "array"
224 assert response["items"] == {"$ref": "#/components/schemas/DiningBuilding"}