Coverage for tests/test_openapi_contract.py: 100%
115 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"""Pin the public structure and match representative real responses to declarations."""
3import json
4from copy import deepcopy
5from pathlib import Path
7import httpx
8import pytest
10from data_api.api.api import app
11from data_api.api.schemas.calendars import CalendarEvent
12from data_api.api.schemas.errors import ErrorResponse, ValidationErrorResponse
13from data_api.api.schemas.responses import COUNT_HEADERS, DATA_VERSION_HEADERS
14from data_api.checks.openapi import contract_differences, contract_projection
15from data_api.domain.calendars.services import calendars_service
17BASELINE = Path(__file__).parent / "fixtures" / "openapi_contract.json"
20def test_structured_openapi_baseline():
21 expected = json.loads(BASELINE.read_text(encoding="utf-8"))
22 changes = contract_differences(expected, contract_projection(app.openapi()))
23 assert not changes, "\n".join(changes)
26@pytest.mark.parametrize("change", ["route", "parameter", "operationId", "default", "response"])
27def test_compatibility_check_identifies_structural_changes(change):
28 expected = contract_projection(app.openapi())
29 actual = deepcopy(expected)
30 operation = actual["operations"]["GET /courses"]
31 match change:
32 case "route":
33 del actual["operations"]["GET /courses"]
34 location = "GET /courses"
35 case "parameter":
36 del operation["parameters"]["query:lecturers.label"]
37 location = "query:lecturers.label"
38 case "operationId":
39 operation["operationId"] = "unexpectedRename"
40 location = "operationId"
41 case "default":
42 operation["parameters"]["query:type"]["schema"]["default"] = "xclass"
43 location = "query:type.schema.default"
44 case "response":
45 del operation["responses"]["503"]
46 location = "responses.503"
47 assert any(location in difference for difference in contract_differences(expected, actual))
50def test_documentation_changes_are_ignored_but_title_fields_are_not():
51 schema = deepcopy(app.openapi())
52 expected = contract_projection(schema)
53 schema["paths"]["/courses"]["get"]["description"] = "New documentation"
54 schema["components"]["schemas"]["CourseData"]["properties"]["id"]["description"] = "New label"
55 assert contract_projection(schema) == expected
56 assert "title" in expected["schemas"]["LibraryRssItem"]["properties"]
57 del schema["components"]["schemas"]["LibraryRssItem"]["properties"]["title"]
58 assert contract_differences(expected, contract_projection(schema))
61def test_every_snapshot_operation_documents_availability_and_headers():
62 schema = app.openapi()
63 live_prefixes = ("/energy/", "/libraries/spaces", "/libraries/lost")
64 for path, methods in schema["paths"].items():
65 for operation in methods.values():
66 responses = operation["responses"]
67 if path.startswith(live_prefixes):
68 assert "503" not in responses
69 assert "X-Data-Commit-Hash" not in responses["200"].get("headers", {})
70 continue
71 assert responses["503"]["content"]["application/json"]["schema"] == {
72 "$ref": "#/components/schemas/ErrorResponse"
73 }, path
74 headers = responses["200"]["headers"]
75 counted = path.startswith("/courses") or path == "/calendars/{calendar_id}/events"
76 assert headers == (COUNT_HEADERS if counted else DATA_VERSION_HEADERS), path
79def test_manual_errors_and_course_validation_have_body_schemas():
80 schema = app.openapi()
81 for path, methods in schema["paths"].items():
82 for operation in methods.values():
83 for status in ["400", "404", "500", "502", "503", "504"]:
84 if status in operation["responses"]:
85 assert operation["responses"][status]["content"]["application/json"][
86 "schema"
87 ] == {"$ref": "#/components/schemas/ErrorResponse"}, (path, status)
88 detail = schema["components"]["schemas"]["ValidationErrorResponse"]["properties"]["detail"]
89 assert {choice["type"] for choice in detail["anyOf"]} == {"string", "array"}
90 assert schema["components"]["schemas"]["LibraryRssItem"]["properties"]["link"]["anyOf"] == [
91 {"type": "string"},
92 {"type": "null"},
93 ]
94 for path, method in [
95 ("/courses", "get"),
96 ("/courses/query", "post"),
97 ]:
98 assert schema["paths"][path][method]["responses"]["422"]["content"]["application/json"][
99 "schema"
100 ] == {"$ref": "#/components/schemas/ValidationErrorResponse"}
103def assert_error_matches_schema(response, schema_path, status, method="get"):
104 assert response.status_code == status
105 declared = app.openapi()["paths"][schema_path][method]["responses"][str(status)]
106 ref = declared["content"]["application/json"]["schema"]["$ref"]
107 model = ValidationErrorResponse if ref.endswith("ValidationErrorResponse") else ErrorResponse
108 model.model_validate(response.json())
109 assert float(response.headers["X-Process-Time"]) >= 0
112@pytest.mark.parametrize(
113 "url,schema_path,status,detail_type",
114 [
115 ("/calendars/missing", "/calendars/{calendar_id}", 404, str),
116 (
117 "/calendars/missing/events?start=2026-10-02&end=2026-10-01",
118 "/calendars/{calendar_id}/events",
119 400,
120 str,
121 ),
122 ("/courses?type=invalid", "/courses", 422, list),
123 ("/courses?lecturers.label=%5B", "/courses", 422, str),
124 ],
125)
126async def test_actual_manual_and_validation_errors(
127 dataset_runtime, url, schema_path, status, detail_type
128):
129 async with httpx.AsyncClient(
130 transport=httpx.ASGITransport(app=app), base_url="http://test"
131 ) as client:
132 response = await client.get(url)
133 assert_error_matches_schema(response, schema_path, status)
134 assert isinstance(response.json()["detail"], detail_type)
137async def test_snapshot_unavailable_matches_openapi():
138 async with httpx.AsyncClient(
139 transport=httpx.ASGITransport(app=app), base_url="http://test"
140 ) as client:
141 response = await client.get("/libraries/rss/news")
142 assert_error_matches_schema(response, "/libraries/rss/{rss_type}", 503)
143 assert "X-Data-Commit-Hash" not in response.headers
146@pytest.mark.parametrize("timeout,status", [(False, 502), (True, 504)])
147async def test_live_upstream_error_matches_openapi(mock_upstream, timeout, status):
148 def upstream(request):
149 if timeout:
150 raise httpx.ReadTimeout("private upstream timeout")
151 return httpx.Response(503)
153 mock_upstream(upstream)
154 async with httpx.AsyncClient(
155 transport=httpx.ASGITransport(app=app), base_url="http://test"
156 ) as client:
157 response = await client.get("/libraries/spaces")
158 assert_error_matches_schema(response, "/libraries/spaces", status)
161async def test_calendar_pagination_body_and_headers_match_openapi(monkeypatch):
162 events = [
163 {
164 "id": f"event-{index}",
165 "title": "Event",
166 "start": "2026-10-01",
167 "end": "2026-10-02",
168 "all_day": True,
169 }
170 for index in range(3)
171 ]
173 async def search(*args, **kwargs):
174 return "calendar-version", events
176 monkeypatch.setattr(calendars_service, "search_calendar_events", search)
177 async with httpx.AsyncClient(
178 transport=httpx.ASGITransport(app=app), base_url="http://test"
179 ) as client:
180 response = await client.get("/calendars/academic/events?limit=1&offset=1")
181 assert response.status_code == 200
182 assert len(response.json()) == 1
183 CalendarEvent.model_validate(response.json()[0])
184 assert response.headers["X-Total-Count"] == "3"
185 assert response.headers["X-Data-Commit-Hash"] == "calendar-version"
186 declared = app.openapi()["paths"]["/calendars/{calendar_id}/events"]["get"]["responses"]["200"]
187 assert declared["headers"] == COUNT_HEADERS