Initial public release
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
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
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
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
|
||||
Reference in New Issue
Block a user