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
536 lines
19 KiB
Python
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 <Kempen>" 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
|