"""OpenAPI contract test. This test pins the public HTTP contract of backend-compliance. It loads the FastAPI app, extracts the live OpenAPI schema, and compares it against a checked-in baseline at ``tests/contracts/openapi.baseline.json``. Rules: - Adding new paths/operations/fields → OK (additive change). - Removing a path, changing a method, changing a status code, removing or renaming a response/request field → FAIL. Such changes require updating every consumer (admin-compliance, developer-portal, SDKs) in the same change, then regenerating the baseline with: python tests/contracts/regenerate_baseline.py and explaining the contract change in the PR description. The baseline is missing on first run — the test prints the command to create it and skips. This is intentional: Phase 1 step 1 generates it fresh from the current app state before any refactoring begins. """ from __future__ import annotations import json from pathlib import Path from typing import Any import pytest BASELINE_PATH = Path(__file__).parent / "openapi.baseline.json" def _load_live_schema() -> dict[str, Any]: """Import the FastAPI app and extract its OpenAPI schema. Kept inside the function so that test collection does not fail if the app has import-time side effects that aren't satisfied in the test env. """ from main import app # type: ignore[import-not-found] return app.openapi() def _collect_operations(schema: dict[str, Any]) -> dict[str, dict[str, Any]]: """Return a flat {f'{METHOD} {path}': operation} map for diffing.""" out: dict[str, dict[str, Any]] = {} for path, methods in schema.get("paths", {}).items(): for method, op in methods.items(): if method.lower() in {"get", "post", "put", "patch", "delete", "options", "head"}: out[f"{method.upper()} {path}"] = op return out @pytest.mark.contract def test_openapi_no_breaking_changes() -> None: if not BASELINE_PATH.exists(): pytest.skip( f"Baseline missing. Run: python {Path(__file__).parent}/regenerate_baseline.py" ) baseline = json.loads(BASELINE_PATH.read_text()) live = _load_live_schema() baseline_ops = _collect_operations(baseline) live_ops = _collect_operations(live) # 1. No operation may disappear. removed = sorted(set(baseline_ops) - set(live_ops)) assert not removed, ( f"Breaking change: {len(removed)} operation(s) removed from public API:\n " + "\n ".join(removed) ) # 2. For operations that exist in both, response status codes must be a superset. for key, baseline_op in baseline_ops.items(): live_op = live_ops[key] baseline_codes = set((baseline_op.get("responses") or {}).keys()) live_codes = set((live_op.get("responses") or {}).keys()) missing = baseline_codes - live_codes assert not missing, ( f"Breaking change: {key} no longer returns status code(s) {sorted(missing)}" ) # 3. Required request-body fields may not be added (would break existing clients). for key, baseline_op in baseline_ops.items(): live_op = live_ops[key] base_req = _required_body_fields(baseline_op) live_req = _required_body_fields(live_op) new_required = live_req - base_req assert not new_required, ( f"Breaking change: {key} added required request field(s) {sorted(new_required)}" ) def _required_body_fields(op: dict[str, Any]) -> set[str]: rb = op.get("requestBody") or {} content = rb.get("content") or {} for media in content.values(): schema = media.get("schema") or {} return set(schema.get("required") or []) return set()