from __future__ import annotations from uuid import uuid4 import pytest from app.core.config import Settings from app.core.errors import AppError from app.models import AnalysisRun, Dataset, Job, Project from app.services.detection_service import DetectionService from app.services.job_service import JobService from app.services.segmentation_service import SegmentationService class FakeSession: """Minimal session double without rollback support, mirroring existing test doubles.""" def __init__(self, objects=None) -> None: self.objects = objects or {} self.added = [] self.commits = 0 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: pass def _project_and_dataset(): project_id = uuid4() dataset_id = uuid4() project = Project(id=project_id, name="Mol") dataset = Dataset( id=dataset_id, project_id=project_id, name="ortho.tif", dataset_type="raster", source="fixture", source_name="fixture", storage_path="storage/uploads/ortho.tif", source_metadata={"fixture": True}, ) db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset}) return db, project_id, dataset_id def _statuses(db: FakeSession) -> tuple[list[str], list[str]]: runs = [item.status for item in db.added if isinstance(item, AnalysisRun)] jobs = [item.status for item in db.added if isinstance(item, Job)] return runs, jobs def test_invalid_fixture_detections_mark_run_and_job_failed() -> None: db, project_id, dataset_id = _project_and_dataset() with pytest.raises(AppError) as exc_info: DetectionService.run_detection( db=db, project_id=project_id, dataset_id=dataset_id, model_id="manual-fixture-detector", confidence_threshold=0.5, parameters_json={"fixture_mode": True, "fixture_detections": "not-a-list"}, settings=Settings(_env_file=None), ) assert exc_info.value.code == "INVALID_FIXTURE_DETECTIONS" run_statuses, job_statuses = _statuses(db) assert run_statuses and all(status == "failed" for status in run_statuses) assert job_statuses and all(status == "failed" for status in job_statuses) def test_invalid_fixture_segmentations_mark_run_and_job_failed() -> None: db, project_id, dataset_id = _project_and_dataset() with pytest.raises(AppError) as exc_info: SegmentationService.run_segmentation( db=db, project_id=project_id, dataset_id=dataset_id, model_id="fixture-segmenter", confidence_threshold=0.5, parameters_json={"fixture_mode": True, "fixture_segmentations": "not-a-list"}, settings=Settings(_env_file=None), ) assert exc_info.value.code == "INVALID_FIXTURE_SEGMENTATIONS" run_statuses, job_statuses = _statuses(db) assert run_statuses and all(status == "failed" for status in run_statuses) assert job_statuses and all(status == "failed" for status in job_statuses) def test_unexpected_error_in_sync_job_marks_job_failed() -> None: project_id = uuid4() db = FakeSession(objects={(Project, project_id): Project(id=project_id, name="Mol")}) def exploding_operation(): raise RuntimeError("unexpected internal failure") with pytest.raises(RuntimeError): JobService.run_sync_job( db=db, project_id=project_id, job_type="test.unexpected", parameters={}, operation=exploding_operation, ) jobs = [item for item in db.added if isinstance(item, Job)] assert jobs final_job = jobs[-1] assert final_job.status == "failed" assert "Unexpected internal error" in (final_job.error_message or "") def test_app_error_in_sync_job_still_marks_job_failed() -> None: project_id = uuid4() db = FakeSession(objects={(Project, project_id): Project(id=project_id, name="Mol")}) def failing_operation(): raise AppError(code="SOME_DOMAIN_ERROR", message="Bounded failure", status_code=422) with pytest.raises(AppError): JobService.run_sync_job( db=db, project_id=project_id, job_type="test.bounded", parameters={}, operation=failing_operation, ) jobs = [item for item in db.added if isinstance(item, Job)] assert jobs assert jobs[-1].status == "failed" assert jobs[-1].error_message == "Bounded failure"