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
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
1"""Tests for newsletters endpoints."""
3from copy import deepcopy
4from itertools import product
5from unittest.mock import AsyncMock
7import pytest
8from conftest import NEWSLETTER_NAMES
9from fastmcp import Client
10from httpx import ASGITransport, AsyncClient
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
21pytestmark = pytest.mark.usefixtures("dataset_runtime")
24class TestNewslettersEndpoints:
25 """Tests for newsletters endpoints."""
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
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"
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"
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 ]
78 async def get_dataset(endpoint):
79 assert endpoint == "newsletters.json"
80 return "test", data
82 monkeypatch.setattr(nthudata, "get", get_dataset)
83 return data
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
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())
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
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
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)
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)
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
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
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]
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"