"""Step definitions for validation-gated apply pipeline tests. Uses mock objects for PlanLifecycleService and Plan to test the validation gate logic in PlanApplyService without needing a database. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock from behave import given, then, when from behave.runner import Context from cleveragents.application.services.plan_apply_service import ( ApplyOutcome, PlanApplyService, ) from cleveragents.domain.models.core.change import ( ChangeEntry, ChangeOperation, SpecChangeSet, ) from cleveragents.domain.models.core.plan import ( PlanPhase, PlanTimestamps, ProcessingState, ) __all__: list[str] = [] # --------------------------------------------------------------------------- # Mock helpers # --------------------------------------------------------------------------- _PLAN_ID = "01PLANTEST000000000000001" def _make_mock_plan( *, plan_id: str = _PLAN_ID, phase: PlanPhase = PlanPhase.EXECUTE, state: ProcessingState = ProcessingState.COMPLETE, changeset_id: str | None = "CS001", validation_summary: dict[str, Any] | None = None, is_terminal: bool = False, ) -> MagicMock: """Create a mock Plan object.""" plan = MagicMock() plan.identity.plan_id = plan_id plan.phase = phase plan.processing_state = state plan.changeset_id = changeset_id plan.validation_summary = validation_summary plan.is_terminal = is_terminal plan.error_details = None plan.timestamps = PlanTimestamps() return plan def _make_changeset( plan_id: str = _PLAN_ID, n_files: int = 1, ) -> SpecChangeSet: """Create a SpecChangeSet with n_files entries.""" cs = SpecChangeSet(plan_id=plan_id) for i in range(n_files): cs.add_change( ChangeEntry( plan_id=plan_id, resource_id="RES001", tool_name="builtin/test-tool", operation=ChangeOperation.MODIFY, path=f"src/file_{i}.py", before_hash=f"before-{i}", after_hash=f"after-{i}", ) ) return cs def _make_service( plan: MagicMock, changeset: SpecChangeSet | None = None, ) -> PlanApplyService: """Create a PlanApplyService with mocked lifecycle and store.""" lifecycle = MagicMock() lifecycle.get_plan.return_value = plan lifecycle.complete_apply.return_value = plan lifecycle.constrain_apply.return_value = plan lifecycle._commit_plan = MagicMock() store = MagicMock() if changeset is not None: store.get.return_value = changeset else: store.get.return_value = None return PlanApplyService( lifecycle_service=lifecycle, changeset_store=store, ) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("an apply pipeline test environment") def step_given_apply_env(context: Context) -> None: context.ap_plan_id = _PLAN_ID context.ap_plan = None context.ap_changeset = None context.ap_service = None context.ap_result = None # ApplyResult | None context.ap_allow_empty = False context.ap_external_summary = None # dict[str, Any] | None # --------------------------------------------------------------------------- # Given steps # --------------------------------------------------------------------------- @given("a plan with changeset containing {n} files") def step_given_plan_with_files(context: Context, n: str) -> None: cs = _make_changeset(n_files=int(n)) plan = _make_mock_plan(changeset_id=cs.changeset_id) context.ap_plan = plan context.ap_changeset = cs context.ap_service = _make_service(plan, cs) @given("the plan has validation summary with {p} required passed and {f} failed") def step_given_validation_summary(context: Context, p: str, f: str) -> None: vs = { "total": int(p) + int(f), "required_passed": int(p), "required_failed": int(f), "informational_passed": 0, "informational_failed": 0, } context.ap_plan.validation_summary = vs @given( "the plan has validation summary with {p} required passed " "and {f} failed and {i} informational" ) def step_given_validation_summary_with_info( context: Context, p: str, f: str, i: str ) -> None: vs = { "total": int(p) + int(f) + int(i), "required_passed": int(p), "required_failed": int(f), "informational_passed": int(i), "informational_failed": 0, } context.ap_plan.validation_summary = vs @given("the plan has no validation summary") def step_given_no_validation_summary(context: Context) -> None: context.ap_plan.validation_summary = None @given("a plan that is already applied") def step_given_already_applied(context: Context) -> None: plan = _make_mock_plan( phase=PlanPhase.APPLY, state=ProcessingState.APPLIED, is_terminal=True, ) cs = _make_changeset() context.ap_plan = plan context.ap_changeset = cs context.ap_service = _make_service(plan, cs) @given("a plan that is in errored state") def step_given_errored_plan(context: Context) -> None: plan = _make_mock_plan( phase=PlanPhase.APPLY, state=ProcessingState.ERRORED, is_terminal=True, ) cs = _make_changeset() context.ap_plan = plan context.ap_changeset = cs context.ap_service = _make_service(plan, cs) @given("a plan that is in cancelled state") def step_given_cancelled_plan(context: Context) -> None: plan = _make_mock_plan( phase=PlanPhase.APPLY, state=ProcessingState.CANCELLED, is_terminal=True, ) cs = _make_changeset() context.ap_plan = plan context.ap_changeset = cs context.ap_service = _make_service(plan, cs) @given("a plan with empty changeset") def step_given_empty_changeset(context: Context) -> None: cs = _make_changeset(n_files=0) plan = _make_mock_plan(changeset_id=cs.changeset_id) context.ap_plan = plan context.ap_changeset = cs context.ap_service = _make_service(plan, cs) @given("an apply plan with no changeset assigned") def step_given_no_changeset(context: Context) -> None: plan = _make_mock_plan(changeset_id=None) context.ap_plan = plan context.ap_changeset = None context.ap_service = _make_service(plan, None) # --------------------------------------------------------------------------- # When steps # --------------------------------------------------------------------------- @when("I apply the plan with validation gate") def step_when_apply(context: Context) -> None: context.ap_result = context.ap_service.apply_with_validation_gate( plan_id=context.ap_plan_id, allow_empty=context.ap_allow_empty, ) @when("I apply the plan with validation gate and allow_empty") def step_when_apply_allow_empty(context: Context) -> None: context.ap_result = context.ap_service.apply_with_validation_gate( plan_id=context.ap_plan_id, allow_empty=True, ) @when( "I apply the plan with external validation summary having {p} passed and {f} failed" ) def step_when_apply_external_summary(context: Context, p: str, f: str) -> None: ext_summary = { "total": int(p) + int(f), "required_passed": int(p), "required_failed": int(f), } context.ap_result = context.ap_service.apply_with_validation_gate( plan_id=context.ap_plan_id, validation_summary=ext_summary, ) # --------------------------------------------------------------------------- # Then steps # --------------------------------------------------------------------------- @then('the apply outcome should be "{outcome}"') def step_then_outcome(context: Context, outcome: str) -> None: assert context.ap_result is not None, "No apply result" expected = ApplyOutcome(outcome) assert context.ap_result.outcome == expected, ( f"Expected outcome '{outcome}', got '{context.ap_result.outcome}'" ) @then("the apply result files changed should be {n}") def step_then_files_changed(context: Context, n: str) -> None: assert context.ap_result is not None assert context.ap_result.files_changed == int(n), ( f"Expected files_changed={n}, got {context.ap_result.files_changed}" ) @then("the apply result validations total should be {n}") def step_then_validations_total(context: Context, n: str) -> None: assert context.ap_result is not None assert context.ap_result.validations_total == int(n), ( f"Expected validations_total={n}, got {context.ap_result.validations_total}" ) @then("the apply result validations failed should be {n}") def step_then_validations_failed(context: Context, n: str) -> None: assert context.ap_result is not None assert context.ap_result.validations_failed == int(n), ( f"Expected validations_failed={n}, got {context.ap_result.validations_failed}" ) @then('the apply result message should contain "{text}"') def step_then_message_contains(context: Context, text: str) -> None: assert context.ap_result is not None assert text in context.ap_result.message, ( f"Expected message to contain '{text}', got: {context.ap_result.message}" ) @then("the apply result plan_id should match the plan ID") def step_then_plan_id_matches(context: Context) -> None: assert context.ap_result is not None assert context.ap_result.plan_id == context.ap_plan_id