"""Step definitions for plan_lifecycle_rollback.feature. Tests for PlanLifecycleService.rollback_plan() — the new service-layer method that validates plan state and delegates to CheckpointService before emitting PLAN_ROLLED_BACK domain events. All step text uses the ``plr-`` prefix to avoid collisions with existing step definitions. """ 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_lifecycle_service import ( PlanLifecycleService, ) from cleveragents.config.settings import Settings from cleveragents.core.exceptions import ( BusinessRuleViolation, NotFoundError, PlanError, ResourceNotFoundError, ) from cleveragents.domain.models.core.checkpoint import RollbackResult from cleveragents.domain.models.core.plan import ( PlanPhase, ProcessingState, ProjectLink, ) from cleveragents.infrastructure.events.types import EventType # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- _PLAN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" _CHECKPOINT_ID = "01BRZ4NFEK0000000000000000" _NONEXISTENT_PLAN_ID = "01NONEXISTENT0000000000000" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_mock_checkpoint_service( plan_id: str = _PLAN_ID, checkpoint_id: str = _CHECKPOINT_ID, *, side_effect: Exception | None = None, ) -> MagicMock: """Build a mock CheckpointService that returns a valid RollbackResult.""" mock_svc = MagicMock() if side_effect is not None: mock_svc.selective_rollback.side_effect = side_effect else: mock_svc.selective_rollback.return_value = RollbackResult( restored_files_count=3, changed_paths=["src/foo.py", "src/bar.py", "tests/test_foo.py"], from_checkpoint_id=checkpoint_id, ) return mock_svc def _make_mock_event_bus() -> MagicMock: """Build a mock EventBus.""" mock_bus = MagicMock() mock_bus.emitted_events: list[Any] = [] def _emit(event: Any) -> None: mock_bus.emitted_events.append(event) mock_bus.emit.side_effect = _emit return mock_bus def _create_service( checkpoint_service: Any = None, event_bus: Any = None, ) -> PlanLifecycleService: """Create a PlanLifecycleService with optional dependencies.""" settings = Settings() return PlanLifecycleService( settings=settings, checkpoint_service=checkpoint_service, event_bus=event_bus, ) def _create_plan_in_service( service: PlanLifecycleService, phase: PlanPhase = PlanPhase.EXECUTE, state: ProcessingState = ProcessingState.PROCESSING, ) -> str: """Create an action and plan, then force the plan into the given phase/state.""" service.create_action( name="local/test-rollback-action", description="Test action for rollback", definition_of_done="Tests pass", strategy_actor="local/test-strategist", execution_actor="local/test-executor", ) plan = service.use_action( action_name="local/test-rollback-action", project_links=[ProjectLink(project_name="test-project")], ) plan_id = plan.identity.plan_id # Force the plan into the desired phase/state by direct mutation # (avoids needing to run full actor pipeline in unit tests) plan.phase = phase plan.processing_state = state service._plans[plan_id] = plan return plan_id # --------------------------------------------------------------------------- # Given steps # --------------------------------------------------------------------------- @given("plr-a plan lifecycle service with a mock checkpoint service") def step_plr_service_with_mock_checkpoint(context: Context) -> None: """Set up a PlanLifecycleService with a mock CheckpointService.""" context.plr_mock_checkpoint_svc = _make_mock_checkpoint_service() context.plr_service = _create_service( checkpoint_service=context.plr_mock_checkpoint_svc, ) context.plr_checkpoint_id = _CHECKPOINT_ID @given("plr-a plan lifecycle service without a checkpoint service") def step_plr_service_without_checkpoint(context: Context) -> None: """Set up a PlanLifecycleService with no CheckpointService.""" context.plr_service = _create_service(checkpoint_service=None) context.plr_checkpoint_id = _CHECKPOINT_ID @given("plr-a plan lifecycle service with a mock checkpoint service and event bus") def step_plr_service_with_checkpoint_and_event_bus(context: Context) -> None: """Set up a PlanLifecycleService with mock CheckpointService and EventBus.""" context.plr_mock_checkpoint_svc = _make_mock_checkpoint_service() context.plr_mock_event_bus = _make_mock_event_bus() context.plr_service = _create_service( checkpoint_service=context.plr_mock_checkpoint_svc, event_bus=context.plr_mock_event_bus, ) context.plr_checkpoint_id = _CHECKPOINT_ID @given("plr-a plan lifecycle service with a mock checkpoint service but no event bus") def step_plr_service_with_checkpoint_no_event_bus(context: Context) -> None: """Set up a PlanLifecycleService with mock CheckpointService but no EventBus.""" context.plr_mock_checkpoint_svc = _make_mock_checkpoint_service() context.plr_service = _create_service( checkpoint_service=context.plr_mock_checkpoint_svc, event_bus=None, ) context.plr_checkpoint_id = _CHECKPOINT_ID @given( "plr-a plan lifecycle service with a checkpoint service that raises ResourceNotFoundError" ) def step_plr_service_checkpoint_raises_resource_not_found(context: Context) -> None: """Set up a service whose checkpoint service raises ResourceNotFoundError.""" context.plr_mock_checkpoint_svc = _make_mock_checkpoint_service( side_effect=ResourceNotFoundError( "Checkpoint not found", resource_type="checkpoint", resource_id=_CHECKPOINT_ID, ) ) context.plr_service = _create_service( checkpoint_service=context.plr_mock_checkpoint_svc, ) context.plr_checkpoint_id = _CHECKPOINT_ID @given( "plr-a plan lifecycle service with a checkpoint service that raises BusinessRuleViolation" ) def step_plr_service_checkpoint_raises_business_rule(context: Context) -> None: """Set up a service whose checkpoint service raises BusinessRuleViolation.""" context.plr_mock_checkpoint_svc = _make_mock_checkpoint_service( side_effect=BusinessRuleViolation("Checkpoint rollback failed"), ) context.plr_service = _create_service( checkpoint_service=context.plr_mock_checkpoint_svc, ) context.plr_checkpoint_id = _CHECKPOINT_ID @given("plr-a plan in Execute phase with a sandbox") def step_plr_plan_in_execute_with_sandbox(context: Context) -> None: """Create a plan in Execute/PROCESSING state.""" context.plr_plan_id = _create_plan_in_service( context.plr_service, phase=PlanPhase.EXECUTE, state=ProcessingState.PROCESSING, ) @given("plr-a plan in Execute phase") def step_plr_plan_in_execute(context: Context) -> None: """Create a plan in Execute/PROCESSING state (no sandbox needed for error tests).""" context.plr_plan_id = _create_plan_in_service( context.plr_service, phase=PlanPhase.EXECUTE, state=ProcessingState.PROCESSING, ) @given("plr-a plan in APPLIED terminal state") def step_plr_plan_in_applied_state(context: Context) -> None: """Create a plan in APPLIED terminal state.""" context.plr_plan_id = _create_plan_in_service( context.plr_service, phase=PlanPhase.APPLY, state=ProcessingState.APPLIED, ) @given("plr-a plan in CANCELLED terminal state") def step_plr_plan_in_cancelled_state(context: Context) -> None: """Create a plan in CANCELLED terminal state.""" context.plr_plan_id = _create_plan_in_service( context.plr_service, phase=PlanPhase.APPLY, state=ProcessingState.CANCELLED, ) @given("plr-a plan in Execute/PROCESSING state with a sandbox") def step_plr_plan_in_execute_processing(context: Context) -> None: """Create a plan in Execute/PROCESSING state.""" context.plr_plan_id = _create_plan_in_service( context.plr_service, phase=PlanPhase.EXECUTE, state=ProcessingState.PROCESSING, ) @given("plr-a plan in Execute/QUEUED state with a sandbox") def step_plr_plan_in_execute_queued(context: Context) -> None: """Create a plan in Execute/QUEUED state.""" context.plr_plan_id = _create_plan_in_service( context.plr_service, phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED, ) @given("plr-a plan in Strategize/QUEUED state with a sandbox") def step_plr_plan_in_strategize_queued(context: Context) -> None: """Create a plan in Strategize/QUEUED state.""" context.plr_plan_id = _create_plan_in_service( context.plr_service, phase=PlanPhase.STRATEGIZE, state=ProcessingState.QUEUED, ) @given("plr-a checkpoint exists for the plan") def step_plr_checkpoint_exists(context: Context) -> None: """Checkpoint already set up via mock; this step is a no-op for clarity.""" # The mock checkpoint service is already configured to return a valid # RollbackResult for any plan_id/checkpoint_id combination. pass # --------------------------------------------------------------------------- # When steps # --------------------------------------------------------------------------- @when("plr-I call rollback_plan with the plan id and checkpoint id") def step_plr_call_rollback_plan(context: Context) -> None: """Call rollback_plan on the service.""" context.plr_raised_error = None context.plr_result = None try: context.plr_result = context.plr_service.rollback_plan( context.plr_plan_id, context.plr_checkpoint_id, ) except (PlanError, NotFoundError, Exception) as exc: context.plr_raised_error = exc @when("plr-I call rollback_plan with a non-existent plan id") def step_plr_call_rollback_nonexistent(context: Context) -> None: """Call rollback_plan with a plan id that does not exist.""" context.plr_raised_error = None context.plr_result = None try: context.plr_result = context.plr_service.rollback_plan( _NONEXISTENT_PLAN_ID, _CHECKPOINT_ID, ) except (PlanError, NotFoundError, Exception) as exc: context.plr_raised_error = exc # --------------------------------------------------------------------------- # Then steps # --------------------------------------------------------------------------- @then("plr-the rollback should succeed") def step_plr_rollback_succeeded(context: Context) -> None: """Assert that rollback_plan returned without raising an error.""" assert context.plr_raised_error is None, ( f"Expected rollback to succeed but got error: {context.plr_raised_error}" ) assert context.plr_result is not None, ( "Expected rollback_plan to return a RollbackResult, got None" ) @then("plr-the result should be a RollbackResult") def step_plr_result_is_rollback_result(context: Context) -> None: """Assert that the result is a RollbackResult instance.""" assert isinstance(context.plr_result, RollbackResult), ( f"Expected RollbackResult, got {type(context.plr_result)}" ) @then("plr-the checkpoint service selective_rollback should have been called") def step_plr_selective_rollback_called(context: Context) -> None: """Assert that CheckpointService.selective_rollback was called.""" context.plr_mock_checkpoint_svc.selective_rollback.assert_called_once_with( context.plr_plan_id, context.plr_checkpoint_id, ) @then("plr-the checkpoint service selective_rollback should NOT have been called") def step_plr_selective_rollback_not_called(context: Context) -> None: """Assert that CheckpointService.selective_rollback was NOT called.""" context.plr_mock_checkpoint_svc.selective_rollback.assert_not_called() @then('plr-a PlanError should be raised with "{message_fragment}"') def step_plr_plan_error_raised(context: Context, message_fragment: str) -> None: """Assert that a PlanError was raised containing the given message fragment.""" assert context.plr_raised_error is not None, ( f"Expected a PlanError to be raised but no error was raised. " f"Result was: {context.plr_result}" ) assert isinstance(context.plr_raised_error, PlanError), ( f"Expected PlanError, got {type(context.plr_raised_error)}: " f"{context.plr_raised_error}" ) error_message = str(context.plr_raised_error) assert message_fragment in error_message, ( f"Expected error message to contain '{message_fragment}', " f"got: '{error_message}'" ) @then("plr-a NotFoundError should be raised") def step_plr_not_found_error_raised(context: Context) -> None: """Assert that a NotFoundError was raised.""" assert context.plr_raised_error is not None, ( "Expected a NotFoundError to be raised but no error was raised." ) assert isinstance(context.plr_raised_error, NotFoundError), ( f"Expected NotFoundError, got {type(context.plr_raised_error)}: " f"{context.plr_raised_error}" ) @then("plr-a ResourceNotFoundError should be raised") def step_plr_resource_not_found_error_raised(context: Context) -> None: """Assert that a ResourceNotFoundError was raised.""" assert context.plr_raised_error is not None, ( "Expected a ResourceNotFoundError to be raised but no error was raised." ) assert isinstance(context.plr_raised_error, ResourceNotFoundError), ( f"Expected ResourceNotFoundError, got {type(context.plr_raised_error)}: " f"{context.plr_raised_error}" ) @then("plr-a BusinessRuleViolation should be raised") def step_plr_business_rule_violation_raised(context: Context) -> None: """Assert that a BusinessRuleViolation was raised.""" assert context.plr_raised_error is not None, ( "Expected a BusinessRuleViolation to be raised but no error was raised." ) assert isinstance(context.plr_raised_error, BusinessRuleViolation), ( f"Expected BusinessRuleViolation, got {type(context.plr_raised_error)}: " f"{context.plr_raised_error}" ) @then("plr-a PLAN_ROLLED_BACK domain event should have been emitted") def step_plr_plan_rolled_back_event_emitted(context: Context) -> None: """Assert that a PLAN_ROLLED_BACK event was emitted.""" emitted = context.plr_mock_event_bus.emitted_events rolled_back_events = [ e for e in emitted if e.event_type == EventType.PLAN_ROLLED_BACK ] assert len(rolled_back_events) >= 1, ( f"Expected at least one PLAN_ROLLED_BACK event, " f"got events: {[e.event_type for e in emitted]}" ) @then("plr-the event should contain the checkpoint_id") def step_plr_event_contains_checkpoint_id(context: Context) -> None: """Assert that the PLAN_ROLLED_BACK event details contain the checkpoint_id.""" emitted = context.plr_mock_event_bus.emitted_events rolled_back_events = [ e for e in emitted if e.event_type == EventType.PLAN_ROLLED_BACK ] assert len(rolled_back_events) >= 1, "No PLAN_ROLLED_BACK event found" event = rolled_back_events[0] details = event.details or {} assert "checkpoint_id" in details, ( f"Expected 'checkpoint_id' in event details, got: {details}" ) assert details["checkpoint_id"] == context.plr_checkpoint_id, ( f"Expected checkpoint_id '{context.plr_checkpoint_id}', " f"got '{details['checkpoint_id']}'" )