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

1"""Tests for dining endpoints.""" 

2 

3from copy import deepcopy 

4from datetime import datetime, timezone 

5from itertools import product 

6 

7import pytest 

8from httpx import ASGITransport, AsyncClient 

9 

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 

14 

15pytestmark = pytest.mark.usefixtures("dataset_runtime") 

16 

17 

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 = [] 

45 

46 async def fake_get(endpoint): 

47 calls.append(endpoint) 

48 return "testhash", data 

49 

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) 

55 

56 monkeypatch.setattr(nthudata, "get", fake_get) 

57 monkeypatch.setattr(services, "datetime", Saturday) 

58 yield data, calls 

59 assert data == original 

60 

61 

62class TestDiningEndpoints: 

63 """Tests for dining endpoints.""" 

64 

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 

72 

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 

77 

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 

87 

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 

97 

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"] 

137 

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"] 

153 

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) 

161 

162 async def fake_get(endpoint): 

163 return "testhash", data 

164 

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 

170 

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 []) 

180 

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] == [] 

186 

187 @pytest.mark.parametrize("fuzzy", [True, False]) 

188 async def test_unversioned_data(self, client, dining_candidates, monkeypatch, fuzzy): 

189 data, calls = dining_candidates 

190 

191 async def fake_get(endpoint): 

192 calls.append(endpoint) 

193 return None, data 

194 

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"] 

201 

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 

205 

206 

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"}