201 lines
7.6 KiB
Python
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()
|