Files
cleveragents-core/features/steps/plan_lifecycle_rollback_steps.py
T
HAL9000 8c8cbb771a fix(plan-lifecycle): align rollback contract with spec
- update PlanLifecycleService documentation for rollback phase/state semantics\n- add Behave scenarios covering checkpoint error propagation and supporting steps\n- adjust mock helpers to raise configured exceptions and keep scenario count accurate\n\nISSUES CLOSED: #3677
2026-06-03 03:22:59 -04:00

433 lines
16 KiB
Python

"""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']}'"
)