Coverage for tests/test_newsletters.py: 100%

143 statements  

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

1"""Tests for newsletters endpoints.""" 

2 

3from copy import deepcopy 

4from itertools import product 

5from unittest.mock import AsyncMock 

6 

7import pytest 

8from conftest import NEWSLETTER_NAMES 

9from fastmcp import Client 

10from httpx import ASGITransport, AsyncClient 

11 

12from data_api.api import schemas 

13from data_api.api.api import app 

14from data_api.core.exceptions import DataNotAvailableException 

15from data_api.data.manager import nthudata 

16from data_api.domain.newsletters.services import newsletters_service 

17from data_api.mcp.server import mcp 

18from data_api.mcp.tools.newsletters import _get_newsletters 

19from data_api.runtime import get_runtime 

20 

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

22 

23 

24class TestNewslettersEndpoints: 

25 """Tests for newsletters endpoints.""" 

26 

27 @pytest.fixture 

28 async def client(self): 

29 """Create async test client.""" 

30 async with AsyncClient( 

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

32 ) as client: 

33 yield client 

34 

35 async def test_get_all_newsletters(self, client: AsyncClient): 

36 """Test getting all newsletters.""" 

37 response = await client.get("/newsletters") 

38 assert response.status_code == 200 

39 assert [source["name"] for source in response.json()] == NEWSLETTER_NAMES 

40 assert response.headers["X-Data-Commit-Hash"] == "fixture" 

41 

42 async def test_get_sources(self, client: AsyncClient): 

43 response = await client.get("/newsletters/sources") 

44 assert response.status_code == 200 

45 assert [source["name"] for source in response.json()] == NEWSLETTER_NAMES 

46 assert all(set(source) == {"name", "link", "details"} for source in response.json()) 

47 assert response.headers["X-Data-Commit-Hash"] == "fixture" 

48 

49 

50@pytest.fixture 

51def newsletter_data(monkeypatch): 

52 data = [ 

53 { 

54 "name": "Alpha Newsletter", 

55 "link": "https://example.com/alpha", 

56 "details": {"publisher": "Alpha"}, 

57 "articles": [ 

58 {"title": "Notice", "date": "2026-10-01"}, 

59 {"title": "XXXXXX"}, 

60 {"title": None}, 

61 {}, 

62 ], 

63 }, 

64 { 

65 "name": "ZZZZZZ", 

66 "link": "https://example.com/other", 

67 "details": {}, 

68 "articles": [{"title": "Notice"}], 

69 }, 

70 { 

71 "name": "Alpha Empty", 

72 "link": "https://example.com/empty", 

73 "details": {}, 

74 "articles": [], 

75 }, 

76 ] 

77 

78 async def get_dataset(endpoint): 

79 assert endpoint == "newsletters.json" 

80 return "test", data 

81 

82 monkeypatch.setattr(nthudata, "get", get_dataset) 

83 return data 

84 

85 

86@pytest.mark.parametrize("fuzzy,name_filter,title_filter", list(product([False, True], repeat=3))) 

87async def test_combined_filters_preserve_cached_data( 

88 newsletter_data, fuzzy, name_filter, title_filter 

89): 

90 original = deepcopy(newsletter_data) 

91 name = ("Alhpa" if fuzzy else "Alpha Newsletter") if name_filter else None 

92 title = ("Notise" if fuzzy else "Not") if title_filter else None 

93 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: 

94 params = {"fuzzy": fuzzy} 

95 if name: 

96 params["name"] = name 

97 if title: 

98 params["title"] = title 

99 response = await client.get("/newsletters", params=params) 

100 assert response.status_code == 200 

101 expected = deepcopy(original) 

102 if name_filter: 

103 expected = [ 

104 source 

105 for source in expected 

106 if (source["name"].startswith("Alpha") if fuzzy else source["name"] == name) 

107 ] 

108 if title_filter: 

109 expected = [ 

110 { 

111 **source, 

112 "articles": [ 

113 article for article in source["articles"] if article.get("title") == "Notice" 

114 ], 

115 } 

116 for source in expected 

117 if any(article.get("title") == "Notice" for article in source["articles"]) 

118 ] 

119 # REST supplies null defaults for optional article metadata. 

120 expected = [ 

121 schemas.newsletters.NewsletterInfo.model_validate(source).model_dump(mode="json") 

122 for source in expected 

123 ] 

124 assert response.json() == expected 

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

126 assert newsletter_data == original 

127 

128 

129@pytest.mark.parametrize( 

130 "path,params,expected_names", 

131 [ 

132 ("/newsletters", {"name": "Alpha Newsletter", "fuzzy": False}, ["Alpha Newsletter"]), 

133 ("/newsletters", {"name": "Alpha", "fuzzy": False}, []), 

134 ("/newsletters", {"title": "notice", "fuzzy": False}, []), 

135 ("/newsletters", {"name": "Missing"}, []), 

136 ("/newsletters", {"title": "Missing"}, []), 

137 ("/newsletters", {"name": "", "title": ""}, ["Alpha Newsletter", "ZZZZZZ", "Alpha Empty"]), 

138 ("/newsletters/sources", {"name": "Alpha Newsletter"}, ["Alpha Newsletter"]), 

139 ("/newsletters/sources", {"name": "Alpha"}, []), 

140 ], 

141) 

142async def test_filter_edge_cases(newsletter_data, path, params, expected_names): 

143 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: 

144 response = await client.get(path, params=params) 

145 assert response.status_code == 200 

146 assert [source["name"] for source in response.json()] == expected_names 

147 if path.endswith("sources"): 

148 assert all("articles" not in source for source in response.json()) 

149 

150 

151@pytest.mark.parametrize("score,matched", [(59, False), (60, True)]) 

152@pytest.mark.parametrize("field", ["name", "title"]) 

153async def test_fuzzy_threshold(newsletter_data, monkeypatch, score, matched, field): 

154 monkeypatch.setattr("thefuzz.fuzz.partial_ratio", lambda query, value: score) 

155 _, result = await newsletters_service.get_newsletters(**{field: "query"}) 

156 assert bool(result) is matched 

157 

158 

159@pytest.mark.parametrize("search", [None, "", "Alhpa", "Missing"]) 

160async def test_mcp_and_rest_name_search_match(newsletter_data, search): 

161 original = deepcopy(newsletter_data) 

162 async with ( 

163 AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as rest, 

164 Client(mcp) as tools, 

165 ): 

166 response = await rest.get("/newsletters", params={"name": search or ""}) 

167 result = await tools.call_tool("get_newsletters", {"search": search}) 

168 assert response.status_code == 200 

169 assert not result.is_error 

170 assert [source["name"] for source in result.data["newsletters"]] == [ 

171 source["name"] for source in response.json() 

172 ] 

173 assert result.data["count"] == len(response.json()) 

174 assert newsletter_data == original 

175 

176 

177@pytest.mark.parametrize("search", [None, "", "query"]) 

178async def test_mcp_delegates_search_to_domain(monkeypatch, search): 

179 get_newsletters = AsyncMock(return_value=("test", [])) 

180 monkeypatch.setattr(get_runtime().newsletters, "get_newsletters", get_newsletters) 

181 assert await _get_newsletters(search) == {"count": 0, "newsletters": []} 

182 get_newsletters.assert_awaited_once_with(name=search) 

183 

184 

185@pytest.mark.parametrize("score,matched", [(59, False), (60, True)]) 

186async def test_rest_and_mcp_share_fuzzy_threshold(newsletter_data, monkeypatch, score, matched): 

187 monkeypatch.setattr("thefuzz.fuzz.partial_ratio", lambda query, value: score) 

188 async with ( 

189 AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as rest, 

190 Client(mcp) as tools, 

191 ): 

192 response = await rest.get("/newsletters", params={"name": "query"}) 

193 result = await tools.call_tool("get_newsletters", {"search": "query"}) 

194 assert response.status_code == 200 

195 assert not result.is_error 

196 expected_names = [source["name"] for source in newsletter_data] if matched else [] 

197 assert [source["name"] for source in response.json()] == expected_names 

198 assert [source["name"] for source in result.data["newsletters"]] == expected_names 

199 assert result.data["count"] == len(expected_names) 

200 

201 

202@pytest.mark.parametrize("search", [None, "", "Alpha", "ZZZZZZ"]) 

203async def test_mcp_returns_all_matching_sources(newsletter_data, search): 

204 template = deepcopy(newsletter_data[0]) 

205 newsletter_data[:] = [ 

206 {**deepcopy(template), "name": f"Alpha {index}"} for index in range(20) 

207 ] + [ 

208 {**deepcopy(template), "name": "ZZZZZZ"}, 

209 {**deepcopy(template), "name": "Alpha Empty", "articles": []}, 

210 ] 

211 original = deepcopy(newsletter_data) 

212 if search == "Alpha": 

213 expected = original[:20] + original[-1:] 

214 elif search == "ZZZZZZ": 

215 expected = original[20:21] 

216 else: 

217 expected = original 

218 async with Client(mcp) as tools: 

219 result = await tools.call_tool("get_newsletters", {"search": search}) 

220 assert not result.is_error 

221 assert result.data == { 

222 "count": len(expected), 

223 "newsletters": [ 

224 { 

225 "name": source["name"], 

226 "link": source["link"], 

227 "latest_articles": [ 

228 {"title": article.get("title"), "date": article.get("date")} 

229 for article in source["articles"][:3] 

230 ], 

231 } 

232 for source in expected 

233 ], 

234 } 

235 assert newsletter_data == original 

236 

237 

238@pytest.mark.parametrize("path", ["/newsletters", "/newsletters/sources"]) 

239@pytest.mark.parametrize("dataset", [None, ("empty", []), (None, [])]) 

240async def test_empty_and_unavailable_dataset(monkeypatch, path, dataset): 

241 async def get_dataset(endpoint): 

242 if dataset is None: 

243 raise DataNotAvailableException("Dataset temporarily unavailable") 

244 return dataset 

245 

246 monkeypatch.setattr(nthudata, "get", get_dataset) 

247 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: 

248 response = await client.get(path) 

249 assert response.status_code == (503 if dataset is None else 200) 

250 if dataset is not None: 

251 assert response.json() == [] 

252 assert response.headers.get("X-Data-Commit-Hash") == dataset[0] 

253 

254 

255def test_openapi_newsletter_contract(): 

256 paths = app.openapi()["paths"] 

257 query = paths["/newsletters"]["get"] 

258 assert query["operationId"] == "getAllNewsletters" 

259 parameters = {parameter["name"]: parameter for parameter in query["parameters"]} 

260 assert set(parameters) == {"name", "title", "fuzzy"} 

261 assert parameters["fuzzy"]["schema"]["default"] is True 

262 assert "deprecated" not in query 

263 assert paths["/newsletters/sources"]["get"]["operationId"] == "getNewsletterSources"