Files
ModelForge-Public/backend/tests/test_model_changes.py
T

201 lines
7.6 KiB
Python

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()