Harden RC7 API response contracts
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND = ROOT / "backend"
|
||||
|
||||
|
||||
def test_no_untyped_fastapi_response_models_remain() -> None:
|
||||
route_root = BACKEND / "app" / "api" / "routes"
|
||||
route_sources = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in sorted(route_root.glob("*.py"))
|
||||
)
|
||||
|
||||
assert "response_model=dict" not in route_sources
|
||||
|
||||
|
||||
def test_every_json_success_response_has_a_concrete_canonical_schema() -> None:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(BACKEND))
|
||||
from app.main import create_app
|
||||
|
||||
from scripts.audit_api_contracts import (
|
||||
ALLOWED_NON_ENVELOPE_ENDPOINTS,
|
||||
_validate_response_contracts,
|
||||
)
|
||||
|
||||
openapi = create_app().openapi()
|
||||
assert _validate_response_contracts(openapi) == []
|
||||
|
||||
untyped_successes: set[tuple[str, str]] = set()
|
||||
for path, path_item in openapi["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method.upper() not in {"GET", "POST", "PATCH", "DELETE"}:
|
||||
continue
|
||||
has_json_schema = any(
|
||||
response.get("content", {})
|
||||
.get("application/json", {})
|
||||
.get("schema")
|
||||
for code, response in operation.get("responses", {}).items()
|
||||
if str(code).startswith("2")
|
||||
)
|
||||
if not has_json_schema:
|
||||
untyped_successes.add((method.upper(), path))
|
||||
|
||||
assert untyped_successes == ALLOWED_NON_ENVELOPE_ENDPOINTS - {
|
||||
("GET", "/health"),
|
||||
("GET", "/health/live"),
|
||||
("GET", "/health/ready"),
|
||||
}
|
||||
@@ -125,10 +125,15 @@ def test_quality_check_evidence_geojson_rejects_cross_project_access() -> None:
|
||||
def test_quality_check_evidence_geojson_api_uses_canonical_envelope(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
quality_check_id = uuid4()
|
||||
reference_dataset_id = uuid4()
|
||||
payload = {
|
||||
"quality_check_id": str(quality_check_id),
|
||||
"project_id": str(project_id),
|
||||
"candidate_dataset_id": None,
|
||||
"reference_dataset_id": str(reference_dataset_id),
|
||||
"analysis_run_id": None,
|
||||
"feature_count": 0,
|
||||
"warnings": [],
|
||||
"geojson": {"type": "FeatureCollection", "features": []},
|
||||
}
|
||||
|
||||
|
||||
@@ -495,9 +495,25 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None:
|
||||
"analyze",
|
||||
lambda *_args, **_kwargs: {
|
||||
"dataset_id": str(output_dataset_id),
|
||||
"product_key": "dtm_1m",
|
||||
"surface_model": "terrain",
|
||||
"selection_bbox": lambert_bbox_payload().bbox.model_dump(),
|
||||
"sample_count": 100,
|
||||
"summary": {"metric_value": 25.0, "metric_unit": "m TAW", "metrics": []},
|
||||
"slope_sample_count": 81,
|
||||
"coverage_ratio": 1.0,
|
||||
"resolution_m": 5.0,
|
||||
"vertical_reference": "TAW",
|
||||
"summary": {
|
||||
"metric_label": "Gemiddelde terreinhoogte",
|
||||
"metric_value": 25.0,
|
||||
"metric_unit": "m TAW",
|
||||
"aggregation_method": "mean",
|
||||
"primary_metric_key": "terrain_elevation_mean_m",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["water_depth_m", "water_volume_m3"],
|
||||
"limitation_message": "Terrain height is not water depth.",
|
||||
"generated_at": "2026-07-18T00:00:00Z",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -507,7 +523,25 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None:
|
||||
"dataset_id": str(output_dataset_id),
|
||||
"dataset_ids": [str(output_dataset_id)],
|
||||
"partition_count": 1,
|
||||
"product_key": "dtm_1m",
|
||||
"surface_model": "terrain",
|
||||
"selection_bbox": lambert_bbox_payload().bbox.model_dump(),
|
||||
"sample_count": 100,
|
||||
"slope_sample_count": 81,
|
||||
"coverage_ratio": 1.0,
|
||||
"resolution_m": 5.0,
|
||||
"vertical_reference": "TAW",
|
||||
"summary": {
|
||||
"metric_label": "Gemiddelde terreinhoogte",
|
||||
"metric_value": 25.0,
|
||||
"metric_unit": "m TAW",
|
||||
"aggregation_method": "mean",
|
||||
"primary_metric_key": "terrain_elevation_mean_m",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["water_depth_m", "water_volume_m3"],
|
||||
"limitation_message": "Terrain height is not water depth.",
|
||||
"generated_at": "2026-07-18T00:00:00Z",
|
||||
},
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
@@ -385,9 +385,27 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None:
|
||||
"analyze",
|
||||
lambda *_args, **_kwargs: {
|
||||
"dataset_id": str(output_dataset_id),
|
||||
"product_key": "pluviaal_current_t100",
|
||||
"mechanism": "pluviaal",
|
||||
"climate_context": "huidig klimaat",
|
||||
"probability_class": "middelgrote kans",
|
||||
"return_period_years": 100,
|
||||
"selection_bbox": flood_payload().bbox.model_dump(),
|
||||
"selected_cell_count": 10,
|
||||
"inundated_cell_count": 4,
|
||||
"summary": {"metric_value": 0.01, "metric_unit": "ha", "metrics": []},
|
||||
"inundated_fraction": 0.4,
|
||||
"resolution_m": 5.0,
|
||||
"summary": {
|
||||
"metric_label": "Overstroomde oppervlakte",
|
||||
"metric_value": 0.01,
|
||||
"metric_unit": "ha",
|
||||
"aggregation_method": "positive_depth_area",
|
||||
"primary_metric_key": "inundated_area_ha",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["permanent_water_volume_m3"],
|
||||
"limitation_message": "Scenario depth is not bathymetry.",
|
||||
"generated_at": "2026-07-18T00:00:00Z",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -397,7 +415,27 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None:
|
||||
"dataset_id": str(output_dataset_id),
|
||||
"dataset_ids": [str(output_dataset_id)],
|
||||
"partition_count": 1,
|
||||
"product_key": "pluviaal_current_t100",
|
||||
"mechanism": "pluviaal",
|
||||
"climate_context": "huidig klimaat",
|
||||
"probability_class": "middelgrote kans",
|
||||
"return_period_years": 100,
|
||||
"selection_bbox": flood_payload().bbox.model_dump(),
|
||||
"selected_cell_count": 10,
|
||||
"inundated_cell_count": 4,
|
||||
"inundated_fraction": 0.4,
|
||||
"resolution_m": 5.0,
|
||||
"summary": {
|
||||
"metric_label": "Overstroomde oppervlakte",
|
||||
"metric_value": 0.01,
|
||||
"metric_unit": "ha",
|
||||
"aggregation_method": "positive_depth_area",
|
||||
"primary_metric_key": "inundated_area_ha",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["permanent_water_volume_m3"],
|
||||
"limitation_message": "Scenario depth is not bathymetry.",
|
||||
"generated_at": "2026-07-18T00:00:00Z",
|
||||
},
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
@@ -514,7 +514,29 @@ def test_api_uses_canonical_envelopes(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
ThematicRasterAnalysisService,
|
||||
"analyze",
|
||||
lambda *_args, **_kwargs: {"dataset_id": str(dataset_id), "theme": "population", "summary": {"metric_value": 10.0}},
|
||||
lambda *_args, **_kwargs: {
|
||||
"dataset_id": str(dataset_id),
|
||||
"product_key": "population_density_2019",
|
||||
"theme": "population",
|
||||
"metric_kind": "population_density",
|
||||
"selection_bbox": payload().bbox.model_dump(),
|
||||
"selected_cell_count": 10,
|
||||
"valid_cell_count": 10,
|
||||
"coverage_ratio": 1.0,
|
||||
"resolution_m": 100.0,
|
||||
"observation_year": 2019,
|
||||
"summary": {
|
||||
"metric_label": "Geraamd aantal inwoners",
|
||||
"metric_value": 10.0,
|
||||
"metric_unit": "inwoners",
|
||||
"aggregation_method": "sum_density_cells",
|
||||
"primary_metric_key": "estimated_inhabitants",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["current_population"],
|
||||
"limitation_message": "2019 density estimate.",
|
||||
"generated_at": "2026-07-18T00:00:00Z",
|
||||
},
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
try:
|
||||
|
||||
@@ -8,12 +8,12 @@ def test_partitioned_raster_routes_are_canonical_and_documented() -> None:
|
||||
routes = (ROOT / "backend/app/api/routes/datasets.py").read_text(encoding="utf-8")
|
||||
contracts = (ROOT / "docs/API_CONTRACTS.md").read_text(encoding="utf-8")
|
||||
|
||||
for path in (
|
||||
"/datasets/raster/terrain/select",
|
||||
"/datasets/raster/flood-hazard/select",
|
||||
):
|
||||
assert f'@router.post("{path}", response_model=dict)' in routes
|
||||
assert path in contracts
|
||||
assert '"/datasets/raster/terrain/select",' in routes
|
||||
assert "response_model=Envelope[TerrainSelectionResponse]" in routes
|
||||
assert '"/datasets/raster/flood-hazard/select",' in routes
|
||||
assert "response_model=Envelope[FloodHazardSelectionResponse]" in routes
|
||||
assert "/datasets/raster/terrain/select" in contracts
|
||||
assert "/datasets/raster/flood-hazard/select" in contracts
|
||||
assert "envelope(TerrainAnalysisService.analyze_partitions" in routes
|
||||
assert "envelope(FloodHazardAnalysisService.analyze_partitions" in routes
|
||||
|
||||
|
||||
@@ -165,7 +165,13 @@ def test_mdk_readiness_api_uses_canonical_envelope(monkeypatch) -> None:
|
||||
lambda: {
|
||||
"source_key": "mdk_bcp_bathymetry",
|
||||
"status": "tls_error",
|
||||
"configured_url": "https://example.invalid/wcs",
|
||||
"tls_verified": False,
|
||||
"capabilities_reachable": False,
|
||||
"acquisition_supported": False,
|
||||
"checked_at": "2026-07-18T00:00:00Z",
|
||||
"message": "TLS validation failed.",
|
||||
"limitation_message": "No insecure fallback is permitted.",
|
||||
},
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
Reference in New Issue
Block a user