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 ", 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 "" 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("report", 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("report", 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