Files
geointel/backend/tests/test_sprint17_export_foundation.py
Jens faeb58ef6d
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
Initial public release
2026-08-31 21:56:53 +02:00

536 lines
19 KiB
Python

from __future__ import annotations
import json
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.core.errors import AppError
from app.main import app
from app.models import AnalysisRun, Area, Dataset, Export, Project, QualityCheck, SourceRegistry, SourceSnapshot
from app.schemas.export import ExportCreateResponse
from app.services.export_service import ExportService
from app.services.storage_service import StorageService
class FakeQuery:
def __init__(self, rows):
self.rows = rows
def filter(self, *_args):
return self
def order_by(self, *_args):
return self
def offset(self, _offset):
return self
def limit(self, _limit):
return self
def count(self):
return len(self.rows)
def all(self):
return self.rows
class FakeSession:
def __init__(self, rows):
self.rows = rows
self.added = []
def get(self, model, row_id):
row = self.rows.get((model, row_id))
if row is not None:
return row
for item in self.added:
if isinstance(item, model) and item.id == row_id:
return item
return None
def query(self, model):
rows = [row for (row_model, _row_id), row in self.rows.items() if row_model is model]
rows.extend([row for row in self.added if isinstance(row, model)])
return FakeQuery(rows)
def add(self, row):
self.added.append(row)
def commit(self):
return None
def refresh(self, row):
return row
def _govern_fixture_dataset(dataset: Dataset) -> Dataset:
"""Give an export fixture a governed authoritative source identity.
Export is a production boundary: test data must model a source that could
cross it, rather than using the deliberately QA-only ``fixture`` source.
"""
source_id = uuid4()
snapshot_id = uuid4()
checksum = "a" * 64
source = SourceRegistry(
id=source_id,
source_key="grb",
display_name="GRB export test source",
classification="authoritative",
authority_name="Digitaal Vlaanderen",
authority_scope_json={"zone": "Flanders"},
usage_policy_json={"ground_truth_allowed": True},
)
snapshot = SourceSnapshot(
id=snapshot_id,
source_registry_id=source_id,
snapshot_key=f"export-grb-{dataset.id}",
checksum_sha256=checksum,
ingest_status="ingested",
freshness_status="current",
)
dataset.source = "grb"
dataset.source_name = "grb"
dataset.checksum_sha256 = checksum
dataset.source_registry_id = source_id
dataset.source_snapshot_id = snapshot_id
dataset.data_contract_key = "geointel.vector.geojson"
dataset.data_contract_version = "1.0.0"
dataset.validation_status = "passed"
dataset.provenance_status = "complete"
dataset.lineage_status = "not_applicable"
dataset.quarantine_status = "not_quarantined"
dataset.status = "ready"
dataset.source_registry = source
dataset.source_snapshot = snapshot
return dataset
def _authoritative_building_reference(dataset: Dataset) -> Dataset:
dataset = _govern_fixture_dataset(dataset)
dataset.dataset_role = "reference"
dataset.source_registry.usage_policy_json = {
"ground_truth_allowed": True,
"validation_authority": {"building_validation": "primary"},
}
return dataset
def test_detection_export_is_machine_labelled_as_unverified_review_output(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
run_id = uuid4()
source_dataset = _govern_fixture_dataset(
Dataset(
id=dataset_id,
project_id=project_id,
name="ortho.tif",
dataset_type="raster",
source="fixture",
status="ready",
)
)
run = AnalysisRun(
id=run_id,
project_id=project_id,
dataset_id=dataset_id,
analysis_type="detection",
status="success",
model_name="yolo-configured",
)
export_path = tmp_path / "detections-review.geojson"
db = FakeSession({(Dataset, dataset_id): source_dataset, (AnalysisRun, run_id): run})
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_detection_run_geojson(db, run_id, intended_use="review")
content = json.loads(export_path.read_text(encoding="utf-8"))
trust = content["geointel_result"]
assert response.metadata_json["intended_use"] == "review"
assert trust["classification"] == "unverified_ai_review_output"
assert trust["authoritative"] is False
assert trust["operational_use_allowed"] is False
assert trust["blocking_reasons"] == ["authoritative_qa_missing"]
def test_detection_operational_export_fails_closed_without_authoritative_qa(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
run_id = uuid4()
source_dataset = _govern_fixture_dataset(
Dataset(id=dataset_id, project_id=project_id, name="ortho.tif", dataset_type="raster", source="fixture", status="ready")
)
run = AnalysisRun(
id=run_id,
project_id=project_id,
dataset_id=dataset_id,
analysis_type="detection",
status="success",
model_name="yolo-configured",
)
db = FakeSession({(Dataset, dataset_id): source_dataset, (AnalysisRun, run_id): run})
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(tmp_path / "blocked.geojson"))
with pytest.raises(AppError) as exc_info:
ExportService.export_detection_run_geojson(db, run_id, intended_use="operational")
assert exc_info.value.code == "DETECTION_OPERATIONAL_EXPORT_BLOCKED"
def test_detection_operational_export_requires_zero_error_authoritative_qa(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_id = uuid4()
run_id = uuid4()
check_id = uuid4()
source_dataset = _govern_fixture_dataset(
Dataset(id=dataset_id, project_id=project_id, name="ortho.tif", dataset_type="raster", source="fixture", status="ready")
)
reference = _authoritative_building_reference(
Dataset(
id=reference_id,
project_id=project_id,
name="grb.geojson",
dataset_type="vector",
source="fixture",
dataset_role="reference",
status="ready",
)
)
run = AnalysisRun(
id=run_id,
project_id=project_id,
dataset_id=dataset_id,
analysis_type="detection",
status="success",
model_name="yolo-configured",
)
check = QualityCheck(
id=check_id,
project_id=project_id,
analysis_run_id=run_id,
candidate_dataset_id=dataset_id,
reference_dataset_id=reference_id,
check_type="detections_vs_reference",
status="ok",
findings_json={
"false_positives": 0,
"false_negatives": 0,
"warnings": [],
"unsupported_geometry": False,
"coverage": {"applied": True},
"temporal_compatibility": {"status": "compatible"},
},
created_at=datetime.now(timezone.utc),
)
export_path = tmp_path / "detections-operational.geojson"
db = FakeSession(
{
(Dataset, dataset_id): source_dataset,
(Dataset, reference_id): reference,
(AnalysisRun, run_id): run,
(QualityCheck, check_id): check,
}
)
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_detection_run_geojson(db, run_id, intended_use="operational")
trust = json.loads(export_path.read_text(encoding="utf-8"))["geointel_result"]
assert response.metadata_json["intended_use"] == "operational"
assert trust["operational_use_allowed"] is True
assert trust["quality_check_id"] == str(check_id)
assert trust["reference_dataset_id"] == str(reference_id)
def test_dataset_geojson_export_persists_export_and_writes_artifact(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
dataset_path = tmp_path / "input.geojson"
dataset_path.write_text(json.dumps({"type": "FeatureCollection", "features": []}), encoding="utf-8")
export_path = tmp_path / "exports" / "buildings.geojson"
dataset = _govern_fixture_dataset(Dataset(
id=dataset_id,
project_id=project_id,
name="buildings.geojson",
dataset_type="vector",
source="fixture",
storage_path=str(dataset_path),
status="ready",
))
db = FakeSession({(Dataset, dataset_id): dataset})
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_dataset_geojson(db, dataset_id, name="buildings")
exports = [item for item in db.added if isinstance(item, Export)]
assert len(exports) == 1
assert response.export_id == exports[0].id
assert response.export_type == "dataset_geojson"
assert response.metadata_json["feature_count"] == 0
assert json.loads(export_path.read_text(encoding="utf-8"))["type"] == "FeatureCollection"
def test_dataset_geojson_export_rejects_raster_dataset(tmp_path, monkeypatch) -> None:
dataset_id = uuid4()
dataset = Dataset(
id=dataset_id,
project_id=uuid4(),
name="ortho.tif",
dataset_type="raster",
source="fixture",
storage_path=str(tmp_path / "ortho.tif"),
status="ready",
)
db = FakeSession({(Dataset, dataset_id): dataset})
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(tmp_path / "unused.geojson"))
try:
ExportService.export_dataset_geojson(db, dataset_id)
except AppError as exc:
assert exc.code == "INVALID_DATASET_TYPE"
else:
raise AssertionError("Raster datasets must not be exported as dataset GeoJSON")
def test_project_metadata_export_persists_json_summary(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
quality_check_id = uuid4()
project = Project(id=project_id, name="Demo", region="Kempen", status="active")
area = Area(id=uuid4(), project_id=project_id, name="Demo AOI", original_crs="EPSG:4326", area_m2=100.0)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="fixture",
dataset_role="reference",
source_name="fixture",
status="ready",
metadata_json={"feature_count": 2},
)
quality_check = QualityCheck(
id=quality_check_id,
project_id=project_id,
reference_dataset_id=dataset_id,
check_type="demo_candidate_vs_reference",
status="ok",
score=0.5,
created_at=datetime.now(timezone.utc),
)
previous_export_id = uuid4()
previous_export = Export(
id=previous_export_id,
project_id=project_id,
export_type="dataset_geojson",
storage_path="storage/exports/previous.geojson",
metadata_json={"source": "dataset"},
created_at=datetime.now(timezone.utc),
)
export_path = tmp_path / "metadata.json"
db = FakeSession(
{
(Project, project_id): project,
(Area, area.id): area,
(Dataset, dataset_id): dataset,
(QualityCheck, quality_check_id): quality_check,
(Export, previous_export_id): previous_export,
}
)
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_project_metadata(db, project_id)
payload = json.loads(export_path.read_text(encoding="utf-8"))
assert response.export_type == "project_metadata_json"
assert payload["project"]["id"] == str(project_id)
assert payload["areas"][0]["name"] == "Demo AOI"
assert payload["readiness_summary"]["overall_state"] == "ready"
assert payload["readiness_summary"]["counts"]["area_count"] == 1
assert payload["known_limitations"]
assert payload["datasets"][0]["id"] == str(dataset_id)
assert payload["quality_checks"][0]["id"] == str(quality_check_id)
assert payload["exports"][0]["id"] == str(previous_export_id)
assert response.metadata_json["export_count"] == 1
assert response.metadata_json["readiness_state"] == "ready"
def test_project_report_export_persists_html_artifact(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
project = Project(id=project_id, name="Demo <Kempen>", description="QA report", region="Kempen", status="active")
area = Area(id=uuid4(), project_id=project_id, name="Demo AOI", original_crs="EPSG:4326", area_m2=100.0)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="fixture",
dataset_role="reference",
status="ready",
metadata_json={"feature_count": 2},
)
previous_export_id = uuid4()
previous_export = Export(
id=previous_export_id,
project_id=project_id,
export_type="project_metadata_json",
storage_path="storage/exports/metadata.json",
metadata_json={"source": "project_metadata"},
created_at=datetime.now(timezone.utc),
)
export_path = tmp_path / "report.html"
quality_check = QualityCheck(
id=uuid4(),
project_id=project_id,
reference_dataset_id=dataset_id,
check_type="demo_candidate_vs_reference",
status="ok",
score=0.5,
created_at=datetime.now(timezone.utc),
)
db = FakeSession(
{
(Project, project_id): project,
(Area, area.id): area,
(Dataset, dataset_id): dataset,
(QualityCheck, quality_check.id): quality_check,
(Export, previous_export_id): previous_export,
}
)
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_project_report(db, project_id)
html = export_path.read_text(encoding="utf-8")
assert response.export_type == "project_report_html"
assert response.metadata_json["format"] == "html"
assert response.metadata_json["readiness_state"] == "ready"
assert "<!doctype html>" in html
assert "Demo &lt;Kempen&gt;" in html
assert "V1 Readiness Summary" in html
assert "Overall state:" in html
assert "No live GRB/OSM/Sentinel fetching is performed by the report export." in html
assert "reference.geojson" in html
assert "Export History (1)" in html
assert "project_metadata_json" in html
def test_export_content_reads_persisted_artifact(tmp_path) -> None:
export_id = uuid4()
export_path = tmp_path / "artifact.json"
export_path.write_text(json.dumps({"hello": "world"}), encoding="utf-8")
export = Export(
id=export_id,
project_id=uuid4(),
export_type="project_metadata_json",
storage_path=str(export_path),
metadata_json={},
)
db = FakeSession({(Export, export_id): export})
response = ExportService.get_export_content(db, export_id)
assert response.export_id == export_id
assert response.content == {"hello": "world"}
def test_export_content_rejects_html_report_preview(tmp_path) -> None:
export_id = uuid4()
export_path = tmp_path / "report.html"
export_path.write_text("<!doctype html><html><body>report</body></html>", encoding="utf-8")
export = Export(
id=export_id,
project_id=uuid4(),
export_type="project_report_html",
storage_path=str(export_path),
metadata_json={"format": "html"},
)
db = FakeSession({(Export, export_id): export})
try:
ExportService.get_export_content(db, export_id)
except AppError as exc:
assert exc.code == "EXPORT_CONTENT_UNSUPPORTED"
assert exc.status_code == 415
else:
raise AssertionError("HTML report artifacts must be download-only through the content preview API")
def test_export_download_path_rejects_missing_artifact(tmp_path) -> None:
export_id = uuid4()
export = Export(
id=export_id,
project_id=uuid4(),
export_type="dataset_geojson",
storage_path=str(tmp_path / "missing.geojson"),
metadata_json={},
)
db = FakeSession({(Export, export_id): export})
try:
ExportService.get_export_download_path(db, export_id)
except AppError as exc:
assert exc.code == "EXPORT_CONTENT_NOT_FOUND"
else:
raise AssertionError("Missing export artifacts must fail clearly")
def test_export_geojson_endpoint_returns_canonical_envelope(monkeypatch) -> None:
export_id = uuid4()
dataset_id = uuid4()
monkeypatch.setattr(
ExportService,
"export_dataset_geojson",
lambda *_args, **_kwargs: ExportCreateResponse(
export_id=export_id,
path="storage/exports/demo.geojson",
status="ready",
export_type="dataset_geojson",
metadata_json={"source": "dataset"},
),
)
response = TestClient(app).post("/api/v1/exports/geojson", json={"dataset_id": str(dataset_id)})
assert response.status_code == 200
payload = response.json()
assert set(payload) == {"data"}
assert payload["data"]["export_id"] == str(export_id)
assert payload["data"]["export_type"] == "dataset_geojson"
def test_export_download_endpoint_returns_file_response(tmp_path, monkeypatch) -> None:
export_id = uuid4()
export_path = tmp_path / "download.geojson"
export_path.write_text(json.dumps({"type": "FeatureCollection", "features": []}), encoding="utf-8")
monkeypatch.setattr(ExportService, "get_export_download_path", lambda *_args, **_kwargs: export_path)
response = TestClient(app).get(f"/api/v1/exports/{export_id}/download")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/json")
assert "download.geojson" in response.headers["content-disposition"]
assert response.json()["type"] == "FeatureCollection"
def test_export_download_endpoint_returns_html_media_type(tmp_path, monkeypatch) -> None:
export_id = uuid4()
export_path = tmp_path / "report.html"
export_path.write_text("<!doctype html><html><body>report</body></html>", encoding="utf-8")
monkeypatch.setattr(ExportService, "get_export_download_path", lambda *_args, **_kwargs: export_path)
response = TestClient(app).get(f"/api/v1/exports/{export_id}/download")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
assert "report.html" in response.headers["content-disposition"]
assert "report" in response.text