Coverage for tests/test_announcements.py: 100%

106 statements  

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

1"""Tests for announcements endpoints.""" 

2 

3from copy import deepcopy 

4from itertools import product 

5 

6import pytest 

7from httpx import ASGITransport, AsyncClient 

8 

9from data_api.api.api import app 

10from data_api.data.manager import nthudata 

11from data_api.domain.announcements.services import announcements_service 

12 

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

14 

15 

16@pytest.fixture 

17def announcement_sources(monkeypatch): 

18 sources = [ 

19 { 

20 "title": "Notice", 

21 "link": "https://academic.site.nthu.edu.tw/p/403-1007-1504-1.php", 

22 "department": "Academic", 

23 "language": "en", 

24 "articles": [ 

25 { 

26 "title": "Notice", 

27 "link": "https://example.com/article", 

28 "date": "2026-10-05", 

29 } 

30 ], 

31 }, 

32 { 

33 "title": "Notice", 

34 "link": "hTtP://Academic.Site.Nthu.Edu.Tw/p/403-1007-1505-1.php", 

35 "department": "Academic", 

36 "language": "zh-tw", 

37 "articles": [], 

38 }, 

39 { 

40 "title": "Notice", 

41 "link": "https://student.site.nthu.edu.tw/", 

42 "department": "Student", 

43 "language": "en", 

44 "articles": [ 

45 { 

46 "title": "Notice", 

47 "link": "https://academic.site.nthu.edu.tw/article", 

48 "date": "2026-10-05", 

49 } 

50 ], 

51 }, 

52 { 

53 "title": "Notice", 

54 "link": "HtTpS://Events.Site.Nthu.Edu.Tw/P/Case?Token=AbC#Frag", 

55 "department": "Events", 

56 "language": "en", 

57 "articles": [], 

58 }, 

59 ] 

60 original = deepcopy(sources) 

61 

62 async def get_announcements(endpoint): 

63 assert endpoint == "announcements.json" 

64 return "test-commit", sources 

65 

66 monkeypatch.setattr(nthudata, "get", get_announcements) 

67 yield sources 

68 assert sources == original 

69 

70 

71class TestAnnouncementsEndpoints: 

72 """Tests for announcements endpoints.""" 

73 

74 @pytest.fixture 

75 async def client(self): 

76 """Create async test client.""" 

77 async with AsyncClient( 

78 transport=ASGITransport(app=app), base_url="http://test", follow_redirects=True 

79 ) as client: 

80 yield client 

81 

82 async def test_get_all_announcements(self, client: AsyncClient): 

83 """Test getting all announcements.""" 

84 response = await client.get("/announcements") 

85 assert response.status_code == 200 

86 

87 @pytest.mark.parametrize( 

88 "department", 

89 ["清華公佈欄", "國立清華大學學生會"], 

90 ) 

91 async def test_get_announcements_by_department(self, client: AsyncClient, department: str): 

92 """Test getting announcements filtered by department.""" 

93 params = {"department": department} 

94 response = await client.get("/announcements", params=params) 

95 assert response.status_code == 200 

96 

97 @pytest.mark.parametrize("path", ["/announcements"]) 

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

99 @pytest.mark.parametrize( 

100 "url, indices", 

101 [ 

102 ("academic.site.nthu.edu.tw", [0, 1]), 

103 ("academic.site.nthu.edu.tw/p/403-1007-1504-1.php", [0]), 

104 ("403-1007-1504-1.php", [0]), 

105 ("403-1007-1504-1.PHP", []), 

106 ("https://academic.site.nthu.edu.tw", [0, 1]), 

107 ("http://academic.site.nthu.edu.tw", [0, 1]), 

108 ("HTTPS://Academic.Site.Nthu.Edu.Tw", [0, 1]), 

109 ("hTtPs://aCaDeMiC.sItE.nThU.eDu.Tw/p/403-1007-1504-1.php", [0]), 

110 ("HTTPS://events.site.nthu.edu.tw/P/Case?Token=AbC#Frag", [3]), 

111 ("events.site.nthu.edu.tw/P/Case", [3]), 

112 ("P/Case", [3]), 

113 ("p/Case", []), 

114 ("EVENTS.SITE.NTHU.EDU.TW/p/Case", []), 

115 ("HTTPS://events.site.nthu.edu.tw/P/case", []), 

116 ("events.site.nthu.edu.tw/P/Case?token=AbC", []), 

117 ("events.site.nthu.edu.tw/P/Case?Token=abc", []), 

118 ("events.site.nthu.edu.tw/P/Case?Token=AbC#frag", []), 

119 ("/P/Case?Token=AbC#Frag", [3]), 

120 ("/p/Case?Token=AbC#Frag", []), 

121 ("nonexistent.site.nthu.edu.tw", []), 

122 ("academic.site.nthu.edu.tx", []), 

123 ("", [0, 1, 2, 3]), 

124 ], 

125 ) 

126 async def test_filter_by_source_url( 

127 self, client: AsyncClient, announcement_sources, path, fuzzy, url, indices 

128 ): 

129 response = await client.get(path, params={"url": url, "fuzzy": fuzzy}) 

130 assert response.status_code == 200 

131 expected = [deepcopy(announcement_sources[index]) for index in indices] 

132 for source in expected: 

133 if source["department"] == "Events": 

134 source["link"] = "https://events.site.nthu.edu.tw/P/Case?Token=AbC#Frag" 

135 elif source["language"] == "zh-tw": 

136 source["link"] = "https://academic.site.nthu.edu.tw/p/403-1007-1505-1.php" 

137 assert response.json() == expected 

138 assert response.headers["X-Data-Commit-Hash"] == "test-commit" 

139 

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

141 async def test_url_combines_with_other_filters( 

142 self, client: AsyncClient, announcement_sources, fuzzy 

143 ): 

144 response = await client.get( 

145 "/announcements", 

146 params={ 

147 "url": "academic.site.nthu.edu.tw", 

148 "department": "Academic", 

149 "title": "Notice", 

150 "language": "en", 

151 "fuzzy": fuzzy, 

152 }, 

153 ) 

154 assert response.status_code == 200 

155 assert response.json() == announcement_sources[:1] 

156 

157 @pytest.mark.parametrize("department", [None, "教務處", "不存在的部門"]) 

158 async def test_get_announcements_sources(self, client: AsyncClient, monkeypatch, department): 

159 """Test getting announcement sources.""" 

160 sources = [ 

161 { 

162 "title": "教務處公告", 

163 "link": "https://example.com/academic", 

164 "department": "教務處", 

165 "language": "zh-tw", 

166 }, 

167 { 

168 "title": "學生事務處公告", 

169 "link": "https://example.com/student", 

170 "department": "學生事務處", 

171 "language": "zh-tw", 

172 }, 

173 ] 

174 

175 async def get_sources(endpoint): 

176 assert endpoint == "announcements_list.json" 

177 return "test-commit", sources 

178 

179 monkeypatch.setattr(nthudata, "get", get_sources) 

180 params = {"department": department} if department else {} 

181 response = await client.get("/announcements/sources", params=params) 

182 assert response.status_code == 200 

183 expected = [ 

184 source for source in sources if not department or source["department"] == department 

185 ] 

186 assert response.json() == expected 

187 assert response.headers["X-Data-Commit-Hash"] == "test-commit" 

188 

189 

190def test_announcements_openapi_contract(): 

191 schema = app.openapi() 

192 paths = schema["paths"] 

193 announcement_paths = {path for path in paths if path.startswith("/announcements")} 

194 assert announcement_paths == { 

195 "/announcements", 

196 "/announcements/sources", 

197 } 

198 assert paths["/announcements"]["get"]["operationId"] == "getAnnouncements" 

199 assert paths["/announcements/sources"]["get"]["operationId"] == "getAnnouncementsList" 

200 assert not paths["/announcements"]["get"].get("deprecated", False) 

201 assert not paths["/announcements/sources"]["get"].get("deprecated", False) 

202 parameters = { 

203 parameter["name"]: parameter for parameter in paths["/announcements"]["get"]["parameters"] 

204 } 

205 assert {"department", "title", "language", "fuzzy", "url"} <= parameters.keys() 

206 assert parameters["url"]["required"] is False 

207 assert parameters["url"]["schema"]["type"] == "string" 

208 description = parameters["department"]["description"] 

209 assert "/announcements/sources" in description 

210 assert "/announcements/lists/departments" not in description 

211 response_schema = paths["/announcements/sources"]["get"]["responses"]["200"]["content"][ 

212 "application/json" 

213 ]["schema"] 

214 assert response_schema["type"] == "array" 

215 assert response_schema["items"] == {"$ref": "#/components/schemas/AnnouncementSource"} 

216 source_schema = schema["components"]["schemas"]["AnnouncementSource"] 

217 fields = {"title", "link", "language", "department"} 

218 assert set(source_schema["required"]) == fields 

219 assert set(source_schema["properties"]) == fields 

220 assert all(field["type"] == "string" for field in source_schema["properties"].values()) 

221 assert source_schema["properties"]["link"]["format"] == "uri" 

222 

223 

224@pytest.mark.parametrize("selected", list(product([False, True], repeat=3))) 

225async def test_fuzzy_filters_preserve_sources_and_cached_articles(monkeypatch, selected): 

226 flags = list(product([False, True], repeat=3)) 

227 data = [ 

228 { 

229 "department": "Alpha" if department else "ZZZZZZ", 

230 "language": "en" if language else "zh-tw", 

231 "articles": [{"title": "Notice" if title else "XXXXXX"}], 

232 } 

233 for department, language, title in flags 

234 ] 

235 empty_source = {"department": "Alpha", "language": "en", "articles": []} 

236 data.append(empty_source) 

237 original = deepcopy(data) 

238 

239 async def get_announcements(endpoint): 

240 return "test", data 

241 

242 monkeypatch.setattr(nthudata, "get", get_announcements) 

243 department, language, title = selected 

244 commit, result = await announcements_service.fuzzy_search_announcements( 

245 department="Alpha" if department else None, 

246 language="en" if language else None, 

247 title="Notice" if title else None, 

248 ) 

249 expected = [ 

250 source 

251 for source, matches in zip(data, flags) 

252 if all(not enabled or matched for enabled, matched in zip(selected, matches)) 

253 ] 

254 if not title: 

255 expected.append(empty_source) 

256 assert commit == "test" 

257 assert result == expected 

258 assert data == original