procurement-ingest/tests/test_api_spec_drift.py
Adam Moussa 24c89b43e5
Some checks are pending
Deploy / deploy (push) Waiting to run
feat(api): swap /docs from Swagger UI to Redoc (vendored offline) (#129)
Redoc 2.5.3 standalone bundle (MIT) replaces the three swagger-ui-dist
assets: one ~1.05MB JS file instead of ~1.8MB of JS+CSS+preset, and the
layout traps (StandaloneLayout/BaseLayout) go away. Redoc is read-only by
design, which matches the existing posture: try-it-out was already
disabled since data routes need SigV4 (Postman for live calls).

Unchanged: single token-gated response, offline vendoring (no CDN),
per-request server-URL injection, script-breakout guards, cached shell
with per-request spec splice. Bundle self-containment verified: the
search worker is an inlined Blob, and the only new Worker(filename) path
is Prism's async mode, which Redoc never invokes.

Verified via headless-Chrome render of the real handler output: all
routes, the OpenAPI 3.1 webhooks section, and planned-route markers
render; no placeholder leakage.
2026-07-24 12:00:22 -04:00

125 lines
4.7 KiB
Python

"""Spec <-> implementation drift gate.
lambdas/api/openapi.json is the published contract; lambdas/api/router.py is
what the Lambda actually serves. This test makes them the SAME set: an
endpoint added/removed/renamed on one side without the other fails CI here,
so the docs page can never silently lie. Also pins the phase-2 x-planned
markers, the four outbound webhook events, and the enum values the spec
promises against the WO extraction contract (prompts.py).
"""
import json
from pathlib import Path
from tests.support import REPO_ROOT, load_lambda_module
_SPEC_PATH = Path(REPO_ROOT) / "lambdas" / "api" / "openapi.json"
_HTTP_METHODS = {"get", "put", "post", "patch", "delete", "head", "options"}
def _spec():
return json.loads(_SPEC_PATH.read_text(encoding="utf-8"))
def _spec_routes(spec):
implemented, planned = set(), set()
for path, ops in spec["paths"].items():
for method, op in ops.items():
if method not in _HTTP_METHODS:
continue
key = (method.upper(), path)
if op.get("x-planned"):
planned.add(key)
else:
implemented.add(key)
return implemented, planned
def test_spec_is_openapi_31():
assert _spec()["openapi"] == "3.1.0"
def test_implemented_routes_match_router_exactly():
router = load_lambda_module("api", "router")
implemented, planned = _spec_routes(_spec())
assert implemented == set(router.DATA_ROUTES) | set(router.DOCS_ROUTES)
assert planned == set(router.PLANNED_ROUTES)
def test_every_data_route_has_a_repo_function():
router = load_lambda_module("api", "router")
handler = load_lambda_module("api", "handler")
assert set(router.DATA_ROUTES.values()) == set(handler._REPO_FUNCS)
def test_webhooks_section_documents_all_four_events():
spec = _spec()
assert set(spec["webhooks"]) == {
"work_order.created",
"work_order.updated",
"work_order.cancelled",
"work_order.comment_added",
}
for ops in spec["webhooks"].values():
assert "post" in ops
def test_spec_enums_match_wo_extraction_contract():
# The WO pipeline's prompts.py is the enum source of truth (the webhook
# contract pins it too). If the pipeline ever widens wo_status or
# record_type, the published spec must move in the same PR.
prompts = load_lambda_module("wo", "email_processor/prompts")
prompt_text = prompts.EXTRACTION_PROMPT
spec = _spec()
schemas = spec["components"]["schemas"]
wo_status_enum = {
value
for value in schemas["WorkOrder"]["properties"]["wo_status"]["enum"]
if value is not None
}
record_type_enum = {
value
for value in schemas["WorkOrder"]["properties"]["record_type"]["enum"]
if value is not None
}
for value in wo_status_enum - {"unknown"}:
assert value in prompt_text, f"wo_status {value!r} not in extraction prompt"
for value in record_type_enum:
assert value in prompt_text, f"record_type {value!r} not in extraction prompt"
event_data = schemas["WorkOrderEventData"]["properties"]
assert set(event_data["wo_status"]["enum"]) == set(
schemas["WorkOrder"]["properties"]["wo_status"]["enum"]
)
def test_spec_has_no_script_breakout_sequence():
# The spec is inlined into a <script type="application/json"> block on the
# docs page. The handler escapes "<" -> < defensively, but keep the
# committed spec itself clean so the raw /openapi.json is also breakout-safe
# and a reviewer sees the invariant here.
raw = _SPEC_PATH.read_text(encoding="utf-8")
assert "</" not in raw and "<!--" not in raw
def test_docs_and_spec_files_ship_with_the_handler():
# The handler serves these from its own package dir; if any file is missing
# from lambdas/api/ the bundle would 500 at runtime. Redoc is vendored
# offline (no CDN) and inlined into the single token-gated response.
api_dir = Path(REPO_ROOT) / "lambdas" / "api"
assert (api_dir / "openapi.json").is_file()
assert (api_dir / "docs.html").is_file()
assert (api_dir / "redoc.standalone.js").is_file()
docs = (api_dir / "docs.html").read_text(encoding="utf-8")
assert "__OPENAPI_SPEC_JSON__" in docs
assert "__REDOC_JS__" in docs
assert "Redoc.init" in docs
# The vendored Redoc bundle must carry no raw </script>: it is inlined
# into a <script> block, and a literal </script would break it out. (The
# handler also escapes it defensively, but keeping the pinned asset clean
# is the load-bearing guarantee and catches a bad version bump here.)
assert "</script" not in (api_dir / "redoc.standalone.js").read_text(
encoding="utf-8"
)