332 lines
12 KiB
Python
332 lines
12 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
|
|
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 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 = 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_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
|