procurement-ingest/tests/test_api_spec_drift.py
Adam Moussa b89e98a879
Some checks are pending
Deploy / deploy (push) Waiting to run
feat(api): Redocly lint gate + SHOC-themed /docs (Redoc theming, topbar, collapsible samples) (#130)
* feat(api): Add @redocly/cli as a dev dependency

Signed-off-by: Adam Moussa <adam@seahavenind.com>

* feat(api): Add Redocly configuration file with custom rules

Signed-off-by: Adam Moussa <adam@seahavenind.com>

* chore(api): Redocly lint config + bring openapi.json into compliance

redocly.yaml from the Redocly guidelines builder, with three generated
rules corrected: response-contains-property had the status codes as the
required body fields (intent was the Error schema's top-level 'error';
403 exempt since API Gateway emits AWS's {message} shape, 501 not 503);
operation-4xx-problem-details-rfc7807 off (adopting RFC 7807 would be a
runtime + SHOC-contract change, decided against); the two inert casing
rules (parameter names, schema properties) removed because both name
sets are contract-pinned (gateway resource paths, DynamoDB items).

Spec changes, no runtime impact: operationIds renamed to method-prefixed
kebab-case (get-work-orders, post-work-order-comment, ...); tags added to
all 15 operations + root tags object (groups the Redoc sidebar); examples
on all six parameters; license field; server description punctuation; two
descriptions reworded to start capitalized. Real linter catches fixed:
the two x-planned ops were missing their {workOrderId} path parameter
and any 4xx response (403 added - true today, gateway rejects unsigned).

.redocly.lint-ignore.yaml pins the six deliberate exceptions: webhook
keys are the shipped SHOC contract event names (not renameable), and the
x-planned ops answer only 501 (no 2xx to document).

package.json: npm run lint:api. Verified: lint 0 errors, 675 pytest,
headless-Chrome render of the tagged docs page.

* feat(api): SHOC design-system theme for /docs (vendored fonts)

Themes the Redoc page with the canonical SHOC token set: Montserrat 600
headings / DM Sans body / JetBrains Mono code, primary #1c75bc, navy
#262262 sidebar text + right panel, #f9fafb background, 244px sidebar.
sortRequiredPropsFirst on; 200 responses pre-expanded.

Fonts ship as lambdas/api/fonts.css (latin woff2 subsets from
@fontsource 5.3.0, embedded as data URIs, ~90KB) and inline via a new
__FONTS_CSS__ placeholder with the same </style breakout guard --
the offline single-response invariant holds, nothing fetches Google
Fonts (test-pinned). Bundling cp + bundle-consistency pin + spec-drift
asset checks extended.

Verified: headless-Chrome render (theme + fonts applied), ruff, 675
pytest, cdk synth + staged-asset check.

* feat(api): SHOC gradient topbar on /docs

64px fixed header with the SHOC shell gradient token (#1b1f52 ->
#1c4f8f -> #1c75bc), Sea Haven wordmark in Montserrat 600, page name
right-aligned in DM Sans. Redoc's scrollYOffset: 64 keeps the sticky
sidebar and anchor scrolling clear of the fixed bar. Verified via
headless-Chrome render.

* style(api): normalize /docs header and right-panel blues

The right panel's #262262 is a purple-leaning navy that clashed with
the cyan-leaning gradient, and the bar's brightest point sat directly
over the dark panel. Right panel now uses #1b1f52 (the gradient's own
dark endpoint) and the gradient runs bright-to-dark so its dark end
lands flush on the panel -- no seam, one blue family. Verified via
headless-Chrome render.

* style(api): right-panel gradient on /docs via bundle-pinned override

Redoc's theme only takes solid colors (it derives shades from
rightPanel.backgroundColor), so the gradient (#1b3d79 -> #1b3068 ->
#1b1f52, continuing the topbar blend) rides as a CSS override on the
styled-components classes of the per-section right-panel divs
(.sc-iGgWBj.sc-gsFSXq + the .sc-dExYaf stub). Those names are
deterministic for the vendored 2.5.3 bundle (verified across loads) but
change on any Redoc bump: re-derive via headless probe (find elements
whose computed background equals the rightPanel color). If they stop
matching, the panel falls back to the solid #1b1f52 theme color --
cosmetic only. Verified via headless-Chrome render.

* feat(api): collapsible samples column on /docs

Redoc CE has no built-in panel toggle, so the topbar gains a Hide/Show
samples button that flips .samples-collapsed on <html>: the right-panel
divs hide (same bundle-pinned styled-components classes as the gradient
override) and each section's content half takes the full width. Choice
persists in localStorage; aria-pressed tracks state. If the pinned
classes stop matching after a Redoc bump the toggle goes inert --
cosmetic only. Both states verified via headless-Chrome render.

* ci(api): spec-lint CI gate + npm Dependabot coverage

New spec-lint job mirrors the local npm run lint:api so openapi.json
cannot drift from redocly.yaml with green CI. Dependabot gains the npm
ecosystem (package.json is new; nothing watched @redocly/cli).

* feat(api): docs finishing touches - x-tagGroups, favicon, docs:preview

x-tagGroups sections the Redoc sidebar (Read API / Meta / SHOC Feed);
inline data-URI SVG favicon (SHOC blue) stops the browser's follow-up
/favicon.ico request 403ing at the gateway; npm run docs:preview wraps
the real-handler local render (scripts/preview_docs.py); README gains a
docs-page architecture section covering the inline pattern, theme,
pinned-selector caveat, and tooling. Lint 0 errors, 675 pytest,
headless render verified.

---------

Signed-off-by: Adam Moussa <adam@seahavenind.com>
2026-07-24 18:04:30 +00:00

131 lines
5 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()
assert (api_dir / "fonts.css").is_file()
docs = (api_dir / "docs.html").read_text(encoding="utf-8")
assert "__OPENAPI_SPEC_JSON__" in docs
assert "__REDOC_JS__" in docs
assert "__FONTS_CSS__" in docs
assert "Redoc.init" in docs
# The docs page must never fetch fonts (or anything else) off-box: the
# design-system fonts ride inline as data URIs in fonts.css.
assert "fonts.googleapis.com" not in docs
# The vendored blobs must carry no raw </script>/</style>: each is inlined
# into a <script>/<style> block a literal terminator would break out of.
# (The handler also escapes them defensively, but keeping the pinned
# assets clean is the load-bearing guarantee and catches a bad bump here.)
assert "</script" not in (api_dir / "redoc.standalone.js").read_text(
encoding="utf-8"
)
assert "</style" not in (api_dir / "fonts.css").read_text(encoding="utf-8")