from __future__ import annotations from types import SimpleNamespace from uuid import uuid4 from pathlib import Path import importlib from hashlib import sha256 from geoalchemy2.shape import from_shape from app.core.errors import AppError from app.models import Area, Dataset, DatasetVersion from app.services.raster_operations_service import RasterOperationsService from app.services.storage_service import StorageService from app.api.routes.datasets import _run_job_sync from shapely.geometry import box import pytest class FakeSession: def __init__(self, datasets=None, areas=None): self.datasets = {item.id: item for item in (datasets or [])} self.areas = {item.id: item for item in (areas or [])} self.added = [] def get(self, model, item_id): if model.__name__ == "Dataset": return self.datasets.get(item_id) if model.__name__ == "Area": return self.areas.get(item_id) return None def add(self, item): self.added.append(item) def commit(self): return None def refresh(self, _item): return None def test_raster_preview_dependency_aware_when_rasterio_unavailable(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "raster.tif" source.write_bytes(b"\x00\x01") dataset = Dataset( id=dataset_id, project_id=project_id, name="raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="raster.tif", stored_filename="raster.tif", content_type="image/tiff", size_bytes=2, ) db = FakeSession([dataset]) monkeypatch.setattr( "app.services.raster_operations_service._import_rasterio", lambda: (_ for _ in ()).throw(ImportError("rasterio not installed")), ) try: RasterOperationsService.preview(db, dataset_id) except AppError as exc: assert exc.code == "RASTER_PROCESSING_UNAVAILABLE" else: raise AssertionError("Missing rasterio should raise RASTER_PROCESSING_UNAVAILABLE") def test_raster_stats_dependency_aware_when_numpy_unavailable(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "raster.tif" source.write_bytes(b"dummy") dataset = Dataset( id=dataset_id, project_id=project_id, name="raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="raster.tif", stored_filename="raster.tif", content_type="image/tiff", size_bytes=5, ) db = FakeSession([dataset]) class FakeRasterio: def open(self, *_args, **_kwargs): raise AssertionError("stats should fail before opening raster when numpy import fails") monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (FakeRasterio(), None)) monkeypatch.setattr("app.services.raster_operations_service._import_numpy", lambda: (_ for _ in ()).throw(ImportError("numpy not installed"))) try: RasterOperationsService.stats(db, dataset_id) except AppError as exc: assert exc.code == "RASTER_PROCESSING_UNAVAILABLE" else: raise AssertionError("Missing numpy should raise RASTER_PROCESSING_UNAVAILABLE for stats") def test_raster_preview_returns_metadata_payload(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "raster.tif" source.write_bytes(b"dummy-raster") dataset = Dataset( id=dataset_id, project_id=project_id, name="raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="raster.tif", stored_filename="raster.tif", content_type="image/tiff", size_bytes=13, checksum_sha256="checksum", ) db = FakeSession([dataset]) class FakeSource: width = 200 height = 120 count = 4 def __init__(self): self.shape = (4, 120, 200) def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None def read(self, *args, **kwargs): return [[[1, 2], [3, 4]]] class FakeWindowsModule: pass class FakeRasterio: class enums: class Resampling: nearest = "nearest" windows = FakeWindowsModule() def open(self, _path): return FakeSource() metadata = { "width": 200, "height": 120, "band_count": 4, "bounds": [0.0, 0.0, 1.0, 1.0], "crs": "EPSG:3857", "dtype": ["uint8"], "resolution": [1.0, 1.0], "transform": [1, 0, 0, 0, -1, 0, 0, 0, 1], "nodata": None, "driver": "GTiff", } monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (FakeRasterio(), None)) monkeypatch.setattr( "app.services.raster_operations_service.extract_raster_metadata", lambda _path: dict(metadata), ) monkeypatch.setattr("app.services.storage_service.get_settings", lambda: SimpleNamespace(storage_root=str(tmp_path))) monkeypatch.setattr( "app.services.raster_operations_service.RasterOperationsService._write_preview_image", lambda _data, _path: (100, 80), ) payload = RasterOperationsService.preview(db, dataset_id) assert payload["dataset_id"] == str(dataset_id) assert payload["ready"] is True assert payload["preview"]["format"] == "PNG" assert payload["metadata"]["size_bytes"] == 13 assert payload["metadata"]["checksum_sha256"] == "checksum" def test_raster_reproject_rejects_invalid_crs(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "raster.tif" source.write_bytes(b"dummy") dataset = Dataset( id=dataset_id, project_id=project_id, name="raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="raster.tif", stored_filename="raster.tif", content_type="image/tiff", size_bytes=5, ) db = FakeSession([dataset]) class FakeCRS: @staticmethod def from_user_input(_value): raise ValueError("invalid") class FakeRasterio: class crs: CRS = FakeCRS def open(self, *_args, **_kwargs): raise AssertionError("Invalid CRS should fail before opening raster") monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (FakeRasterio(), None)) try: RasterOperationsService.reproject(db, dataset_id, target_crs="not-a-crs", output_name=None) except AppError as exc: assert exc.code == "INVALID_PARAMETERS" else: raise AssertionError("Invalid target CRS should fail with INVALID_PARAMETERS") def test_raster_reproject_returns_persisted_derived_dataset(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() output_id = uuid4() source = tmp_path / "raster.tif" source.write_bytes(b"dummy") dataset = Dataset( id=dataset_id, project_id=project_id, name="raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="raster.tif", stored_filename="raster.tif", content_type="image/tiff", size_bytes=5, ) db = FakeSession([dataset]) class FakeWarp: @staticmethod def calculate_default_transform(*_args, **_kwargs): return ("transform", 8, 9) @staticmethod def reproject(**_kwargs): return None class FakeResampling: nearest = "nearest" bilinear = "nearest" cubic = "nearest" class FakeCRS: def __init__(self, value: str): self.value = value def __str__(self): return self.value def to_string(self): return self.value @staticmethod def from_user_input(value: str): return FakeCRS(value) class FakeTransform: @staticmethod def to_gdal(): return [1, 0, 0, 0, 1, 0, 0, 0, 1] class FakeSource: width = 10 height = 12 count = 2 crs = FakeCRS("EPSG:3857") transform = FakeTransform() nodata = 0 meta = { "driver": "GTiff", "dtype": "uint8", "count": 2, "width": 10, "height": 12, "crs": "EPSG:3857", "transform": FakeTransform(), } @staticmethod def band(_source, band_index): return (band_index,) def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeOutput: def __init__(self, path: str): self._path = Path(path) def write(self, _data): self._path.parent.mkdir(parents=True, exist_ok=True) self._path.write_bytes(b"reprojected") def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeRasterio: crs = FakeCRS enums = type("enums", (), {"Resampling": FakeResampling}) band = FakeSource.band warp = FakeWarp windows = type("windows", (), {}) def open(self, path: str, mode: str = "r", **_kwargs): if "w" in mode: return FakeOutput(path) return FakeSource() metadata = { "width": 8, "height": 9, "band_count": 2, "bounds": [0.0, 0.0, 8.0, 9.0], "crs": "EPSG:31370", "dtype": ["uint8", "uint8"], "resolution": [1.0, 1.0], "transform": [1, 0, 0, 0, -1, 0, 0, 0, 1], "nodata": None, "driver": "GTiff", } monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (FakeRasterio(), None)) monkeypatch.setattr("app.services.raster_operations_service.extract_raster_metadata", lambda _path: dict(metadata)) monkeypatch.setattr("app.services.storage_service.get_settings", lambda: SimpleNamespace(storage_root=str(tmp_path))) monkeypatch.setattr("uuid.uuid4", lambda: output_id) result_id = RasterOperationsService.reproject( db, dataset_id=dataset_id, target_crs="EPSG:31370", output_name="reprojected_raster", ) assert result_id == output_id assert len(db.added) == 2 derived = db.added[0] version = db.added[1] assert isinstance(version, DatasetVersion) assert version.dataset_id == output_id assert version.version == 1 assert derived.id == output_id assert derived.metadata_json is not None assert derived.metadata_json["operation"] == "raster.reproject" assert derived.metadata_json["source_dataset_id"] == str(dataset_id) assert derived.metadata_json["operation_parameters"]["target_crs"] == "EPSG:31370" assert derived.metadata_json["target_crs"] == "EPSG:31370" assert derived.metadata_json["output_dataset_id"] == str(output_id) def test_raster_inspect_returns_payload(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "raster.tif" source.write_bytes(b"dummy-raster") dataset = Dataset( id=dataset_id, project_id=project_id, name="raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="raster.tif", stored_filename="raster.tif", content_type="image/tiff", size_bytes=13, checksum_sha256="checksum", ) db = FakeSession([dataset]) metadata = { "width": 200, "height": 120, "band_count": 4, "bounds": [0.0, 0.0, 1.0, 1.0], "crs": "EPSG:3857", "dtype": ["uint8"], "resolution": [1.0, 1.0], "transform": [1, 0, 0, 0, -1, 0, 0, 0, 1], "nodata": None, "driver": "GTiff", } monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (SimpleNamespace(), None)) monkeypatch.setattr("app.services.raster_operations_service.extract_raster_metadata", lambda _path: dict(metadata)) payload = RasterOperationsService.inspect(db, dataset_id) assert payload["dataset_id"] == str(dataset_id) assert payload["ready"] is True assert payload["metadata"]["driver"] == "GTiff" assert payload["metadata"]["dataset_id"] == str(dataset_id) assert payload["metadata"]["size_bytes"] == 13 def test_raster_inspect_rejects_non_raster_dataset(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "not-raster.json" source.write_text("{}", encoding="utf-8") dataset = Dataset( id=dataset_id, project_id=project_id, name="not-raster.json", dataset_type="vector", source="user_upload", storage_path=str(source), original_filename="not-raster.json", stored_filename="not-raster.json", content_type="application/geo+json", ) db = FakeSession([dataset]) monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (SimpleNamespace(), None)) try: RasterOperationsService.inspect(db, dataset_id) except AppError as exc: assert exc.code == "INVALID_DATASET_TYPE" else: raise AssertionError("Inspecting vector dataset as raster should fail") def test_raster_tile_dependency_aware_when_rasterio_unavailable(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "raster.tif" source.write_bytes(b"\x00\x01") dataset = Dataset( id=dataset_id, project_id=project_id, name="raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="raster.tif", stored_filename="raster.tif", content_type="image/tiff", size_bytes=2, ) db = FakeSession([dataset]) monkeypatch.setattr( "app.services.raster_operations_service._import_rasterio", lambda: (_ for _ in ()).throw(ImportError("rasterio not installed")), ) try: RasterOperationsService.tile(db, dataset_id, tile_size=512, overlap=64) except AppError as exc: assert exc.code == "RASTER_PROCESSING_UNAVAILABLE" else: raise AssertionError("Missing rasterio should raise RASTER_PROCESSING_UNAVAILABLE for tile") def test_raster_tile_validation_rejects_bad_parameters() -> None: try: RasterOperationsService._validate_tile_request(tile_size=0, overlap=0) except AppError as exc: assert exc.code == "INVALID_PARAMETERS" else: raise AssertionError("Tile size 0 should be rejected") try: RasterOperationsService._validate_tile_request(tile_size=256, overlap=300) except AppError as exc: assert exc.code == "INVALID_PARAMETERS" else: raise AssertionError("Overlap larger than tile size should be rejected") def test_raster_clip_rejects_non_raster_dataset(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() area_id = uuid4() source = tmp_path / "not-raster.geojson" source.write_text("{}", encoding="utf-8") dataset = Dataset( id=dataset_id, project_id=project_id, name="not-raster.geojson", dataset_type="vector", source="user_upload", storage_path=str(source), original_filename="not-raster.geojson", stored_filename="not-raster.geojson", content_type="application/geo+json", ) area = Area(id=area_id, project_id=project_id, geometry="POINT(0 0)", original_crs="EPSG:4326") db = FakeSession([dataset], [area]) try: RasterOperationsService.clip(db, dataset_id, area_id, None) except AppError as exc: assert exc.code == "INVALID_DATASET_TYPE" else: raise AssertionError("Clipping vector dataset as raster should fail") def test_raster_clip_rejects_missing_area(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() area_id = uuid4() source = tmp_path / "raster.tif" source.write_bytes(b"\x00\x01") dataset = Dataset( id=dataset_id, project_id=project_id, name="raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="raster.tif", stored_filename="raster.tif", content_type="image/tiff", size_bytes=2, ) db = FakeSession([dataset], []) monkeypatch.setattr( "app.services.raster_operations_service._import_rasterio", lambda: (SimpleNamespace(), SimpleNamespace()), ) try: RasterOperationsService.clip(db, dataset_id, area_id, None) except AppError as exc: assert exc.code == "AREA_NOT_FOUND" else: raise AssertionError("Clipping without area should fail with AREA_NOT_FOUND") def test_raster_tile_returns_manifest_payload(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "raster.tif" source.write_bytes(b"source") dataset = Dataset( id=dataset_id, project_id=project_id, name="raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="raster.tif", stored_filename="raster.tif", content_type="image/tiff", size_bytes=6, ) db = FakeSession([dataset]) class FakeArray: shape = (1, 10, 10) @property def size(self): return self.shape[0] * self.shape[1] * self.shape[2] class FakeWindow: def __init__(self, xoff: float, yoff: float, width: float, height: float): self.xoff = xoff self.yoff = yoff self.width = width self.height = height class FakeWindowTransform: def __init__(self, xoff: float, yoff: float, width: float, height: float): self.xoff = xoff self.yoff = yoff self.width = width self.height = height def to_gdal(self): return [1.0, 0.0, self.xoff, 0.0, -1.0, self.yoff, 0.0, 0.0, 1.0] class FakeWindowBounds: def __init__(self, xoff: float, yoff: float, width: float, height: float): self.left = float(xoff) self.right = float(xoff + width) self.bottom = float(yoff) self.top = float(yoff + height) class FakeWindows: @staticmethod def Window(xoff: float, yoff: float, width: float, height: float): return FakeWindow(xoff, yoff, width, height) @staticmethod def transform(window: FakeWindow, _source_transform): return FakeWindowTransform(window.xoff, window.yoff, window.width, window.height) @staticmethod def bounds(window: FakeWindow, _source_transform): return ( float(window.xoff), float(window.yoff), float(window.xoff + window.width), float(window.yoff + window.height), ) class FakeCRS: def to_string(self): return "EPSG:31370" class FakeSource: width = 10 height = 10 def __init__(self): self.profile = {"width": self.width, "height": self.height, "count": 1, "dtype": "uint8", "transform": None} self.transform = None self.nodata = 0 self.crs = FakeCRS() def read(self, *args, **kwargs): return FakeArray() def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeOutput: def __init__(self, path: Path): self._path = path def write(self, _data): self._path.parent.mkdir(parents=True, exist_ok=True) self._path.write_text("tile") def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeRasterio: windows = FakeWindows def __call__(self, *_args, **_kwargs): return FakeSource() def open(self, path: str, mode: str = "r", **_kwargs): if mode and "w" in mode: return FakeOutput(Path(path)) return FakeSource() fake_rasterio = FakeRasterio() monkeypatch.setattr( "app.services.raster_operations_service._import_rasterio", lambda: (fake_rasterio, SimpleNamespace()), ) payload = RasterOperationsService.tile(db, dataset_id, tile_size=4, overlap=1, output_name="fixture") assert payload["dataset_id"] == str(dataset_id) assert payload["ready"] is True assert payload["tile_set_id"] is not None assert payload["count"] >= 1 assert payload["count"] == len(payload["manifest"]["tiles"]) assert payload["count"] == len(payload["manifest"]["tile_paths"]) assert payload["manifest"]["tiles"][0]["index"] == 0 assert payload["manifest_path"].endswith(".json") assert payload["manifest"]["tile_size"] == 4 assert payload["manifest"]["overlap"] == 1 assert payload["manifest"]["source_dataset_id"] == str(dataset_id) assert payload["manifest"]["source_raster_id"] == str(dataset_id) assert payload["manifest"]["crs"] == "EPSG:31370" assert payload["manifest"]["source_crs"] == "EPSG:31370" assert payload["manifest"]["count"] == payload["count"] assert payload["manifest"]["tiles"][0]["crs"] == "EPSG:31370" assert payload["manifest"]["tiles"][0]["bounds"] == [0.0, 0.0, 4.0, 4.0] assert payload["manifest"]["ai_inference"] is False assert payload["manifest"]["tile_server"] is None @pytest.mark.parametrize( ("dimension", "expected"), [ (512, [0]), (513, [0, 1]), (960, [0, 448]), (961, [0, 448, 449]), ], ) def test_raster_tile_offsets_use_full_tiles_and_one_unique_edge_start(dimension, expected) -> None: assert RasterOperationsService._tile_offsets(dimension, tile_size=512, step=448) == expected def test_raster_tile_rejects_limit_before_creating_output(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "large-raster.tif" source.write_bytes(b"source") dataset = Dataset( id=dataset_id, project_id=project_id, name="large-raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="large-raster.tif", stored_filename="large-raster.tif", content_type="image/tiff", size_bytes=6, ) db = FakeSession([dataset]) class FakeSource: width = 2048 height = 2048 count = 1 crs = None def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None fake_rasterio = SimpleNamespace(open=lambda _path: FakeSource()) monkeypatch.setattr( "app.services.raster_operations_service._import_rasterio", lambda: (fake_rasterio, SimpleNamespace()), ) tile_root = tmp_path / "tiles-that-must-not-exist" monkeypatch.setattr( StorageService, "raster_tiles_root", staticmethod(lambda *_args: tile_root), ) with pytest.raises(AppError) as error: RasterOperationsService.tile(db, dataset_id, tile_size=512, overlap=64, max_tiles=1) assert error.value.code == "RASTER_TILE_LIMIT_EXCEEDED" assert error.value.details == {"expected_tile_count": 25, "max_tiles": 1} assert not tile_root.exists() def test_raster_tile_rejects_changed_source_bytes_before_creating_output(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "changed-raster.tif" source.write_bytes(b"changed") dataset = Dataset( id=dataset_id, project_id=project_id, name="changed-raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="changed-raster.tif", stored_filename="changed-raster.tif", content_type="image/tiff", size_bytes=7, checksum_sha256=sha256(b"original").hexdigest(), data_contract_key="raster.generic", ) db = FakeSession([dataset]) tile_root = tmp_path / "tiles-that-must-not-exist" monkeypatch.setattr( StorageService, "raster_tiles_root", staticmethod(lambda *_args: tile_root), ) with pytest.raises(AppError) as error: RasterOperationsService.tile(db, dataset_id) assert error.value.code == "DATASET_STORAGE_CHECKSUM_MISMATCH" assert not tile_root.exists() def test_raster_clip_persists_derived_dataset(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "source.tif" source.write_bytes(b"source") output_id = uuid4() area_id = uuid4() dataset = Dataset( id=dataset_id, project_id=project_id, name="source.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="source.tif", stored_filename="source.tif", content_type="image/tiff", size_bytes=6, ) area = Area( id=area_id, project_id=project_id, geometry=from_shape(box(0.0, 0.0, 1.0, 1.0), srid=4326), original_crs="EPSG:4326", ) db = FakeSession([dataset], [area]) class FakeClippedData: shape = (1, 3, 4) @property def size(self): return 12 class FakeOutput: def __init__(self, output_file: Path): self.output_file = output_file def write(self, _data): self.output_file.parent.mkdir(parents=True, exist_ok=True) self.output_file.write_bytes(b"derived") def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeSource: width = 10 height = 10 crs = SimpleNamespace(to_string=lambda: "EPSG:3857") nodata = 0.0 profile = {"width": 10, "height": 10, "count": 1, "dtype": "uint8", "transform": "identity"} def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeMask: @staticmethod def mask(_source, _geom, crop=True, nodata=None, filled=True): return FakeClippedData(), SimpleNamespace(to_gdal=lambda: [1, 0, 0, 0, 1, 0, 0, 0, 1]) class FakeRasterio: mask = FakeMask() def open(self, path: str, mode: str = "r", **_kwargs): if "w" in mode: return FakeOutput(Path(path)) return FakeSource() metadata = { "width": 4, "height": 3, "band_count": 1, "bounds": [0.0, 0.0, 4.0, 3.0], "crs": "EPSG:3857", "dtype": ["uint8"], "resolution": [1.0, 1.0], "transform": [1, 0, 0, 0, -1, 0, 0, 0, 1], "nodata": None, "driver": "GTiff", } monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (FakeRasterio(), None)) monkeypatch.setattr("app.services.raster_operations_service.extract_raster_metadata", lambda _path: dict(metadata)) monkeypatch.setattr("app.services.storage_service.get_settings", lambda: SimpleNamespace(storage_root=str(tmp_path))) # Force a deterministic derived output id so we can assert provenance fields. monkeypatch.setattr( "uuid.uuid4", lambda: output_id, ) result_id = RasterOperationsService.clip(db, dataset_id, area_id, "clip-result.tif") assert result_id == output_id assert len(db.added) == 2 derived = db.added[0] version = db.added[1] assert isinstance(derived, Dataset) assert isinstance(version, DatasetVersion) assert version.dataset_id == output_id assert version.version == 1 assert derived.id == output_id assert derived.source == "operation:raster.clip" assert derived.dataset_type == "raster" assert derived.derived_from_dataset_id == dataset_id assert derived.metadata_json is not None assert derived.metadata_json.get("operation") == "raster.clip" assert derived.metadata_json.get("source_dataset_id") == str(dataset_id) assert derived.metadata_json.get("operation_parameters", {}).get("area_id") == str(area_id) assert derived.storage_path is not None assert Path(derived.storage_path).exists() def test_raster_clip_rejects_dataset_without_crs(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() area_id = uuid4() source = tmp_path / "raster.tif" source.write_bytes(b"\x00\x01") dataset = Dataset( id=dataset_id, project_id=project_id, name="raster.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="raster.tif", stored_filename="raster.tif", content_type="image/tiff", size_bytes=2, ) area = Area(id=area_id, project_id=project_id, geometry=from_shape(box(0.0, 0.0, 1.0, 1.0), srid=4326), original_crs="EPSG:4326") db = FakeSession([dataset], [area]) class FakeSource: width = 10 height = 10 crs = None def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeRasterio: def open(self, _path): return FakeSource() monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (FakeRasterio(), SimpleNamespace())) try: RasterOperationsService.clip(db, dataset_id, area_id, None) except AppError as exc: assert exc.code == "INVALID_DATASET_CRS" else: raise AssertionError("Clipping raster without CRS should fail") def test_run_job_sync_persists_job_output_dataset_for_raster_ops(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() recorded = {} class FakeJobRecord: def __init__(self, job_id): self.id = job_id self.job_type = "raster.reproject" self.status = "success" self.project_id = project_id self.dataset_id = None self.input_dataset_id = dataset_id self.output_dataset_id = None self.parameters_json = {} self.result_json = {} self.error_message = None self.created_at = None self.started_at = None self.finished_at = None def model_dump(self) -> dict: return { "id": self.id, "job_type": self.job_type, "status": self.status, "project_id": self.project_id, "dataset_id": self.dataset_id, "input_dataset_id": self.input_dataset_id, "output_dataset_id": self.output_dataset_id, "parameters_json": self.parameters_json, "result_json": self.result_json, "error_message": self.error_message, "created_at": self.created_at, "started_at": self.started_at, "finished_at": self.finished_at, } fake_job_id = uuid4() fake_output_dataset_id = uuid4() def fake_create_job(_db, payload): recorded["payload"] = payload return FakeJobRecord(fake_job_id) def fake_mark_running(_db, _job_id): recorded["running_called_with"] = _job_id return FakeJobRecord(fake_job_id) def fake_mark_success(_db, _job_id, result=None, output_dataset_id=None): record = FakeJobRecord(fake_job_id) record.result_json = result record.output_dataset_id = output_dataset_id return record def fake_mark_failed(*_args, **_kwargs): raise AssertionError("Raster job failure path should not execute") monkeypatch.setattr("app.api.routes.datasets.JobService.create_job", fake_create_job) monkeypatch.setattr("app.api.routes.datasets.JobService.mark_running", fake_mark_running) monkeypatch.setattr("app.api.routes.datasets.JobService.mark_success", fake_mark_success) monkeypatch.setattr("app.api.routes.datasets.JobService.mark_failed", fake_mark_failed) result = _run_job_sync( db=SimpleNamespace(add=lambda _item: None, commit=lambda: None, refresh=lambda _item: None), project_id=project_id, input_dataset_id=dataset_id, job_type="raster.reproject", parameters={}, operation=lambda: fake_output_dataset_id, ) assert result["output_dataset_id"] == str(fake_output_dataset_id) assert result["result_json"]["output_dataset_id"] == str(fake_output_dataset_id) assert recorded["running_called_with"] == fake_job_id def test_run_job_sync_records_raster_job_error(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() recorded = {} class FakeJobRecord: def __init__(self, job_id): self.id = job_id self.job_type = "raster.clip" self.status = "failed" self.project_id = project_id self.dataset_id = None self.input_dataset_id = dataset_id self.output_dataset_id = None self.parameters_json = {} self.result_json = {} self.error_message = "Operation failed" self.created_at = None self.started_at = None self.finished_at = None def model_dump(self) -> dict: return { "id": self.id, "job_type": self.job_type, "status": self.status, "project_id": self.project_id, "dataset_id": self.dataset_id, "input_dataset_id": self.input_dataset_id, "output_dataset_id": self.output_dataset_id, "parameters_json": self.parameters_json, "result_json": self.result_json, "error_message": self.error_message, "created_at": self.created_at, "started_at": self.started_at, "finished_at": self.finished_at, } fake_job_id = uuid4() def fake_create_job(_db, _payload): return FakeJobRecord(fake_job_id) def fake_mark_running(_db, _job_id): recorded["running_called_with"] = _job_id return FakeJobRecord(fake_job_id) def fake_mark_failed(_db, _job_id, error_message, details): record = FakeJobRecord(_job_id) record.error_message = error_message record.result_json = details recorded["mark_failed_payload"] = {"error_message": error_message, "details": details} return record monkeypatch.setattr("app.api.routes.datasets.JobService.create_job", fake_create_job) monkeypatch.setattr("app.api.routes.datasets.JobService.mark_running", fake_mark_running) monkeypatch.setattr("app.api.routes.datasets.JobService.mark_failed", fake_mark_failed) try: _run_job_sync( db=SimpleNamespace(add=lambda _item: None, commit=lambda: None, refresh=lambda _item: None), project_id=project_id, input_dataset_id=dataset_id, job_type="raster.reproject", parameters={}, operation=lambda: (_ for _ in ()).throw(AppError(code="INVALID_DATASET_CRS", message="Missing CRS", status_code=400)), ) raise AssertionError("Expected AppError to be raised") except AppError as exc: assert exc.code == "INVALID_DATASET_CRS" assert recorded["running_called_with"] == fake_job_id assert recorded["mark_failed_payload"]["error_message"] == "Missing CRS" assert recorded["mark_failed_payload"]["details"]["code"] == "INVALID_DATASET_CRS" def test_raster_clip_rejects_empty_raster_clip(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() area_id = uuid4() source = tmp_path / "source.tif" source.write_bytes(b"source") dataset = Dataset( id=dataset_id, project_id=project_id, name="source.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="source.tif", stored_filename="source.tif", content_type="image/tiff", size_bytes=6, ) area = Area( id=area_id, project_id=project_id, geometry=from_shape(box(0.0, 0.0, 1.0, 1.0), srid=4326), original_crs="EPSG:4326", ) db = FakeSession([dataset], [area]) class FakeOutputData: size = 0 class FakeMask: @staticmethod def mask(_source, _geom, crop=True, nodata=None, filled=True): return FakeOutputData(), SimpleNamespace(to_gdal=lambda: [1, 0, 0, 0, 1, 0, 0, 0, 1]) class FakeSource: width = 10 height = 10 crs = SimpleNamespace(to_string=lambda: "EPSG:3857") nodata = 0 profile = {"width": 10, "height": 10, "count": 1, "dtype": "uint8", "transform": "identity"} def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeRasterio: mask = FakeMask() def open(self, _path): return FakeSource() monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (FakeRasterio(), SimpleNamespace())) monkeypatch.setattr( "app.services.raster_operations_service._import_numpy", lambda: SimpleNamespace(asarray=lambda _values: _values, isfinite=lambda _values: False), ) try: RasterOperationsService.clip(db, dataset_id, area_id, None) except AppError as exc: assert exc.code == "RASTER_OPERATION_EMPTY_RESULT" else: raise AssertionError("Clip that produces no raster data should fail") def test_raster_ndvi_rejects_invalid_band_index(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "source.tif" source.write_bytes(b"source") dataset = Dataset( id=dataset_id, project_id=project_id, name="source.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="source.tif", stored_filename="source.tif", content_type="image/tiff", size_bytes=6, ) db = FakeSession([dataset]) class FakeSource: width = 4 height = 4 count = 3 profile = {"dtype": "uint16", "count": 3, "width": 4, "height": 4} nodata = 0 def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeRasterio: def open(self, path, *args, **kwargs): return FakeSource() monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (FakeRasterio(), None)) monkeypatch.setattr( "app.services.raster_operations_service._import_numpy", lambda: importlib.import_module("numpy"), ) try: RasterOperationsService.ndvi(db, dataset_id, nir_band=4, red_band=1) except AppError as exc: assert exc.code == "INVALID_PARAMETERS" assert "nir_band exceeds available band count" in exc.message else: raise AssertionError("Band index exceeding source band count should fail") def test_raster_ndvi_dependency_aware(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "source.tif" source.write_bytes(b"source") dataset = Dataset( id=dataset_id, project_id=project_id, name="source.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="source.tif", stored_filename="source.tif", content_type="image/tiff", size_bytes=6, ) db = FakeSession([dataset]) monkeypatch.setattr( "app.services.raster_operations_service._import_rasterio", lambda: (_ for _ in ()).throw(ImportError("rasterio not installed")), ) try: RasterOperationsService.ndvi(db, dataset_id, nir_band=1, red_band=1) except AppError as exc: assert exc.code == "RASTER_PROCESSING_UNAVAILABLE" else: raise AssertionError("Missing rasterio should raise RASTER_PROCESSING_UNAVAILABLE for spectral index") def test_raster_ndbi_dependency_aware_when_numpy_missing(monkeypatch, tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() source = tmp_path / "source.tif" source.write_bytes(b"source") dataset = Dataset( id=dataset_id, project_id=project_id, name="source.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="source.tif", stored_filename="source.tif", content_type="image/tiff", size_bytes=6, ) db = FakeSession([dataset]) class FakeSource: width = 4 height = 4 count = 6 profile = {"dtype": "uint16", "count": 6, "width": 4, "height": 4} def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None def read(self, *_args, **_kwargs): raise AssertionError("ndbi should fail before raster read when numpy is unavailable") class FakeRasterio: def open(self, path, *args, **kwargs): return FakeSource() monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (FakeRasterio(), None)) monkeypatch.setattr( "app.services.raster_operations_service._import_numpy", lambda: (_ for _ in ()).throw(ImportError("numpy missing")), ) try: RasterOperationsService.ndbi(db, dataset_id, swir_band=1, nir_band=2) except AppError as exc: assert exc.code == "RASTER_PROCESSING_UNAVAILABLE" else: raise AssertionError("Missing numpy should raise RASTER_PROCESSING_UNAVAILABLE for spectral index") def test_raster_index_records_provenance_and_dtype(tmp_path, monkeypatch) -> None: try: numpy = importlib.import_module("numpy") except Exception as exc: pytest.skip(f"numpy unavailable: {exc}") project_id = uuid4() dataset_id = uuid4() source = tmp_path / "source.tif" source.write_bytes(b"source") dataset = Dataset( id=dataset_id, project_id=project_id, name="source.tif", dataset_type="raster", source="user_upload", storage_path=str(source), original_filename="source.tif", stored_filename="source.tif", content_type="image/tiff", size_bytes=6, ) output_dataset_id = uuid4() db = FakeSession([dataset]) class FakeOutput: def __init__(self, path: Path): self.path = path def write(self, _data, indexes=1, window=None): self.path.parent.mkdir(parents=True, exist_ok=True) self.path.write_bytes(b"indexed") def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeSource: width = 4 height = 4 count = 4 nodata = 0 profile = { "driver": "GTiff", "dtype": "uint16", "count": 4, "width": 4, "height": 4, "transform": "identity", } def read(self, band_index, window=None, out_dtype=None): return numpy.array( [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]], dtype=out_dtype, ) def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None class FakeWindows: @staticmethod def Window(xoff, yoff, width, height): return (xoff, yoff, width, height) class FakeRasterio: windows = FakeWindows def open(self, path: str, mode: str = "r", **_kwargs): if "w" in mode: return FakeOutput(Path(path)) return FakeSource() metadata = { "width": 4, "height": 4, "band_count": 4, "bounds": [0.0, 0.0, 4.0, 4.0], "crs": "EPSG:31370", "dtype": ["uint16"], "resolution": [1.0, 1.0], "transform": [1, 0, 0, 0, -1, 0, 0, 0, 1], "nodata": None, "driver": "GTiff", } monkeypatch.setattr("app.services.raster_operations_service._import_rasterio", lambda: (FakeRasterio(), None)) monkeypatch.setattr("app.services.raster_operations_service._import_numpy", lambda: numpy) monkeypatch.setattr("app.services.raster_operations_service.extract_raster_metadata", lambda _path: dict(metadata)) monkeypatch.setattr("app.services.storage_service.get_settings", lambda: SimpleNamespace(storage_root=str(tmp_path))) monkeypatch.setattr("uuid.uuid4", lambda: output_dataset_id) result_dataset_id = RasterOperationsService.ndvi(db, dataset_id, nir_band=4, red_band=3, output_name="ndvi-test") assert result_dataset_id == output_dataset_id assert len(db.added) == 2 derived = db.added[0] version = db.added[1] assert isinstance(version, DatasetVersion) assert version.dataset_id == output_dataset_id assert version.version == 1 assert derived.id == output_dataset_id assert derived.metadata_json is not None assert derived.metadata_json["operation"] == "raster.ndvi" assert derived.metadata_json["source_dataset_id"] == str(dataset_id) assert derived.metadata_json["band_mapping"]["nir_band"] == 4 assert derived.metadata_json["band_mapping"]["red_band"] == 3 assert derived.metadata_json["formula"] == "(nir - red) / (nir + red)" assert derived.metadata_json["output_dtype"] == "float32" assert derived.metadata_json["nodata_strategy"]["mode"] == "nan" assert "path" in derived.metadata_json assert derived.metadata_json["path"] == derived.storage_path assert derived.storage_path is not None assert derived.metadata_json["created_at"] is not None assert derived.metadata_json["output_dataset_id"] == str(output_dataset_id) def test_run_job_sync_serializes_index_job_output_dataset_id(monkeypatch) -> None: project_id = uuid4() dataset_id = uuid4() output_dataset_id = uuid4() class FakeJobRecord: def __init__(self, job_id): self.id = job_id self.job_type = "raster.ndvi" self.status = "success" self.project_id = project_id self.dataset_id = None self.input_dataset_id = dataset_id self.output_dataset_id = None self.parameters_json = {} self.result_json = {} self.error_message = None self.created_at = None self.started_at = None self.finished_at = None def model_dump(self) -> dict: return { "id": self.id, "job_type": self.job_type, "status": self.status, "project_id": self.project_id, "dataset_id": self.dataset_id, "input_dataset_id": self.input_dataset_id, "output_dataset_id": self.output_dataset_id, "parameters_json": self.parameters_json, "result_json": self.result_json, "error_message": self.error_message, "created_at": self.created_at, "started_at": self.started_at, "finished_at": self.finished_at, } fake_job_id = uuid4() def fake_create_job(_db, payload): return FakeJobRecord(fake_job_id) def fake_mark_running(_db, _job_id): return FakeJobRecord(fake_job_id) def fake_mark_success(_db, _job_id, result=None, output_dataset_id=None): record = FakeJobRecord(fake_job_id) record.result_json = result record.output_dataset_id = output_dataset_id return record monkeypatch.setattr("app.api.routes.datasets.JobService.create_job", fake_create_job) monkeypatch.setattr("app.api.routes.datasets.JobService.mark_running", fake_mark_running) monkeypatch.setattr("app.api.routes.datasets.JobService.mark_success", fake_mark_success) result = _run_job_sync( db=SimpleNamespace(add=lambda _item: None, commit=lambda: None, refresh=lambda _item: None), project_id=project_id, input_dataset_id=dataset_id, job_type="raster.ndvi", parameters={"nir_band": 4, "red_band": 3}, operation=lambda: output_dataset_id, ) assert result["job_type"] == "raster.ndvi" assert result["output_dataset_id"] == str(output_dataset_id) assert result["result_json"]["output_dataset_id"] == str(output_dataset_id)