from __future__ import annotations from hashlib import sha256 import json from pathlib import Path from uuid import uuid4 import pytest from fastapi.testclient import TestClient from app.core.config import Settings from app.core.errors import AppError from app.main import app from app.models import ( AnalysisRun, Dataset, DatasetVersion, Detection, Job, Project, SourceRegistry, SourceSnapshot, ) from app.services.detection_service import DetectionService from app.services.model_asset_catalog_service import ModelAssetCatalogService from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService from app.services.tile_manifest_service import TileManifestService class FakeSession: def __init__(self, objects=None) -> None: self.objects = objects or {} self.added = [] self.commits = 0 self.refreshes = [] def get(self, model, item_id): return self.objects.get((model, item_id)) def add(self, item) -> None: self.added.append(item) if getattr(item, "id", None) is not None: self.objects[(item.__class__, item.id)] = item def commit(self) -> None: self.commits += 1 def refresh(self, item) -> None: self.refreshes.append(item) class MockYoloAdapter: def __init__(self, settings: Settings) -> None: self.settings = settings @staticmethod def dependencies_available() -> bool: return True def load_model(self, model_path: Path): return {"model_path": str(model_path)} def predict_tiles(self, model, tile_paths, confidence_threshold: float) -> list[list[dict]]: # The service batches tiles; this double still answers per tile. return [self.predict_tile(model, tile_path, confidence_threshold) for tile_path in tile_paths] def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]: assert model["model_path"].endswith("building-detector.pt") return [ { "class_name": "building", "confidence": 0.9, "bbox": [10.0, 20.0, 30.0, 40.0], "properties": {"adapter": "mock"}, } ] def _project_and_raster_dataset(): project_id = uuid4() dataset_id = uuid4() source_registry_id = uuid4() source_snapshot_id = uuid4() checksum = "a" * 64 project = Project(id=project_id, name="Geel") source_registry = SourceRegistry( id=source_registry_id, source_key="test-derived-raster", display_name="Governed test-derived raster", classification="derived", authority_name="GeoIntel test fixture", usage_policy_json={"ground_truth_allowed": False}, ) source_snapshot = SourceSnapshot( id=source_snapshot_id, source_registry_id=source_registry_id, snapshot_key="test-derived-raster-v1", checksum_sha256=checksum, freshness_status="current", ingest_status="ingested", ) dataset = Dataset( id=dataset_id, project_id=project_id, name="source.tif", dataset_type="raster", source="test-derived-raster", source_name="test-derived-raster", storage_path="storage/uploads/source.tif", checksum_sha256=checksum, crs="EPSG:4326", bounds_json={"min_x": 4.0, "min_y": 51.0, "max_x": 5.0, "max_y": 52.0}, source_registry_id=source_registry_id, source_snapshot_id=source_snapshot_id, data_contract_key="geointel.raster.geotiff", data_contract_version="1.0.0", validation_status="passed", provenance_status="complete", lineage_status="not_applicable", quarantine_status="not_quarantined", status="ready", ) dataset.source_registry = source_registry dataset.source_snapshot = source_snapshot dataset.versions.append( DatasetVersion(id=uuid4(), dataset_id=dataset_id, version=1, checksum_sha256=checksum) ) db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset}) return db, project_id, dataset_id def _manifest(tmp_path: Path, db: FakeSession, dataset: Dataset) -> Path: tile_path = tmp_path / "tile_0000.tif" tile_path.write_bytes(b"tile") binding = TileManifestService.dataset_binding(db, dataset) manifest_path = tmp_path / "manifest.json" manifest_path.write_text( json.dumps( { **binding, "tile_set_id": "tiles-fixture", "count": 1, "crs": "EPSG:4326", "bounds": [4.0, 51.0, 5.0, 52.0], "tiles": [ { "path": str(tile_path), "pixel_window": [0, 0, 100, 100], "bounds": [4.0, 51.0, 5.0, 52.0], "transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01], "crs": "EPSG:4326", "index": 0, **TileManifestService.tile_integrity(tile_path), } ], } ), encoding="utf-8", ) return manifest_path def _write_model_sidecar( model_path: Path, settings: Settings, *, db: FakeSession | None = None, ) -> None: model_sha256 = sha256(model_path.read_bytes()).hexdigest() source_registry_id = uuid4() source_snapshot_id = uuid4() source_version = settings.yolo_model_version or "test-v1" if db is not None: source_registry = SourceRegistry( id=source_registry_id, source_key="model", display_name="Governed test model artifact", classification="experimental", authority_name="GeoIntel test fixture", freshness_status="current", ingest_status="configured", ) source_snapshot = SourceSnapshot( id=source_snapshot_id, source_registry_id=source_registry_id, snapshot_key=f"model-{source_version}", source_version=source_version, checksum_sha256=model_sha256, freshness_status="current", ingest_status="ingested", ) db.objects[(SourceRegistry, source_registry_id)] = source_registry db.objects[(SourceSnapshot, source_snapshot_id)] = source_snapshot payload = { "schema_version": RuntimeModelProvenanceService.MANIFEST_SCHEMA_VERSION, "data_contract": {"key": "geointel.model.pytorch", "version": "1.0.0"}, "model": { "model_id": settings.yolo_model_id, "task_type": "object_detection", "sha256": model_sha256, "model_format": "pytorch", "framework": "ultralytics/pytorch", "class_mapping": {"0": "building"}, "source_version": source_version, }, "source": { "source_registry_id": str(source_registry_id), "source_snapshot_id": str(source_snapshot_id), "source_registry_key": "model", "source_snapshot_checksum_sha256": model_sha256, }, "lineage": { "upstream_asset_ids": ["test-training-corpus"], "upstream_checksums_sha256": ["a" * 64], "transformations": [ {"name": "test-training", "version": "1.0.0", "checksum_sha256": "b" * 64} ], }, "metadata": {"training_manifest_sha256": "c" * 64}, "imported_at": "2026-08-01T10:00:00+00:00", } payload["metadata"]["runtime_manifest_sha256"] = RuntimeModelProvenanceService.manifest_self_checksum(payload) RuntimeModelProvenanceService.manifest_path_for_model(model_path).write_text( json.dumps(payload, sort_keys=True), encoding="utf-8", ) def test_model_asset_catalog_lists_supported_local_model_files(tmp_path: Path) -> None: model_file = tmp_path / "building-detector.pt" model_file.write_bytes(b"local model") ignored_file = tmp_path / "notes.txt" ignored_file.write_text("ignore me", encoding="utf-8") settings = Settings(yolo_models_dir=str(tmp_path), yolo_model_path=str(model_file), yolo_enabled=True) response = ModelAssetCatalogService.list_assets(settings=settings) assert response.total == 1 asset = response.items[0] assert asset.model_asset_id == "building-detector-pt" assert asset.filename == "building-detector.pt" assert asset.display_name == "building-detector" assert asset.model_path == str(model_file) assert asset.size_bytes == len(b"local model") assert len(asset.sha256) == 64 assert asset.active is True assert asset.runtime_available is True assert asset.runtime_status == "active" assert asset.governed_validation_status == "not_verified_by_catalog" assert asset.promotion_status == "not_verified_by_catalog" assert asset.status == "runtime_active" assert asset.will_download_models is False def test_model_asset_catalog_resolves_known_asset(tmp_path: Path) -> None: model_file = tmp_path / "building-detector.pt" model_file.write_bytes(b"local model") settings = Settings(yolo_models_dir=str(tmp_path), yolo_enabled=True) asset = ModelAssetCatalogService.resolve_asset("building-detector-pt", settings=settings) assert asset.filename == "building-detector.pt" assert asset.model_path == str(model_file) def test_model_asset_catalog_only_exposes_explicit_active_asset_in_runtime(tmp_path: Path) -> None: active_file = tmp_path / "approved-building-detector.pt" active_file.write_bytes(b"approved") (tmp_path / "training-smoke.pt").write_bytes(b"experiment") (tmp_path / "partial-checkpoint.pt").write_bytes(b"partial") settings = Settings( yolo_models_dir=str(tmp_path), yolo_model_path=str(active_file), yolo_enabled=True, ) response = ModelAssetCatalogService.list_assets(settings=settings) assert response.total == 1 assert response.items[0].filename == active_file.name assert response.items[0].active is True assert response.items[0].runtime_status == "active" assert response.items[0].governed_validation_status == "not_verified_by_catalog" assert response.items[0].promotion_status == "not_verified_by_catalog" assert response.items[0].status == "runtime_active" def test_model_asset_catalog_rejects_unknown_asset(tmp_path: Path) -> None: settings = Settings(yolo_models_dir=str(tmp_path), yolo_enabled=True) with pytest.raises(AppError) as exc_info: ModelAssetCatalogService.resolve_asset("missing-model", settings=settings) assert exc_info.value.code == "DETECTION_MODEL_ASSET_NOT_FOUND" assert exc_info.value.status_code == 404 def test_model_assets_api_returns_canonical_envelope(monkeypatch, tmp_path: Path) -> None: model_file = tmp_path / "building-detector.pt" model_file.write_bytes(b"local model") monkeypatch.setenv("YOLO_MODELS_DIR", str(tmp_path)) monkeypatch.setenv("YOLO_MODEL_PATH", str(model_file)) response = TestClient(app).get("/api/v1/detection/model-assets") assert response.status_code == 200 payload = response.json() assert set(payload) == {"data"} assert payload["data"]["total"] == 1 assert payload["data"]["items"][0]["model_asset_id"] == "building-detector-pt" assert payload["data"]["items"][0]["active"] is True assert payload["data"]["items"][0]["runtime_status"] == "active" assert payload["data"]["items"][0]["governed_validation_status"] == "not_verified_by_catalog" assert payload["data"]["items"][0]["promotion_status"] == "not_verified_by_catalog" assert payload["data"]["items"][0]["will_download_models"] is False def test_detection_run_persists_selected_model_asset_parameters(tmp_path, monkeypatch: Path) -> None: # A manifest written into tmp_path is only a governed artifact if # tmp_path is the storage root. monkeypatch.setenv("STORAGE_ROOT", str(tmp_path)) model_file = tmp_path / "building-detector.pt" model_file.write_bytes(b"local model") db, project_id, dataset_id = _project_and_raster_dataset() settings = Settings( yolo_enabled=True, yolo_model_path=str(tmp_path / "default.pt"), yolo_models_dir=str(tmp_path), yolo_max_tiles=4, ) _write_model_sidecar(model_file, settings, db=db) result = DetectionService.run_detection( db=db, project_id=project_id, dataset_id=dataset_id, model_id="yolo-configured", model_asset_id="building-detector-pt", confidence_threshold=0.5, tile_manifest_path=str(_manifest(tmp_path, db, db.get(Dataset, dataset_id))), settings=settings, yolo_adapter_class=MockYoloAdapter, ) jobs = [item for item in db.added if isinstance(item, Job)] runs = [item for item in db.added if isinstance(item, AnalysisRun)] detections = [item for item in db.added if isinstance(item, Detection)] assert result.status == "success" assert result.detection_count == 1 assert jobs[0].parameters_json["model_asset_id"] == "building-detector-pt" assert jobs[0].parameters_json["model_asset_path"] == str(model_file) assert len(jobs[0].parameters_json["model_asset_sha256"]) == 64 assert runs[0].parameters_json["model_asset_id"] == "building-detector-pt" assert detections[0].model_name == "yolo-configured"