Coverage for tests/test_external_checks.py: 99%
172 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"""All external-check tests use mock transports, never live services."""
3import hashlib
4import json
5from copy import deepcopy
6from datetime import datetime, timezone
7from pathlib import Path
9import httpx
10import pytest
11from conftest import course_payload
13from data_api.api.schemas.ping import DatasetHealth, PingResponse
14from data_api.checks.common import CheckFailure, fetch_json, report
15from data_api.checks.published import check_published
16from data_api.checks.smoke import ENDPOINTS, check_endpoints, check_smoke
17from data_api.core.config import PREFETCH_ENDPOINTS
18from data_api.data.nthudata import Freshness
21@pytest.fixture
22def publication():
23 rss = json.loads(
24 (Path(__file__).parent / "fixtures" / "library_rss_published_urls.json").read_text(
25 encoding="utf-8"
26 )
27 )
28 payloads = {f"/{path}": [] for path in PREFETCH_ENDPOINTS}
29 payloads.update(
30 {
31 "/buses.json": {},
32 "/maps.json": {},
33 "/courses/latest.json": [course_payload()],
34 "/courses/sources.json": {"semesters": ["11510"]},
35 "/courses/semesters/11510.json": [course_payload()],
36 "/libraries/rss.json": rss,
37 }
38 )
39 documents = {path: json.dumps(payload).encode() for path, payload in payloads.items()}
40 manifest = {"file_details": {"/": [], "libraries": []}}
41 for path, content in documents.items():
42 section, _, name = path.lstrip("/").rpartition("/")
43 manifest["file_details"].setdefault(section or "/", []).append(
44 {
45 "name": name,
46 "version": "fixture",
47 "last_commit": "not-the-version",
48 "sha256": hashlib.sha256(content).hexdigest(),
49 }
50 )
51 return documents, manifest
54def published_client(documents, manifest, overrides=None):
55 def serve(request):
56 if request.url.path in (overrides or {}):
57 result = overrides[request.url.path]
58 if isinstance(result, Exception):
59 raise result
60 return result
61 if request.url.path == "/file_details.json":
62 return httpx.Response(200, json=manifest)
63 if request.url.path in documents:
64 return httpx.Response(
65 200,
66 content=documents[request.url.path],
67 headers={"content-type": "application/json"},
68 )
69 return httpx.Response(404)
71 return httpx.AsyncClient(transport=httpx.MockTransport(serve))
74async def test_complete_publication_accepts_url_regressions_and_serializes(publication):
75 documents, manifest = publication
76 original = deepcopy(documents)
77 async with published_client(documents, manifest) as client:
78 assert await check_published(client, "https://publisher.test/") == []
79 assert documents == original
82async def test_publication_check_does_not_reset_an_active_runtime(publication, dataset_runtime):
83 await dataset_runtime.get("courses/latest.json")
84 snapshot = dataset_runtime.states["/courses/latest.json"].snapshot
85 active_client = dataset_runtime.fetcher.client
86 documents, manifest = publication
87 async with published_client(documents, manifest) as client:
88 assert await check_published(client, "https://publisher.test") == []
89 assert dataset_runtime.states["/courses/latest.json"].snapshot is snapshot
90 assert dataset_runtime.fetcher.client is active_client
91 assert not active_client.is_closed
94async def test_legacy_manifest_and_empty_datasets_are_accepted(publication):
95 documents, manifest = publication
96 for entries in manifest["file_details"].values():
97 for entry in entries:
98 entry.pop("version")
99 entry.pop("sha256")
100 documents["/courses/latest.json"] = b"[]"
101 documents["/libraries/rss.json"] = json.dumps(
102 {"news": [], "eresources": [], "exhibit": [], "branches": []}
103 ).encode()
104 async with published_client(documents, manifest) as client:
105 assert await check_published(client, "https://publisher.test") == []
108@pytest.mark.parametrize(
109 "response,stage,reason",
110 [
111 (httpx.Response(404), "http", "status 404"),
112 (
113 httpx.Response(
114 200, text="<html>not a dataset</html>", headers={"content-type": "text/html"}
115 ),
116 "content-type",
117 "expected application/json",
118 ),
119 (
120 httpx.Response(200, content=b"not json", headers={"content-type": "application/json"}),
121 "checksum",
122 "SHA-256 mismatch",
123 ),
124 (httpx.ReadTimeout("private URL"), "http", "timeout"),
125 (httpx.ConnectError("private URL"), "http", "connection/request failure"),
126 ],
127)
128async def test_delivery_failures_identify_dataset_and_stage(publication, response, stage, reason):
129 documents, manifest = publication
130 async with published_client(documents, manifest, {"/directory.json": response}) as client:
131 failures = await check_published(client, "https://publisher.test")
132 assert failures == [CheckFailure("/directory.json", stage, reason)]
135@pytest.mark.parametrize(
136 "path,payload,expected_stage",
137 [
138 ("/directory.json", [{"unexpected": "private payload"}], "candidate"),
139 ("/courses/latest.json", [{"id": ""}], "candidate"),
140 ("/buses.json", {"unexpected": []}, "candidate"),
141 (
142 "/libraries/rss.json",
143 {"news": [{"title": "Article", "description": "", "image": {"url": "https://"}}]},
144 "candidate",
145 ),
146 ],
147)
148async def test_schema_drift_fails_full_candidate(publication, path, payload, expected_stage):
149 documents, manifest = publication
150 documents[path] = json.dumps(payload).encode()
151 for entries in manifest["file_details"].values():
152 for entry in entries:
153 entry.pop("sha256")
154 async with published_client(documents, manifest) as client:
155 failures = await check_published(client, "https://publisher.test")
156 assert len(failures) == 1
157 assert failures[0].target == path
158 assert failures[0].stage == expected_stage
159 assert "private payload" not in str(failures[0])
162async def test_missing_required_manifest_entry_is_not_silently_loaded(publication):
163 documents, manifest = publication
164 manifest["file_details"]["libraries"] = []
165 async with published_client(documents, manifest) as client:
166 failures = await check_published(client, "https://publisher.test")
167 assert {failure.target for failure in failures} == {
168 "/libraries/rss.json",
169 "/libraries/calendars.json",
170 }
171 assert all(failure.stage == "manifest" for failure in failures)
174async def test_published_semester_must_have_manifest_entry(publication):
175 documents, manifest = publication
176 manifest["file_details"]["courses/semesters"] = []
177 async with published_client(documents, manifest) as client:
178 failures = await check_published(client, "https://publisher.test")
179 assert failures == [
180 CheckFailure(
181 "/courses/semesters/11510.json",
182 "manifest",
183 "published semester dataset is missing",
184 )
185 ]
188@pytest.mark.parametrize(
189 "payload",
190 [
191 [course_payload(id="11420OTHER", semester_code="11420")],
192 [course_payload(), course_payload()],
193 [{"id": "11510INVALID"}],
194 ],
195)
196async def test_published_semester_model_and_identity_are_validated(publication, payload):
197 documents, manifest = publication
198 path = "/courses/semesters/11510.json"
199 documents[path] = json.dumps(payload).encode()
200 manifest["file_details"]["courses/semesters"][0].pop("sha256")
201 async with published_client(documents, manifest) as client:
202 failures = await check_published(client, "https://publisher.test")
203 assert len(failures) == 1
204 assert failures[0].target == path
205 assert failures[0].stage == "candidate"
208async def test_duplicate_manifest_is_rejected(publication):
209 documents, manifest = publication
210 manifest["file_details"]["/"].append(manifest["file_details"]["/"][0])
211 async with published_client(documents, manifest) as client:
212 failures = await check_published(client, "https://publisher.test")
213 assert failures == [CheckFailure("/file_details.json", "manifest", "manifest_parsing")]
216@pytest.mark.parametrize(
217 "content,stage",
218 [
219 (b"invalid", "json"),
220 (b'"\xff"', "json"),
221 (b"null", "json"),
222 ],
223)
224async def test_json_failures_without_checksum(content, stage):
225 async with httpx.AsyncClient(
226 transport=httpx.MockTransport(
227 lambda request: httpx.Response(
228 200, content=content, headers={"content-type": "application/json; charset=utf-8"}
229 )
230 )
231 ) as client:
232 with pytest.raises(CheckFailure) as error:
233 await fetch_json(client, "https://publisher.test/test.json", "/test.json")
234 assert error.value.stage == stage
237def healthy_readiness():
238 return PingResponse(
239 status="ok",
240 ready=True,
241 checked_at=datetime.now(timezone.utc),
242 datasets={
243 f"/{path}": DatasetHealth(usable=True, freshness=Freshness.CURRENT)
244 for path in PREFETCH_ENDPOINTS
245 },
246 ).model_dump(mode="json")
249def smoke_client(overrides=None):
250 def serve(request):
251 if request.url.path in (overrides or {}):
252 return overrides[request.url.path]
253 if request.url.path == "/ping":
254 return httpx.Response(
255 200, json=healthy_readiness(), headers={"Cache-Control": "no-store"}
256 )
257 return httpx.Response(
258 200, json=[], headers={"X-Process-Time": "0.01", "X-Total-Count": "0"}
259 )
261 return httpx.AsyncClient(transport=httpx.MockTransport(serve))
264async def test_smoke_accepts_empty_feeds_and_unknown_versions():
265 async with smoke_client() as client:
266 assert await check_smoke(client, "https://api.test/") == []
267 rss_paths = [check.path for check in ENDPOINTS if check.path.startswith("/libraries/rss/")]
268 assert set(rss_paths) == {
269 "/libraries/rss/news",
270 "/libraries/rss/eresources",
271 "/libraries/rss/exhibit",
272 "/libraries/rss/branches",
273 }
276@pytest.mark.parametrize("version", [None, "wrong-version", "expected-version"])
277async def test_published_serialization_checks_expected_version_header(version):
278 headers = {"X-Process-Time": "0.01", "X-Total-Count": "0"}
279 if version is not None:
280 headers["X-Data-Commit-Hash"] = version
281 expected = {check.path: None for check in ENDPOINTS}
282 expected["/courses"] = "expected-version"
283 async with smoke_client({"/courses": httpx.Response(200, json=[], headers=headers)}) as client:
284 failures = await check_endpoints(client, "https://api.test", expected)
285 if version == "expected-version":
286 assert failures == []
287 else:
288 assert len(failures) == 1
289 assert failures[0].target == "/courses"
290 assert failures[0].stage == "headers"
293@pytest.mark.parametrize(
294 "path,response,stage",
295 [
296 ("/libraries/rss/news", httpx.Response(503), "http"),
297 ("/libraries/rss/branches", httpx.Response(200, json={}), "response-schema"),
298 ("/directory", httpx.Response(200, json=[{"unexpected": "private"}]), "response-schema"),
299 ("/courses", httpx.Response(200, json=[], headers={"X-Process-Time": "0.01"}), "headers"),
300 (
301 "/courses",
302 httpx.Response(200, json=[], headers={"X-Process-Time": "nan", "X-Total-Count": "0"}),
303 "headers",
304 ),
305 (
306 "/courses",
307 httpx.Response(200, json=[], headers={"X-Process-Time": "0.01", "X-Total-Count": "53"}),
308 "headers",
309 ),
310 ("/ping", httpx.Response(503), "http"),
311 ("/ping", httpx.Response(200, json={}), "response-schema"),
312 ("/ping", httpx.Response(200, json=healthy_readiness()), "headers"),
313 ],
314)
315async def test_smoke_reports_endpoint_schema_headers_and_availability(path, response, stage):
316 async with smoke_client({path: response}) as client:
317 failures = await check_smoke(client, "https://api.test")
318 assert len(failures) == 1
319 assert failures[0].target == path
320 assert failures[0].stage == stage
323@pytest.mark.parametrize("missing,unusable", [(True, False), (False, True)])
324async def test_smoke_rejects_false_readiness(missing, unusable):
325 payload = healthy_readiness()
326 if missing:
327 del payload["datasets"]["/courses/latest.json"]
328 if unusable:
329 payload["datasets"]["/courses/latest.json"]["usable"] = False
330 async with smoke_client(
331 {"/ping": httpx.Response(200, json=payload, headers={"Cache-Control": "no-store"})}
332 ) as client:
333 failures = await check_smoke(client, "https://api.test")
334 assert len(failures) == 1
335 assert failures[0].stage == "readiness"
338def test_external_report_exit_status_and_safe_diagnostic(capsys):
339 assert report([], "published-data") == 0
340 assert report([CheckFailure("/rss.json", "candidate", "validation")], "published-data") == 1
341 output = capsys.readouterr().out
342 assert "PASS published-data" in output
343 assert "/rss.json: stage=candidate reason=validation" in output