Add bounded GRB map acquisition
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 21:29:26 +02:00
parent 47cde21e17
commit 488da4cc83
28 changed files with 1644 additions and 134 deletions
@@ -54,4 +54,5 @@ def test_detection_lab_surfaces_local_model_asset_selection() -> None:
assert "onSelectModelAsset" in lab
assert "modelAssets={modelAssets}" in app
assert "Officiële referentiebronnen" in provider_panel
assert "live GRB- en OSM-koppelingen staan uit" in provider_panel
assert "GRB is beschikbaar voor expliciet begrensde kaartselecties" in provider_panel
assert "OSM blijft uitgeschakeld" in provider_panel
@@ -10,12 +10,12 @@ def read(path: str) -> str:
def test_flanders_workspace_exposes_governed_thematic_products_on_demand() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
product_hook = read("frontend/src/hooks/useOfficialRasterProducts.ts")
product_hook = read("frontend/src/hooks/useOfficialMapProducts.ts")
selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
api = read("frontend/src/services/api/datasets.ts")
assert "activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME" in workspace
assert "new Map<DataThemeId, OnDemandRasterProduct>" in workspace
assert "new Map<DataThemeId, OnDemandMapProduct>" in workspace
assert "'Op aanvraag'" in workspace
assert "theme.id === 'space_occupation'" in workspace
assert "setActiveThemeId(fallbackTheme.id)" in workspace
@@ -36,23 +36,23 @@ def test_selection_runs_all_available_themes_and_refreshes_persisted_datasets()
assert "for (const theme of DATA_THEMES)" in workspace
assert "await loadThemeInsights(bbox, availableThemes, areaId)" in workspace
assert "!regionalPartitionedThemeActive && !onDemandRasterThemeActive" in workspace
assert "!regionalPartitionedThemeActive && !onDemandThemeActive" in workspace
assert "onRefreshProjectData" in workspace
assert "selectedProjectId ? loadProjectData(selectedProjectId)" in app
assert "successful.some((item) => item.acquisition)" in selection_hook
assert "await onDatasetsChanged()" in selection_hook
def test_regional_on_demand_rasters_require_a_bounded_drawn_selection() -> None:
def test_regional_on_demand_sources_require_a_bounded_drawn_selection() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
assert "regionalOnDemandRasterThemeActive" in workspace
assert "regionalRasterThemeActive || regionalOnDemandRasterThemeActive" in workspace
assert "Teken een begrensde rechthoek voor een regionale rasteranalyse." in workspace
assert "officiële Vlaamse rasters worden begrensd opgehaald, bewaard en hergebruikt" in workspace
assert "regionalOnDemandThemeActive" in workspace
assert "regionalRasterThemeActive || regionalOnDemandThemeActive" in workspace
assert "Teken een begrensde rechthoek voor deze regionale analyse." in workspace
assert "Vlaamse kaartbronnen worden begrensd opgehaald, bewaard en hergebruikt" in workspace
def test_frontend_does_not_contact_the_external_wcs_directly() -> None:
def test_frontend_does_not_contact_external_map_services_directly() -> None:
frontend_sources = "\n".join(
path.read_text(encoding="utf-8")
for path in (ROOT / "frontend/src").rglob("*")
@@ -60,4 +60,5 @@ def test_frontend_does_not_contact_the_external_wcs_directly() -> None:
)
assert "mercatornet.be" not in frontend_sources.casefold()
assert "geo.api.vlaanderen.be" not in frontend_sources.casefold()
assert "GetCoverage" not in frontend_sources
@@ -11,12 +11,13 @@ def read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_official_raster_catalog_hook_loads_all_governed_registries() -> None:
hook = read("frontend/src/hooks/useOfficialRasterProducts.ts")
def test_official_map_catalog_hook_loads_all_governed_registries() -> None:
hook = read("frontend/src/hooks/useOfficialMapProducts.ts")
assert "datasetsApi.listThematicRasterProducts" in hook
assert "datasetsApi.listDhmvProducts" in hook
assert "datasetsApi.listFloodHazardProducts" in hook
assert "datasetsApi.listGrbProducts" in hook
assert "Promise.all([" in hook
@@ -24,7 +25,7 @@ def test_map_selection_can_acquire_dhmv_and_flood_hazard_products() -> None:
selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
assert "'thematic_raster' | 'dhmv' | 'flood_hazard'" in selection_hook
assert "'thematic_raster' | 'dhmv' | 'flood_hazard' | 'grb'" in selection_hook
assert "datasetsApi.acquireDhmv" in selection_hook
assert "datasetsApi.acquireFloodHazard" in selection_hook
assert "datasetsApi.acquireThematicRaster" in selection_hook
@@ -0,0 +1,392 @@
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from geoalchemy2.shape import from_shape
from shapely.geometry import MultiPolygon, Polygon
from app.core.config import Settings
from app.core.errors import AppError
from app.db.session import get_db
from app.main import app
from app.models import Area, Dataset, Job, Project
from app.schemas.grb import GrbAcquireRequest
from app.services.dataset_service import DatasetService
from app.services.grb_acquisition_service import GrbAcquisitionService
ROOT = Path(__file__).resolve().parents[2]
class FakeQuery:
def __init__(self, result=None):
self.result = result
def filter(self, *_args):
return self
def order_by(self, *_args):
return self
def all(self):
return self.result if isinstance(self.result, list) else []
class FakeSession:
def __init__(self, rows=None, query_result=None):
self.rows = rows or {}
self.query_result = query_result
self.added = []
def get(self, model, row_id):
row = self.rows.get((model, row_id))
if row is not None:
return row
return next((item for item in self.added if isinstance(item, model) and item.id == row_id), None)
def add(self, row):
self.added.append(row)
def commit(self):
return None
def rollback(self):
return None
def refresh(self, row):
return row
def query(self, _model):
return FakeQuery(self.query_result)
class JsonResponse:
def __init__(self, payload):
self.content = json.dumps(payload).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, size=-1):
return self.content if size < 0 else self.content[:size]
def request(*, product_key="buildings", area_id=None, force_refresh=True) -> GrbAcquireRequest:
return GrbAcquireRequest(
bbox={
"min_x": 5.15,
"min_y": 51.18,
"max_x": 5.17,
"max_y": 51.20,
"crs": "EPSG:4326",
},
area_id=area_id,
product_key=product_key,
force_refresh=force_refresh,
)
def polygon_feature(feature_id: str, coordinates) -> dict:
return {
"type": "Feature",
"id": feature_id,
"geometry": {"type": "Polygon", "coordinates": [coordinates]},
"properties": {"source_field": feature_id},
}
def test_grb_registry_exposes_four_governed_products() -> None:
products = {item["key"]: item for item in GrbAcquisitionService.list_products()}
assert set(products) == {"buildings", "roads", "water", "parcels"}
assert products["buildings"]["collections"] == ["GBG"]
assert products["roads"]["collections"] == ["Wegsegment"]
assert products["water"]["collections"] == ["WTZ", "WLAS", "WGR"]
assert products["parcels"]["collections"] == ["ADP"]
assert all(item["authority_level"] == "authoritative" for item in products.values())
def test_grb_fetch_follows_pagination_clips_geometry_and_preserves_official_identity() -> None:
product = GrbAcquisitionService._product("buildings")
settings = Settings(_env_file=None)
pages = []
first = polygon_feature(
"GBG.1",
[(5.155, 51.185), (5.175, 51.185), (5.175, 51.195), (5.155, 51.195), (5.155, 51.185)],
)
second = polygon_feature(
"GBG.2",
[(5.151, 51.181), (5.152, 51.181), (5.152, 51.182), (5.151, 51.182), (5.151, 51.181)],
)
outside = polygon_feature(
"GBG.3",
[(5.3, 51.3), (5.31, 51.3), (5.31, 51.31), (5.3, 51.31), (5.3, 51.3)],
)
def opener(raw_request, timeout):
assert timeout == settings.grb_timeout_seconds
parsed = urlparse(raw_request.full_url)
query = parse_qs(parsed.query)
pages.append(raw_request.full_url)
assert query["bbox-crs"] == [GrbAcquisitionService.OGC_CRS84_URI]
assert query["crs"] == [GrbAcquisitionService.OGC_CRS84_URI]
if query.get("cursor") == ["next"]:
return JsonResponse({"type": "FeatureCollection", "features": [second, outside], "links": []})
return JsonResponse(
{
"type": "FeatureCollection",
"features": [first],
"links": [
{
"rel": "next",
"href": (
"https://geo.api.vlaanderen.be/GRB/ogc/features/v1/"
"collections/GBG/items?cursor=next"
),
}
],
}
)
scope = Polygon(
[(5.15, 51.18), (5.17, 51.18), (5.17, 51.20), (5.15, 51.20), (5.15, 51.18)]
)
features, transfer = GrbAcquisitionService._fetch_features(
product,
scope,
scope.bounds,
"bounded_selection",
settings,
opener,
)
assert len(pages) == 2
assert transfer["page_count"] == 2
assert transfer["candidate_feature_count"] == 3
assert transfer["feature_count"] == 2
assert transfer["reference_truncated"] is False
assert {feature["id"] for feature in features} == {"GBG:GBG.1", "GBG:GBG.2"}
clipped = next(feature for feature in features if feature["id"] == "GBG:GBG.1")
assert clipped["properties"]["source_feature_id"] == "GBG:GBG.1"
assert clipped["properties"]["geometry_clipped_to_selection"] is True
assert clipped["properties"]["coverage_scope"] == "bounded_selection"
def test_grb_fetch_rejects_untrusted_pagination_and_unbounded_feature_volume() -> None:
product = GrbAcquisitionService._product("buildings")
settings = Settings(_env_file=None)
feature = polygon_feature(
"GBG.1",
[(5.151, 51.181), (5.152, 51.181), (5.152, 51.182), (5.151, 51.182), (5.151, 51.181)],
)
scope = Polygon(
[(5.15, 51.18), (5.17, 51.18), (5.17, 51.20), (5.15, 51.20), (5.15, 51.18)]
)
def hostile_opener(_request, timeout):
del timeout
return JsonResponse(
{
"type": "FeatureCollection",
"features": [feature],
"links": [{"rel": "next", "href": "https://example.test/private"}],
}
)
with pytest.raises(AppError) as invalid_next:
GrbAcquisitionService._fetch_features(
product,
scope,
scope.bounds,
"bounded_selection",
settings,
hostile_opener,
)
assert invalid_next.value.code == "GRB_PROVIDER_INVALID_PAGINATION"
def oversized_opener(_request, timeout):
del timeout
return JsonResponse(
{
"type": "FeatureCollection",
"features": [
feature,
{**feature, "id": "GBG.2"},
],
"links": [],
}
)
with pytest.raises(AppError) as oversized:
GrbAcquisitionService._fetch_features(
product,
scope,
scope.bounds,
"bounded_selection",
Settings(_env_file=None, GRB_MAX_FEATURES=1),
oversized_opener,
)
assert oversized.value.code == "GRB_SELECTION_TOO_LARGE"
def test_grb_acquisition_rejects_large_scope_before_network_access() -> None:
project_id = uuid4()
db = FakeSession({(Project, project_id): Project(id=project_id, name="Vlaanderen")})
payload = GrbAcquireRequest(
bbox={"min_x": 4.0, "min_y": 50.7, "max_x": 5.0, "max_y": 51.7, "crs": "EPSG:4326"},
product_key="buildings",
)
with pytest.raises(AppError) as exc_info:
GrbAcquisitionService.acquire(db, project_id, payload, settings=Settings(_env_file=None))
assert exc_info.value.code == "GRB_SELECTION_TOO_LARGE"
def test_grb_acquisition_persists_via_dataset_service_with_selection_metrics(monkeypatch) -> None:
project_id, area_id, dataset_id = uuid4(), uuid4(), uuid4()
municipality = MultiPolygon(
[
Polygon(
[(5.15, 51.18), (5.17, 51.18), (5.17, 51.20), (5.15, 51.20), (5.15, 51.18)]
)
]
)
project = Project(id=project_id, name="Vlaanderen")
area = Area(
id=area_id,
project_id=project_id,
name="Gemeente Mol - officieel",
geometry=from_shape(municipality, srid=4326),
)
db = FakeSession({(Project, project_id): project, (Area, area_id): area})
captured = {}
def opener(_request, timeout):
del timeout
parsed = urlparse(_request.full_url)
collection = parsed.path.split("/")[-2]
if collection == "WTZ":
features = [
polygon_feature(
"WTZ.1",
[(5.151, 51.181), (5.16, 51.181), (5.16, 51.19), (5.151, 51.19), (5.151, 51.181)],
)
]
else:
features = []
return JsonResponse({"type": "FeatureCollection", "features": features, "links": []})
def persist(_db, **kwargs):
captured.update(kwargs)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_id,
name=kwargs["filename"],
dataset_type="vector",
source=kwargs["source"],
dataset_role=kwargs["dataset_role"],
source_name=kwargs["source_name"],
reference_layer_name=kwargs["reference_layer_name"],
temporal_series_key=kwargs["temporal_series_key"],
observed_at=kwargs["observed_at"],
source_version=kwargs["source_version"],
source_metadata=kwargs["source_metadata"],
provenance_metadata=kwargs["provenance_metadata"],
metadata_json={"feature_count": 1},
status="ready",
)
db.rows[(Dataset, dataset_id)] = dataset
return SimpleNamespace(id=dataset_id)
monkeypatch.setattr(DatasetService, "import_vector_bytes", persist)
result = GrbAcquisitionService.acquire(
db,
project_id,
request(product_key="water", area_id=area_id),
settings=Settings(_env_file=None),
opener=opener,
)
assert result["output_dataset_id"] == str(dataset_id)
assert result["feature_count"] == 1
assert captured["dataset_role"] == "reference"
assert captured["source_name"] == "grb"
assert captured["reference_layer_name"] == "water"
assert captured["source_metadata"]["coverage_scope"] == "municipality"
assert captured["source_metadata"]["selection_aggregation"]["metric_key"] == "water_area"
assert captured["source_metadata"]["selection_metrics"][0]["metric_key"] == "water_length"
assert captured["provenance_metadata"]["reference_truncated"] is False
collection = json.loads(captured["content"])
assert collection["features"][0]["properties"]["coverage_scope"] == "municipality"
def test_grb_routes_use_canonical_envelopes_and_existing_job_contract(monkeypatch) -> None:
project_id, dataset_id = uuid4(), uuid4()
db = FakeSession({(Project, project_id): Project(id=project_id, name="Mol")})
monkeypatch.setattr(
GrbAcquisitionService,
"acquire",
lambda *_args, **_kwargs: {
"output_dataset_id": str(dataset_id),
"provider": "grb",
"product_key": "buildings",
"feature_count": 2,
},
)
app.dependency_overrides[get_db] = lambda: db
try:
client = TestClient(app)
products_response = client.get(f"/api/v1/projects/{project_id}/datasets/grb/products")
acquire_response = client.post(
f"/api/v1/projects/{project_id}/datasets/grb/acquire",
json=request().model_dump(mode="json"),
)
finally:
app.dependency_overrides.clear()
assert products_response.status_code == 200
assert set(products_response.json()) == {"data"}
assert products_response.json()["data"]["total"] == 4
assert acquire_response.status_code == 200
assert set(acquire_response.json()) == {"data"}
assert acquire_response.json()["data"]["job_type"] == "vector.grb.acquire"
assert acquire_response.json()["data"]["output_dataset_id"] == str(dataset_id)
assert any(isinstance(item, Job) for item in db.added)
def test_system_capabilities_reports_bounded_grb_integration() -> None:
response = TestClient(app).get("/api/v1/system/capabilities")
assert response.status_code == 200
assert response.json()["data"]["grb"] == "bounded"
grb = next(
item for item in response.json()["data"]["providers"]
if item["provider_name"] == "grb"
)
assert grb["status"] == "configured"
assert grb["fetch_signature"].endswith("/datasets/grb/acquire")
def test_grb_frontend_and_contracts_use_only_the_governed_backend_path() -> None:
selection_hook = (ROOT / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8")
catalog_hook = (ROOT / "frontend/src/hooks/useOfficialMapProducts.ts").read_text(encoding="utf-8")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
contracts = (ROOT / "docs/API_CONTRACTS.md").read_text(encoding="utf-8")
assert "datasetsApi.acquireGrb" in selection_hook
assert "datasetsApi.listGrbProducts" in catalog_hook
assert "officialMapProducts.grb" in workspace
assert "/datasets/grb/acquire" in contracts
assert "geo.api.vlaanderen.be" not in workspace
@@ -286,10 +286,10 @@ def test_provider_capabilities_expose_sprint7a_contract() -> None:
assert capabilities["osm"]["supported_geometry_types"] == ["Polygon", "MultiPolygon", "LineString", "MultiLineString"]
assert capabilities["osm"]["supported_query_modes"] == ["area"]
assert capabilities["osm"]["status"] == "not_configured"
assert capabilities["grb"]["supported_layers"] == ["buildings", "roads", "parcels"]
assert capabilities["grb"]["supported_layers"] == ["buildings", "roads", "water", "parcels"]
assert capabilities["grb"]["supported_geometry_types"] == ["Polygon", "MultiPolygon", "LineString", "MultiLineString"]
assert capabilities["grb"]["supported_query_modes"] == ["area"]
assert capabilities["grb"]["status"] == "not_configured"
assert capabilities["grb"]["supported_query_modes"] == ["bbox", "persisted_area"]
assert capabilities["grb"]["status"] == "configured"
def test_sprint7a_migration_declares_foundation_tables_and_indexes() -> None:
@@ -16,8 +16,8 @@ def test_provider_registry_lists_sprint7b_providers() -> None:
assert set(providers) == {"grb", "osm", "manual", "fixture"}
assert providers["grb"].authority_level == "authoritative"
assert providers["grb"].configured is False
assert providers["grb"].status == "not_configured"
assert providers["grb"].configured is True
assert providers["grb"].status == "configured"
assert providers["osm"].authority_level == "contextual"
assert providers["osm"].configured is False
assert providers["osm"].status == "not_configured"
@@ -34,9 +34,9 @@ def test_provider_capabilities_include_required_metadata() -> None:
assert grb["provider_name"] == "grb"
assert grb["display_name"] == "GRB"
assert grb["supported_layers"] == ["buildings", "roads", "parcels"]
assert grb["supported_layers"] == ["buildings", "roads", "water", "parcels"]
assert grb["supported_geometry_types"] == ["Polygon", "MultiPolygon", "LineString", "MultiLineString"]
assert grb["supported_query_modes"] == ["area"]
assert grb["supported_query_modes"] == ["bbox", "persisted_area"]
assert grb["limitation_message"]
assert grb["attribution"]
assert grb["license_note"]
@@ -56,13 +56,13 @@ def test_provider_to_dataset_mapping_is_enforced() -> None:
assert get_provider_dataset_mapping("osm", requested_dataset_role="reference").dataset_role == "reference"
def test_grb_osm_import_contract_returns_not_configured_without_fetching() -> None:
def test_grb_import_contract_requires_bounded_request_and_osm_remains_not_configured() -> None:
grb = import_provider_dataset("grb", project_id="project", area_id="area", layers=["buildings"])
osm = import_provider_dataset("osm", project_id="project", area_id="area", layers=["buildings"])
assert grb.status == "not_configured"
assert grb.status == "bounded_request_required"
assert grb.dataset_id is None
assert "No live" in grb.message
assert "bounding box" in grb.message
assert osm.status == "not_configured"
assert osm.dataset_id is None
@@ -107,7 +107,7 @@ def test_provider_api_envelopes_and_invalid_provider() -> None:
assert invalid_response.json()["message"] == "Provider not found"
def test_provider_import_api_returns_clear_not_configured_response() -> None:
def test_provider_import_api_points_grb_to_governed_bounded_endpoint() -> None:
client = TestClient(app)
response = client.post(
@@ -121,7 +121,7 @@ def test_provider_import_api_returns_clear_not_configured_response() -> None:
assert response.status_code == 200
assert response.json()["data"]["provider_name"] == "grb"
assert response.json()["data"]["status"] == "not_configured"
assert response.json()["data"]["status"] == "bounded_request_required"
assert response.json()["data"]["dataset_id"] is None