487 lines
17 KiB
Python
487 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
import json
|
|
from pathlib import Path
|
|
import ssl
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from urllib.error import URLError
|
|
from uuid import uuid4
|
|
|
|
from fastapi.testclient import TestClient
|
|
from geoalchemy2.shape import from_shape
|
|
import pytest
|
|
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, DatasetVersion, Project
|
|
from app.schemas.bathymetry import BathymetryPartitionFinalizeRequest
|
|
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
|
|
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
|
|
from app.services.vector_feature_service import VectorFeatureService
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SCRIPTS = ROOT / "scripts"
|
|
if str(SCRIPTS) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
import provision_flanders_geographic_scope as flanders_scope # noqa: E402
|
|
|
|
|
|
class BinaryResponse:
|
|
def __init__(self, content: bytes, *, content_type: str = "application/xml"):
|
|
self.content = content
|
|
self.headers = {"Content-Type": content_type}
|
|
|
|
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]
|
|
|
|
|
|
class JsonResponse:
|
|
def __init__(self, payload, *, url="https://geo.api.vlaanderen.be/VRBG/items"):
|
|
self.payload = payload
|
|
self.url = url
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return self.payload
|
|
|
|
|
|
class SourceSession:
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
def get(self, *_args, **_kwargs):
|
|
return JsonResponse(self.payload)
|
|
|
|
|
|
class VersionQuery:
|
|
def __init__(self, versions):
|
|
self.versions = versions
|
|
|
|
def filter(self, *_args):
|
|
return self
|
|
|
|
def all(self):
|
|
return self.versions
|
|
|
|
|
|
class FinalizeSession:
|
|
def __init__(self, rows, versions=None):
|
|
self.rows = rows
|
|
self.versions = versions or []
|
|
self.commit_count = 0
|
|
|
|
def get(self, model, row_id):
|
|
return self.rows.get((model, row_id))
|
|
|
|
def query(self, model):
|
|
assert model is DatasetVersion
|
|
return VersionQuery(self.versions)
|
|
|
|
def commit(self):
|
|
self.commit_count += 1
|
|
|
|
|
|
def capabilities_xml() -> bytes:
|
|
return b"""<?xml version="1.0"?>
|
|
<WCS_Capabilities version="1.0.0" xmlns="http://www.opengis.net/wcs">
|
|
<ContentMetadata>
|
|
<CoverageOfferingBrief>
|
|
<name>EL.GridCoverage</name>
|
|
<label>Belgian Continental Shelf bathymetry</label>
|
|
<lonLatEnvelope srsName="urn:ogc:def:crs:OGC:1.3:CRS84" />
|
|
</CoverageOfferingBrief>
|
|
</ContentMetadata>
|
|
<Capability><Request><GetCoverage><resultFormat><formats>GeoTIFF</formats></resultFormat></GetCoverage></Request></Capability>
|
|
</WCS_Capabilities>"""
|
|
|
|
|
|
def test_mdk_probe_parses_capabilities_without_enabling_acquisition() -> None:
|
|
seen = {}
|
|
|
|
def opener(request, timeout):
|
|
seen["url"] = request.full_url
|
|
seen["timeout"] = timeout
|
|
return BinaryResponse(capabilities_xml())
|
|
|
|
result = MdkBathymetryProbeService.probe(
|
|
settings=Settings(_env_file=None),
|
|
opener=opener,
|
|
checked_at=datetime(2026, 7, 17, tzinfo=UTC),
|
|
)
|
|
|
|
assert result["status"] == "reachable"
|
|
assert result["tls_verified"] is True
|
|
assert result["capabilities_reachable"] is True
|
|
assert result["acquisition_supported"] is False
|
|
assert result["coverage_identifiers"] == ["EL.GridCoverage"]
|
|
assert result["advertised_formats"] == ["GeoTIFF"]
|
|
assert result["response_sha256"]
|
|
assert "request=GetCapabilities" in seen["url"]
|
|
assert seen["timeout"] == 20
|
|
|
|
|
|
def test_mdk_probe_reports_tls_failure_and_never_uses_insecure_fallback() -> None:
|
|
calls = 0
|
|
|
|
def opener(_request, timeout):
|
|
nonlocal calls
|
|
assert timeout == 20
|
|
calls += 1
|
|
raise URLError(ssl.SSLCertVerificationError("hostname mismatch"))
|
|
|
|
result = MdkBathymetryProbeService.probe(
|
|
settings=Settings(_env_file=None),
|
|
opener=opener,
|
|
)
|
|
|
|
assert calls == 1
|
|
assert result["status"] == "tls_error"
|
|
assert result["tls_verified"] is False
|
|
assert result["capabilities_reachable"] is False
|
|
assert "insecure fallback is prohibited" in result["message"]
|
|
|
|
|
|
def test_mdk_readiness_api_uses_canonical_envelope(monkeypatch) -> None:
|
|
project_id = uuid4()
|
|
db = SimpleNamespace(get=lambda model, row_id: Project(id=project_id, name="Mol") if model is Project else None)
|
|
monkeypatch.setattr(
|
|
MdkBathymetryProbeService,
|
|
"probe",
|
|
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
|
|
try:
|
|
response = TestClient(app).get(
|
|
f"/api/v1/projects/{project_id}/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness"
|
|
)
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
assert response.status_code == 200
|
|
assert set(response.json()) == {"data"}
|
|
assert response.json()["data"]["status"] == "tls_error"
|
|
assert response.json()["data"]["acquisition_supported"] is False
|
|
|
|
|
|
def test_partition_finalization_requires_complete_area_accounting_and_updates_versions() -> None:
|
|
project_id = uuid4()
|
|
area_ids = [uuid4(), uuid4()]
|
|
dataset_id = uuid4()
|
|
project = Project(id=project_id, name="Flanders")
|
|
areas = [
|
|
Area(id=area_ids[0], project_id=project_id, name="Gemeente Mol - officiële grens"),
|
|
Area(id=area_ids[1], project_id=project_id, name="Gemeente Geel - officiële grens"),
|
|
]
|
|
dataset = Dataset(
|
|
id=dataset_id,
|
|
project_id=project_id,
|
|
area_id=area_ids[0],
|
|
name="vha.geojson",
|
|
dataset_type="vector",
|
|
source="VHA",
|
|
source_name=BathymetryProfileAcquisitionService.PROVIDER,
|
|
source_metadata={
|
|
"profile_count": 3,
|
|
"document_count": 2,
|
|
"structured_depth_count": 1,
|
|
"measurement_date_min": "1990-01-01",
|
|
"measurement_date_max": "2020-01-01",
|
|
},
|
|
provenance_metadata={},
|
|
status="ready",
|
|
)
|
|
version = DatasetVersion(id=uuid4(), dataset_id=dataset_id, version=1)
|
|
rows = {
|
|
(Project, project_id): project,
|
|
(Area, area_ids[0]): areas[0],
|
|
(Area, area_ids[1]): areas[1],
|
|
(Dataset, dataset_id): dataset,
|
|
}
|
|
db = FinalizeSession(rows, [version])
|
|
payload = BathymetryPartitionFinalizeRequest(
|
|
partition_scope_key="flanders",
|
|
expected_area_ids=area_ids,
|
|
dataset_ids=[dataset_id],
|
|
no_profile_area_ids=[area_ids[1]],
|
|
manifest_sha256="a" * 64,
|
|
observed_at=datetime(2026, 7, 17, tzinfo=UTC),
|
|
)
|
|
|
|
result = BathymetryProfileAcquisitionService.finalize_partitions(db, project_id, payload)
|
|
|
|
assert result["regional_partitions_complete"] is True
|
|
assert result["partition_count"] == 2
|
|
assert result["data_partition_count"] == 1
|
|
assert result["no_profile_partition_count"] == 1
|
|
assert result["profile_count"] == 3
|
|
assert dataset.source_metadata["coverage_scope"] == "flanders"
|
|
assert dataset.source_metadata["municipality"] == "Mol"
|
|
assert dataset.source_metadata["partitioned_source_audit"] is True
|
|
assert version.source_metadata == dataset.source_metadata
|
|
assert version.provenance_metadata == dataset.provenance_metadata
|
|
assert db.commit_count == 1
|
|
|
|
incomplete = payload.model_copy(update={"no_profile_area_ids": []})
|
|
with pytest.raises(AppError) as exc_info:
|
|
BathymetryProfileAcquisitionService.finalize_partitions(
|
|
FinalizeSession(rows, [version]),
|
|
project_id,
|
|
incomplete,
|
|
)
|
|
assert exc_info.value.code == "BATHYMETRY_PARTITION_MANIFEST_INCOMPLETE"
|
|
|
|
|
|
def test_partition_selection_uses_latest_complete_manifest_without_duplicates() -> None:
|
|
project_id = uuid4()
|
|
source_name = BathymetryProfileAcquisitionService.PROVIDER
|
|
|
|
def partition(area_id, manifest, observed_at, data_partition_count):
|
|
return Dataset(
|
|
id=uuid4(),
|
|
project_id=project_id,
|
|
area_id=area_id,
|
|
name="vha.geojson",
|
|
dataset_type="vector",
|
|
source="VHA",
|
|
source_name=source_name,
|
|
source_metadata={
|
|
"regional_partitions_complete": True,
|
|
"partition_scope_key": "flanders",
|
|
"partition_manifest_sha256": manifest,
|
|
"partition_manifest_observed_at": observed_at,
|
|
"data_partition_count": data_partition_count,
|
|
},
|
|
status="ready",
|
|
)
|
|
|
|
old = [partition(uuid4(), "a" * 64, "2026-07-16T00:00:00+00:00", 1)]
|
|
new_area_ids = [uuid4(), uuid4()]
|
|
new = [
|
|
partition(area_id, "b" * 64, "2026-07-17T00:00:00+00:00", 2)
|
|
for area_id in new_area_ids
|
|
]
|
|
incomplete = [partition(uuid4(), "c" * 64, "2026-07-18T00:00:00+00:00", 2)]
|
|
|
|
selected = VectorFeatureService._latest_complete_partition_manifest(
|
|
[*old, *new, *incomplete],
|
|
source_name=source_name,
|
|
partition_scope_key="flanders",
|
|
)
|
|
|
|
assert {dataset.area_id for dataset in selected} == set(new_area_ids)
|
|
assert all(dataset.source_metadata["partition_manifest_sha256"] == "b" * 64 for dataset in selected)
|
|
|
|
|
|
def test_partition_selection_route_uses_exact_municipality_and_canonical_envelope(monkeypatch) -> None:
|
|
project_id, area_id, dataset_id = uuid4(), uuid4(), uuid4()
|
|
area_geometry = MultiPolygon(
|
|
[
|
|
Polygon(
|
|
[
|
|
(5.0, 51.0),
|
|
(5.2, 51.0),
|
|
(5.2, 51.2),
|
|
(5.0, 51.2),
|
|
(5.0, 51.0),
|
|
]
|
|
)
|
|
]
|
|
)
|
|
area = Area(
|
|
id=area_id,
|
|
project_id=project_id,
|
|
name="Gemeente Mol - officiele grens",
|
|
geometry=from_shape(area_geometry, srid=4326),
|
|
)
|
|
db = SimpleNamespace(get=lambda model, row_id: area if model is Area and row_id == area_id else None)
|
|
captured = {}
|
|
|
|
def select_partitions(_db, **kwargs):
|
|
captured.update(kwargs)
|
|
return {
|
|
"selection_bbox": kwargs["bbox"],
|
|
"selection_area_id": area_id,
|
|
"feature_count": 1,
|
|
"total_feature_count": 1,
|
|
"limit": kwargs["limit"],
|
|
"truncated": False,
|
|
"geojson": {"type": "FeatureCollection", "features": []},
|
|
"partition_count": 1,
|
|
"available_partition_count": 269,
|
|
"partition_scope_key": "flanders",
|
|
"source_name": BathymetryProfileAcquisitionService.PROVIDER,
|
|
"dataset_ids": [dataset_id],
|
|
}
|
|
|
|
monkeypatch.setattr(VectorFeatureService, "select_partitioned_features_by_bbox", select_partitions)
|
|
app.dependency_overrides[get_db] = lambda: db
|
|
try:
|
|
response = TestClient(app).post(
|
|
f"/api/v1/projects/{project_id}/datasets/bathymetry/profiles/partitions/select",
|
|
json={
|
|
"bbox": {
|
|
"min_x": 5.0,
|
|
"min_y": 51.0,
|
|
"max_x": 5.2,
|
|
"max_y": 51.2,
|
|
"crs": "EPSG:4326",
|
|
},
|
|
"area_id": str(area_id),
|
|
"limit": 1000,
|
|
},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
assert response.status_code == 200
|
|
assert set(response.json()) == {"data"}
|
|
assert response.json()["data"]["partition_count"] == 1
|
|
assert response.json()["data"]["available_partition_count"] == 269
|
|
assert captured["partition_area_id"] == area_id
|
|
assert captured["source_name"] == BathymetryProfileAcquisitionService.PROVIDER
|
|
assert captured["partition_scope_key"] == "flanders"
|
|
|
|
|
|
def test_flanders_scope_discovery_uses_complete_unique_vrbg_inventory() -> None:
|
|
features = [
|
|
{
|
|
"type": "Feature",
|
|
"id": f"Refgem.{index:05d}",
|
|
"properties": {"NISCODE": f"{index:05d}", "NAAM": f"Gemeente {index:03d}"},
|
|
"geometry": {
|
|
"type": "Polygon",
|
|
"coordinates": [[[4.0, 50.5], [4.1, 50.5], [4.1, 50.6], [4.0, 50.5]]],
|
|
},
|
|
}
|
|
for index in range(1, 286)
|
|
]
|
|
scope, selected, source_url = flanders_scope.discover_flanders_scope(
|
|
SourceSession({"features": list(reversed(features))}),
|
|
timeout=30,
|
|
min_municipalities=270,
|
|
max_municipalities=300,
|
|
)
|
|
|
|
assert scope.key == "flanders"
|
|
assert scope.project_name == "Flanders Regional Workbench"
|
|
assert len(scope.members) == 285
|
|
assert len(set(scope.nis_codes)) == 285
|
|
assert [item["properties"]["NISCODE"] for item in selected] == sorted(scope.nis_codes)
|
|
assert source_url.startswith("https://geo.api.vlaanderen.be/")
|
|
|
|
|
|
def test_expansion_scripts_are_packaged_and_readiness_checked() -> None:
|
|
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
|
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
|
for script in (
|
|
"provision_flanders_geographic_scope.py",
|
|
"provision_flanders_bathymetry_profiles.py",
|
|
"probe_mdk_bathymetry.py",
|
|
):
|
|
assert f"COPY scripts/{script}" in dockerfile
|
|
assert f"py_compile scripts/{script}" in readiness
|
|
|
|
sources = {
|
|
item["key"]: item
|
|
for item in BathymetryProfileAcquisitionService.list_sources()
|
|
}
|
|
assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only"
|
|
# Bounded acquisition is implemented but remains disabled by default.
|
|
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is True
|
|
assert sources["mdk_bcp_bathymetry"]["configured"] is False
|
|
assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"]
|
|
|
|
|
|
def test_frontend_exhaustively_pages_regional_area_inventory() -> None:
|
|
area_api = (ROOT / "frontend" / "src" / "services" / "api" / "areas.ts").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
assert "const AREA_PAGE_SIZE = 200" in area_api
|
|
assert "while (offset < (total ?? 0))" in area_api
|
|
assert "items.length !== total" in area_api
|
|
assert "list: listProjectAreas" in area_api
|
|
|
|
|
|
def test_frontend_bounds_large_area_and_dataset_catalogs() -> None:
|
|
area_panel = (
|
|
ROOT / "frontend" / "src" / "components" / "project" / "AreaPanel.tsx"
|
|
).read_text(encoding="utf-8")
|
|
dataset_panel = (
|
|
ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetPanel.tsx"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "const AREA_CATALOG_PAGE_SIZE = 12" in area_panel
|
|
assert "{catalogOpen ? (" in area_panel
|
|
assert "visibleAreas.map" in area_panel
|
|
assert "Zoek gemeente of regio" in area_panel
|
|
assert "const DATASET_CATALOG_PAGE_SIZE = 10" in dataset_panel
|
|
assert "visiblePrimaryDatasets.map" in dataset_panel
|
|
assert "Zoek in beschikbare bronnen" in dataset_panel
|
|
assert "{historyOpen ? <ul" in dataset_panel
|
|
|
|
|
|
def test_frontend_labels_flanders_scope_without_kempen_mislabeling() -> None:
|
|
dataset_display = (
|
|
ROOT / "frontend" / "src" / "lib" / "datasetDisplay.ts"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "coverageScope === 'flanders' && layer === 'regional_boundary'" in dataset_display
|
|
assert "'Grens Vlaanderen'" in dataset_display
|
|
assert "coverageScope === 'flanders' && layer === 'municipality_boundaries'" in dataset_display
|
|
assert "'Gemeentegrenzen Vlaanderen'" in dataset_display
|
|
|
|
|
|
def test_frontend_uses_partitioned_bathymetry_selection_for_regional_scope() -> None:
|
|
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
|
index = (ROOT / "frontend" / "index.html").read_text(encoding="utf-8")
|
|
focus = (
|
|
ROOT / "frontend" / "src" / "config" / "primaryFocus.ts"
|
|
).read_text(encoding="utf-8")
|
|
map_workspace = (
|
|
ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx"
|
|
).read_text(encoding="utf-8")
|
|
theme_hook = (
|
|
ROOT / "frontend" / "src" / "hooks" / "useMapThemeSelectionInsights.ts"
|
|
).read_text(encoding="utf-8")
|
|
dataset_api = (
|
|
ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "isPartitionedBathymetry" in map_workspace
|
|
assert "regionalPartitionedThemeActive" in map_workspace
|
|
assert "datasetAvailabilityLabel(dataset, partitions)" in map_workspace
|
|
assert "activeSelectionResult?.geojson ?? null" in map_workspace
|
|
assert "selectBathymetryProfilePartitions" in theme_hook
|
|
assert "datasets/bathymetry/profiles/partitions/select" in dataset_api
|
|
assert "regionalBathymetryContextActive" in app
|
|
assert "regionalBathymetryProfileCount" in app
|
|
assert "profielen · ${regionalBathymetryPartitions.length} gemeenten" in app
|
|
assert "FLANDERS_WORKSPACE_LABEL = 'Vlaanderen (285 gemeenten)'" in focus
|
|
assert "<title>GeoIntel</title>" in index
|