8c8cbb771a
- 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
433 lines
16 KiB
Python
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']}'"
|
|
)
|