from types import SimpleNamespace from unittest.mock import MagicMock from uuid import uuid4 import pytest from sqlalchemy.dialects import sqlite from modelforge_api.domain.model_changes import ( ModelChangeCandidateLink, ModelChangeEvidenceLink, ModelChangeResponse, ) from modelforge_api.persistence.models import ( CapabilityDeployment, DiscoveryCandidateAssessment, SelectedModelComparison, ) from modelforge_api.services.audit import AuditContext from modelforge_api.services.model_changes import ModelChangeError, ModelChangeService def _service() -> tuple[ModelChangeService, MagicMock]: session = MagicMock() service = ModelChangeService(session, AuditContext("operator", "test-user", "test-correlation")) return service, session def _open_case() -> SimpleNamespace: return SimpleNamespace( id=uuid4(), project_binding_id=uuid4(), current_deployment_id=uuid4(), candidate_model_id=None, candidate_revision_id=None, candidate_artifact_set_id=None, candidate_deployment_id=None, discovery_assessment_id=None, download_plan_id=None, artifact_job_id=None, selected_comparison_id=None, migration_plan_id=None, lifecycle_plan_id=None, status="searching", status_reason="", version=1, closed_at=None, updated_by="test-user", ) def test_list_returns_a_bounded_filtered_page_with_stable_order( monkeypatch: pytest.MonkeyPatch, ) -> None: service, session = _service() binding_id = uuid4() row = SimpleNamespace(id=uuid4()) session.scalar.return_value = 241 session.scalars.return_value.all.return_value = [row] monkeypatch.setattr( service, "_response", lambda item: ModelChangeResponse.model_construct(id=item.id), ) page = service.list( project_key="examplerag", project_binding_id=binding_id, open_only=True, limit=100, offset=100, ) assert page.total == 241 assert [item.id for item in page.items] == [row.id] statement = session.scalars.call_args.args[0] sql = str(statement.compile(dialect=sqlite.dialect())) assert "ORDER BY model_change_cases.updated_at DESC, model_change_cases.id ASC" in sql assert "LIMIT ? OFFSET ?" in sql assert statement._limit_clause.value == 100 assert statement._offset_clause.value == 100 count_sql = str(session.scalar.call_args.args[0].compile(dialect=sqlite.dialect())) assert "model_change_cases.closed_at IS NULL" in count_sql assert "model_change_cases.project_binding_id" in count_sql assert 'projects."key" =' in count_sql def _binding_context(row: SimpleNamespace) -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, SimpleNamespace]: binding = SimpleNamespace(id=row.project_binding_id) project = SimpleNamespace(id=uuid4(), key="examplerag", name="ExampleRAG") contract = SimpleNamespace(id=uuid4()) capability = SimpleNamespace(id=uuid4(), key="rag.embedding") return binding, project, contract, capability def test_candidate_deployment_can_never_be_the_current_production_deployment(monkeypatch: pytest.MonkeyPatch) -> None: service, session = _service() row = _open_case() binding, project, contract, capability = _binding_context(row) deployment_id = row.current_deployment_id deployment = SimpleNamespace( id=deployment_id, capability_contract_id=contract.id, artifact_set_id=uuid4(), production=True, channel="stable", status="stable", deprecated_at=None, ) monkeypatch.setattr(service, "_row", lambda _identifier, lock=False: row) monkeypatch.setattr(service, "_binding_context", lambda _binding_id: (binding, project, contract, capability)) session.get.side_effect = lambda model, identifier: deployment if model is CapabilityDeployment and identifier == deployment_id else None request = ModelChangeCandidateLink(expected_version=1, candidate_deployment_id=deployment_id) with pytest.raises(ModelChangeError) as exc: service.link_candidate(row.id, request) assert exc.value.code == "MODEL_CHANGE_CANDIDATE_DEPLOYMENT_INVALID" session.commit.assert_not_called() def test_comparison_evidence_must_match_exact_project_and_current_candidate_pair(monkeypatch: pytest.MonkeyPatch) -> None: service, session = _service() row = _open_case() row.candidate_deployment_id = uuid4() binding, project, contract, capability = _binding_context(row) comparison_id = uuid4() comparison = SimpleNamespace( id=comparison_id, project_binding_id=uuid4(), current_deployment_id=row.current_deployment_id, candidate_deployment_id=row.candidate_deployment_id, status="completed", ) monkeypatch.setattr(service, "_row", lambda _identifier, lock=False: row) monkeypatch.setattr(service, "_binding_context", lambda _binding_id: (binding, project, contract, capability)) session.get.side_effect = lambda model, identifier: comparison if model is SelectedModelComparison and identifier == comparison_id else None request = ModelChangeEvidenceLink(expected_version=1, selected_comparison_id=comparison_id) with pytest.raises(ModelChangeError) as exc: service.link_evidence(row.id, request) assert exc.value.code == "MODEL_CHANGE_COMPARISON_MISMATCH" session.commit.assert_not_called() def test_discovery_assessment_must_belong_to_exact_immutable_revision(monkeypatch: pytest.MonkeyPatch) -> None: service, session = _service() row = _open_case() binding, project, contract, capability = _binding_context(row) model_id = uuid4() revision_id = uuid4() assessment_id = uuid4() revision = SimpleNamespace(id=revision_id, model_id=model_id, resolved_commit_sha="exact-sha") model = SimpleNamespace(id=model_id) assessment = SimpleNamespace( id=assessment_id, model_id=model_id, resolved_commit_sha="different-sha", ) monkeypatch.setattr(service, "_row", lambda _identifier, lock=False: row) monkeypatch.setattr(service, "_binding_context", lambda _binding_id: (binding, project, contract, capability)) def get(model_type: object, identifier: object) -> object | None: from modelforge_api.persistence.models import Model, ModelRevision if model_type is ModelRevision and identifier == revision_id: return revision if model_type is Model and identifier == model_id: return model if model_type is DiscoveryCandidateAssessment and identifier == assessment_id: return assessment return None session.get.side_effect = get request = ModelChangeCandidateLink( expected_version=1, candidate_model_id=model_id, candidate_revision_id=revision_id, discovery_assessment_id=assessment_id, ) with pytest.raises(ModelChangeError) as exc: service.link_candidate(row.id, request) assert exc.value.code == "MODEL_CHANGE_ASSESSMENT_MISMATCH" session.commit.assert_not_called() def test_stale_case_version_is_rejected_before_candidate_mutation(monkeypatch: pytest.MonkeyPatch) -> None: service, session = _service() row = _open_case() row.version = 7 monkeypatch.setattr(service, "_row", lambda _identifier, lock=False: row) request = ModelChangeCandidateLink(expected_version=6, candidate_model_id=uuid4()) with pytest.raises(ModelChangeError) as exc: service.link_candidate(row.id, request) assert exc.value.code == "MODEL_CHANGE_VERSION_CONFLICT" session.commit.assert_not_called()