Files
ss-tools/backend/tests/api/test_dashboard_testing_openapi_yaml.py
busya a32ca0631b feat(037): capture, verification lifecycle, inheritance + close 036 stabilization
- Authoritative candidate capture with server-issued artifacts and raw-byte
  immutability hashing (source_response_hash server-owned)
- Closed-period lifecycle: request-hash bound approvals, persisted closure
  immutability violations, byte-for-byte catalog stability on reclosure
- Verification runs: persisted VerificationRun model + FK migration,
  publish gate (block_publish), scheduled observability runs (02:00 UTC)
- FR-013 baseline inheritance: prior_release_id migration, plan_inheritance/
  execute_inheritance classification and re-extraction, API endpoints
- Visual executor bound to release-deployment environment; caller mismatch
  rejected; visual SSIM/reconciliation modules
- Query execution decomposed: envelope/model/executor split, no direct SQL
- AgentRun approvals extracted to submodule; evidence adapter; _utils
- Dashboard testing service decomposed into 30+ modules (all <400 LOC)
- Five Feature-037 agent tools with permission guards (tools_037.py)
- API readiness endpoint; Alembic env/migrations; test fixture repos
- Specs 036/037 contracts, openapi.yaml, schema.json, tasks/traceability
  updated; semantic index rebuilt with 0 parse warnings
- Fix ADR-0003 parser ambiguity: remove [DEF🆔ADR] prose example
- Add axiom-mcp-agent-feedback.md: agent findings for MCP rework plan
- Tests: 298 service + 1464 API + 45 agent passing; ruff clean
2026-07-31 11:28:50 +03:00

342 lines
18 KiB
Python

# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment [C:3] [TYPE Module] [SEMANTICS test,api,openapi,yaml,alignment,spec]
# @defgroup Compare checked-in OpenAPI YAML against generated FastAPI schema.
# @LAYER Test
# @RELATION VERIFIES -> [Api.DashboardTesting]
# @TEST_EDGE: yaml_vs_generated_paths -> Both specs define the same dashboard-testing paths.
# @TEST_EDGE: yaml_approval_fields -> All three required fields in ApprovalGateRequest.
# @TEST_EDGE: yaml_approval_patterns -> Patterns match exact v-SemVer and 40-char SHA.
# @TEST_EDGE: yaml_decision_enum -> decision enum values match Python schema.
# @TEST_EDGE: yaml_semver_patterns -> release_version uses SemVer pattern; release_commit_hash uses 40 hex.
# @TEST_EDGE: yaml_snapshot_endpoints -> YAML has /structure-snapshot/capture and /diff.
# @TEST_EDGE: yaml_capture_env_required -> environment_id required in YAML capture endpoint.
# @TEST_EDGE: yaml_verification_schemas -> VerificationRun has id, repository_id, trigger, environment_id, overall_status.
# @TEST_EDGE: yaml_status_codes -> Verify 201 for create-candidate, approval-gate, verification-runs.
from __future__ import annotations
from pathlib import Path
import pytest
import re as _re
import yaml as _yaml
from src.app import app
@pytest.fixture
def openapi_schema():
"""Extract the OpenAPI schema from the FastAPI app."""
return app.openapi()
_SPEC_PATH = Path(__file__).parent.parent.parent.parent / "specs" / "037-superset-baseline-engine" / "contracts" / "dashboard-testing.openapi.yaml"
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.Aligner [C:2] [TYPE Class] [SEMANTICS test,api,openapi,yaml,alignment]
class TestOpenApiYamlAlignment:
"""Compare checked-in OpenAPI YAML against generated FastAPI schema.
Ensures the spec file at specs/037-.../dashboard-testing.openapi.yaml
matches what FastAPI generates at runtime for all dashboard-testing
paths, methods, success statuses, approval required fields, and
SemVer/SHA patterns.
"""
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestPathsMatch [C:2] [TYPE Function]
# @BRIEF All paths in the checked-in YAML are also present in the generated spec (and vice versa).
# @TEST_EDGE: yaml_vs_generated_paths -> Both specs define the same dashboard-testing paths.
# @RATIONALE YAML uses camelCase path params (candidateId) while FastAPI generates snake_case
# (candidate_id). The normalization replaces {camelCase} with {snake_case} before
# comparison. This is a deliberately different naming convention — YAML is hand-authored
# for external API consumers, FastAPI auto-generates from Python parameter names.
def test_yaml_paths_match_generated(self, openapi_schema):
"""T037: Checked-in YAML paths match generated FastAPI OpenAPI paths."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
def _normalize_path(p: str) -> str:
"""Convert {camelCase} to {snake_case} in path params for comparison."""
def _to_snake(m: _re.Match) -> str:
name = m.group(1)
snake = _re.sub(r'(?<!^)(?=[A-Z])', '_', name).lower()
return '{' + snake + '}'
return _re.sub(r'\{(\w+)\}', _to_snake, p)
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
yaml_paths = set(yaml_spec.get("paths", {}))
gen_paths = {k for k in openapi_schema.get("paths", {}) if k.startswith("/api/dashboard-testing")}
yaml_normalized = {_normalize_path(f"/api{p}" if not p.startswith("/api") else p) for p in yaml_paths}
gen_normalized = {_normalize_path(p) for p in gen_paths}
missing_in_yaml = gen_normalized - yaml_normalized
extra_in_yaml = yaml_normalized - gen_normalized
assert not missing_in_yaml, (
f"Paths in generated spec but missing from YAML: {missing_in_yaml}"
)
assert not extra_in_yaml, (
f"Paths in YAML but missing from generated spec: {extra_in_yaml}"
)
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestPathsMatch
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestApprovalRequiredFields [C:2] [TYPE Function]
# @BRIEF ApprovalGateRequest in YAML requires ALL THREE fields: agent_run_id, release_version, release_commit_hash.
# @TEST_EDGE: yaml_approval_fields -> All three required fields in ApprovalGateRequest.
def test_yaml_approval_required_fields(self):
"""T037: Checked-in YAML ApprovalGateRequest requires agent_run_id, release_version, release_commit_hash."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
schemas = yaml_spec.get("components", {}).get("schemas", {})
gate_schema = schemas.get("ApprovalGateRequest", {})
required = gate_schema.get("required", [])
for field in ("agent_run_id", "release_version", "release_commit_hash"):
assert field in required, (
f"'{field}' must be required in YAML ApprovalGateRequest. Required: {required}"
)
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestApprovalRequiredFields
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestApprovalFieldPatterns [C:2] [TYPE Function]
# @BRIEF ApprovalGateRequest release_version has v-SemVer pattern; release_commit_hash has 40-hex pattern.
# @TEST_EDGE: yaml_approval_patterns -> Patterns match exact v-SemVer and 40-char SHA.
def test_yaml_approval_field_patterns(self):
"""T037: Checked-in YAML ApprovalGateRequest has v-SemVer and 40-hex patterns."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
schemas = yaml_spec.get("components", {}).get("schemas", {})
gate_schema = schemas.get("ApprovalGateRequest", {})
props = gate_schema.get("properties", {})
version_props = props.get("release_version", {})
assert "pattern" in version_props, (
"release_version in YAML ApprovalGateRequest must have a pattern"
)
ver_pattern = version_props["pattern"]
assert ver_pattern.startswith("^v"), (
f"release_version pattern must start with ^v for v-prefixed SemVer, got: {ver_pattern}"
)
hash_props = props.get("release_commit_hash", {})
assert hash_props.get("pattern") == "^[a-f0-9]{40}$", (
f"release_commit_hash pattern must be ^[a-f0-9]{40}$, got: {hash_props.get('pattern')}"
)
assert hash_props.get("minLength") == 40, "release_commit_hash minLength must be 40"
assert hash_props.get("maxLength") == 40, "release_commit_hash maxLength must be 40"
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestApprovalFieldPatterns
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestDecisionEnum [C:2] [TYPE Function]
# @BRIEF Decision enum in YAML is exactly [confirm, deny] matching Python Literal.
# @TEST_EDGE: yaml_decision_enum -> decision enum values match Python schema.
def test_yaml_decision_enum(self):
"""T037: Decision enum in YAML decide endpoint matches Python Literal['confirm', 'deny']."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
paths = yaml_spec.get("paths", {})
decide_path = next((p for p in paths if "decide" in p), None)
assert decide_path is not None, "Decide path not found in YAML"
request_body = paths[decide_path].get("post", {}).get("requestBody", {})
content = request_body.get("content", {})
json_schema = content.get("application/json", {}).get("schema", {})
decision_prop = json_schema.get("properties", {}).get("decision", {})
decision_enum = decision_prop.get("enum", [])
assert decision_enum == ["confirm", "deny"], (
f"decision enum must be ['confirm', 'deny'], got: {decision_enum}"
)
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestDecisionEnum
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestSemverPatterns [C:2] [TYPE Function]
# @BRIEF Consume endpoint in YAML uses canonical SemVer and 40-char commit hash patterns.
# @TEST_EDGE: yaml_semver_patterns -> release_version uses SemVer pattern; release_commit_hash uses 40 hex.
def test_yaml_consume_semver_commit_hash_patterns(self):
"""T037: YAML consume endpoint has SemVer and canonical 40-char commit hash patterns."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
paths = yaml_spec.get("paths", {})
consume_path = next(((p, v) for p, v in paths.items() if "consume" in p), None)
assert consume_path is not None, "Consume path not found in YAML"
_path, item = consume_path
params = item.get("post", {}).get("parameters", [])
version_param = next((p for p in params if p.get("name") == "release_version"), None)
hash_param = next((p for p in params if p.get("name") == "release_commit_hash"), None)
assert version_param is not None, "release_version parameter missing from consume endpoint in YAML"
assert hash_param is not None, "release_commit_hash parameter missing from consume endpoint in YAML"
version_schema = version_param.get("schema", {})
assert "pattern" in version_schema, "SemVer pattern missing from release_version in YAML"
hash_schema = hash_param.get("schema", {})
assert hash_schema.get("pattern") == "^[a-f0-9]{40}$", (
f"commit hash pattern should require 40 lowercase hex chars, got: {hash_schema.get('pattern')}"
)
assert hash_schema.get("minLength") == 40, "minLength should be 40 for commit hash"
assert hash_schema.get("maxLength") == 40, "maxLength should be 40 for commit hash"
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestSemverPatterns
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestSnapshotEndpoints [C:2] [TYPE Function]
# @BRIEF Release-bound snapshot endpoints exist in YAML with correct response schemas.
# @TEST_EDGE: yaml_snapshot_endpoints -> YAML has /structure-snapshot/capture and /diff.
def test_yaml_snapshot_endpoints(self):
"""T037: Checked-in YAML includes /dashboard-testing/structure-snapshot/* endpoints."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
paths = yaml_spec.get("paths", {})
capture_path = "/dashboard-testing/structure-snapshot/capture"
assert capture_path in paths, (
f"Path {capture_path} missing from YAML spec. Available: {list(paths.keys())}"
)
assert "post" in paths[capture_path], "capture endpoint missing POST"
diff_path = "/dashboard-testing/structure-snapshot/diff"
assert diff_path in paths, (
f"Path {diff_path} missing from YAML spec."
)
assert "post" in paths[diff_path], "diff endpoint missing POST"
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestSnapshotEndpoints
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestCaptureEnvIdRequired [C:2] [TYPE Function]
# @BRIEF Capture endpoint in YAML has environment_id as REQUIRED parameter (not optional).
# @TEST_EDGE: yaml_capture_env_required -> environment_id required in YAML capture endpoint.
def test_yaml_capture_env_id_required(self):
"""T037: Checked-in YAML capture endpoint environment_id is required."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
paths = yaml_spec.get("paths", {})
capture_path = "/dashboard-testing/structure-snapshot/capture"
assert capture_path in paths, "Capture path missing from YAML"
params = paths[capture_path].get("post", {}).get("parameters", [])
env_param = next((p for p in params if p.get("name") == "environment_id"), None)
assert env_param is not None, "environment_id parameter missing in YAML capture endpoint"
assert env_param.get("required") is True, (
"environment_id MUST be required in YAML capture endpoint"
)
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestCaptureEnvIdRequired
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestVerificationRunSchemas [C:2] [TYPE Function]
# @BRIEF VerificationRun and CategoryOutcome schemas exist in YAML with required fields.
# @TEST_EDGE: yaml_verification_schemas -> VerificationRun has id, repository_id, trigger, environment_id, overall_status.
def test_yaml_verification_run_schema(self):
"""T037: Checked-in YAML VerificationRun schema has required fields."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
schemas = yaml_spec.get("components", {}).get("schemas", {})
ver_schema = schemas.get("VerificationRun", {})
required = ver_schema.get("required", [])
for field in ("id", "repository_id", "trigger", "environment_id", "overall_status", "created_at"):
assert field in required, (
f"'{field}' must be required in YAML VerificationRun. Required: {required}"
)
overall_status = ver_schema.get("properties", {}).get("overall_status", {})
status_enum = overall_status.get("enum", [])
assert "pass" in status_enum
assert "fail" in status_enum
assert "blocked" in status_enum
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestVerificationRunSchemas
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestResponseStatusCodes [C:2] [TYPE Function]
# @BRIEF Creation endpoints use 201, validation errors use 422 in YAML spec.
# @TEST_EDGE: yaml_status_codes -> Verify 201 for create-candidate, approval-gate, verification-runs.
def test_yaml_creation_endpoints_have_201(self):
"""T037: Checked-in YAML creation endpoints have 201 responses matching generated spec."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
paths = yaml_spec.get("paths", {})
creation_paths = [
"/dashboard-testing/baseline-candidates",
"/dashboard-testing/baseline-candidates/{candidateId}/approval-gate",
"/dashboard-testing/structure-snapshot/capture",
"/dashboard-testing/verification-runs",
]
for p in creation_paths:
assert p in paths, f"Path {p} missing from YAML"
post_op = paths[p].get("post", {})
responses = post_op.get("responses", {})
assert "201" in responses, (
f"201 response missing for {p} in YAML. Available: {list(responses.keys())}"
)
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestResponseStatusCodes
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestApprovalGateRequestProperties [C:2] [TYPE Function] [SEMANTICS test,api,openapi,approval,gate]
# @BRIEF ApprovalGateRequest schema in YAML includes close_period, reason, reason_required fields.
# @TEST_EDGE yaml_approval_gate_properties -> All properties match Python schema.
def test_yaml_approval_gate_request_properties(self):
"""YAML ApprovalGateRequest has close_period, reason, reason_required matching Python."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
schemas = yaml_spec.get("components", {}).get("schemas", {})
gate_schema = schemas.get("ApprovalGateRequest", {})
props = gate_schema.get("properties", {})
# Must have close_period with correct description
assert "close_period" in props, "close_period missing from YAML ApprovalGateRequest"
assert "period identifier" in props["close_period"]["description"].lower(), (
"close_period description must mention period identifier"
)
# Must have reason and reason_required
assert "reason" in props, "reason missing from YAML ApprovalGateRequest"
assert props["reason"].get("maxLength") == 500, "reason maxLength must be 500"
assert "reason_required" in props, "reason_required missing from YAML ApprovalGateRequest"
# Verify the decide endpoint 200 response
decide_path = next((p for p in yaml_spec.get("paths", {}) if "decide" in p), None)
assert decide_path is not None
post_op = yaml_spec["paths"][decide_path].get("post", {})
responses = post_op.get("responses", {})
assert "200" in responses, "200 response missing for decide endpoint"
content = responses["200"].get("content", {})
schema_ref = content.get("application/json", {}).get("schema", {})
assert "$ref" in schema_ref, "decide 200 must reference a schema"
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestApprovalGateRequestProperties
# #region Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestCaptureEndpointStatuses [C:2] [TYPE Function] [SEMANTICS test,api,openapi,capture,statuses]
# @BRIEF Capture endpoint has 201, 422, 500 responses in YAML.
def test_yaml_capture_endpoint_statuses(self):
"""YAML capture endpoint has 201/422/500 statuses."""
if not _SPEC_PATH.exists():
pytest.skip(f"Spec file not found: {_SPEC_PATH}")
yaml_spec = _yaml.safe_load(_SPEC_PATH.read_text())
paths = yaml_spec.get("paths", {})
capture_path = "/dashboard-testing/baseline-candidates/capture"
assert capture_path in paths, "Capture path missing from YAML"
responses = paths[capture_path]["post"]["responses"]
for status in ("201", "422", "500"):
assert status in responses, f"{status} response missing for capture endpoint"
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.TestCaptureEndpointStatuses
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment.Aligner
# #endregion Test.Api.DashboardTesting.OpenAPI.YamlAlignment