Files
geointel/backend/tests/test_sprint17_export_foundation.py
T

398 lines
14 KiB
Python

from __future__ import annotations
import json
from datetime import datetime, timezone
from uuid import uuid4
from fastapi.testclient import TestClient
from app.core.errors import AppError
from app.main import app
from app.models import 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 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