65aa7a7842
CI / typecheck (push) Has been cancelled
CI / quality (push) Has been cancelled
CI / e2e_tests (push) Has been cancelled
CI / build (push) Has been cancelled
CI / integration_tests (push) Has been cancelled
CI / helm (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / security (push) Has been cancelled
CI / unit_tests (push) Has been cancelled
CI / status-check (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled
Enriches the PLAN_CANCELLED domain event emitted by PlanLifecycleService.cancel_plan() with additional context fields to improve audit trail quality (SEC7 Audit Logging). Progress context fields: cancelled_phase, cancelled_processing_state, last_completed_step, subplan_count Resource cleanup context fields: sandbox_refs, changeset_id, resources_pending_cleanup Existing fields (reason, project_names) preserved for backward compatibility. 15 BDD scenarios added covering all new fields, accuracy, and backward compatibility. Closes #717 Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me> Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
253 lines
9.2 KiB
Python
253 lines
9.2 KiB
Python
"""Step definitions for plan_cancelled_enrichment.feature.
|
|
|
|
Tests that the PLAN_CANCELLED domain event is enriched with:
|
|
- Progress context: cancelled_phase, cancelled_processing_state,
|
|
last_completed_step, subplan_count
|
|
- Resource cleanup context: sandbox_refs, changeset_id,
|
|
resources_pending_cleanup
|
|
- Cancellation reason (existing field, verified still present)
|
|
- Backward-compatible fields: project_names, reason
|
|
|
|
Related: Forgejo issue #717
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
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.domain.models.core.plan import ProjectLink
|
|
from cleveragents.infrastructure.events.types import EventType
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _RecordingBus:
|
|
"""Minimal event bus that records every emitted event."""
|
|
|
|
def __init__(self) -> None:
|
|
self.events: list = []
|
|
|
|
def emit(self, event) -> None:
|
|
self.events.append(event)
|
|
|
|
def subscribe(self, event_type, handler) -> None:
|
|
pass
|
|
|
|
|
|
def _get_cancelled_event(context: Context):
|
|
"""Return the first PLAN_CANCELLED event from the recording bus."""
|
|
events = [
|
|
e
|
|
for e in context.recording_bus.events
|
|
if e.event_type == EventType.PLAN_CANCELLED
|
|
]
|
|
assert events, (
|
|
f"No PLAN_CANCELLED event found. "
|
|
f"All events: {[e.event_type for e in context.recording_bus.events]}"
|
|
)
|
|
return events[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("I have a plan lifecycle service for cancelled enrichment tests")
|
|
def step_create_service_for_enrichment(context: Context) -> None:
|
|
"""Create a PlanLifecycleService with a recording event bus."""
|
|
Settings._instance = None
|
|
settings = Settings()
|
|
context.recording_bus = _RecordingBus()
|
|
context.service = PlanLifecycleService(
|
|
settings=settings,
|
|
event_bus=context.recording_bus,
|
|
)
|
|
context.error = None
|
|
context.plan = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given: action and plan creation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('an action "{action_name}" exists for enrichment tests')
|
|
def step_create_action_for_enrichment(context: Context, action_name: str) -> None:
|
|
"""Create an action with the given namespaced name."""
|
|
context.action = context.service.create_action(
|
|
name=action_name,
|
|
description=f"Action {action_name} for enrichment tests",
|
|
definition_of_done="Tests pass",
|
|
strategy_actor="openai/gpt-4",
|
|
execution_actor="openai/gpt-4",
|
|
)
|
|
|
|
|
|
@given('a plan created from "{action_name}" for enrichment tests')
|
|
def step_create_plan_for_enrichment(context: Context, action_name: str) -> None:
|
|
"""Create a plan from the given action."""
|
|
context.plan = context.service.use_action(
|
|
action_name=action_name,
|
|
project_links=[ProjectLink(project_name="test-project")],
|
|
)
|
|
|
|
|
|
@given('a plan with sandbox refs created from "{action_name}"')
|
|
def step_create_plan_with_sandbox_refs(context: Context, action_name: str) -> None:
|
|
"""Create a plan and inject sandbox refs to simulate active sandboxes."""
|
|
plan = context.service.use_action(
|
|
action_name=action_name,
|
|
project_links=[ProjectLink(project_name="test-project")],
|
|
)
|
|
# Inject sandbox refs directly (model is frozen, use model_copy)
|
|
updated_plan = plan.model_copy(
|
|
update={"sandbox_refs": ["sandbox-001", "sandbox-002"]}
|
|
)
|
|
# Update the in-memory store so cancel_plan retrieves the updated plan
|
|
context.service._plans[updated_plan.identity.plan_id] = updated_plan
|
|
context.plan = updated_plan
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When: cancel the plan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I cancel the plan for enrichment tests with reason "{reason}"')
|
|
def step_cancel_plan_with_reason(context: Context, reason: str) -> None:
|
|
"""Cancel the plan with the given reason."""
|
|
context.error = None
|
|
try:
|
|
with patch(
|
|
"cleveragents.application.services.cleanup_service"
|
|
".stop_all_active_containers",
|
|
return_value=[],
|
|
):
|
|
context.plan = context.service.cancel_plan(
|
|
context.plan.identity.plan_id,
|
|
reason=reason,
|
|
)
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("I cancel the plan for enrichment tests without a reason")
|
|
def step_cancel_plan_without_reason(context: Context) -> None:
|
|
"""Cancel the plan without providing a reason."""
|
|
context.error = None
|
|
try:
|
|
with patch(
|
|
"cleveragents.application.services.cleanup_service"
|
|
".stop_all_active_containers",
|
|
return_value=[],
|
|
):
|
|
context.plan = context.service.cancel_plan(
|
|
context.plan.identity.plan_id,
|
|
)
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then: assertions on event details
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the PLAN_CANCELLED event details should include "{field}"')
|
|
def step_event_details_include_field(context: Context, field: str) -> None:
|
|
"""Assert that the PLAN_CANCELLED event details dict contains the field."""
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
event = _get_cancelled_event(context)
|
|
assert field in event.details, (
|
|
f"Expected field '{field}' in PLAN_CANCELLED event details. "
|
|
f"Actual details keys: {list(event.details.keys())}"
|
|
)
|
|
|
|
|
|
@then('the PLAN_CANCELLED event details should have reason "{expected_reason}"')
|
|
def step_event_details_reason(context: Context, expected_reason: str) -> None:
|
|
"""Assert the reason field in the event details matches expected value."""
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
event = _get_cancelled_event(context)
|
|
actual = event.details.get("reason")
|
|
assert actual == expected_reason, (
|
|
f"Expected reason '{expected_reason}', got '{actual}'"
|
|
)
|
|
|
|
|
|
@then("the PLAN_CANCELLED event reason field should be empty string")
|
|
def step_event_details_reason_empty(context: Context) -> None:
|
|
"""Assert the reason field in the event details is an empty string."""
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
event = _get_cancelled_event(context)
|
|
actual = event.details.get("reason")
|
|
assert actual == "", f"Expected empty string reason, got '{actual}'"
|
|
|
|
|
|
@then('the PLAN_CANCELLED event details should have cancelled_phase "{expected_phase}"')
|
|
def step_event_details_cancelled_phase(context: Context, expected_phase: str) -> None:
|
|
"""Assert the cancelled_phase field matches the expected phase string."""
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
event = _get_cancelled_event(context)
|
|
actual = event.details.get("cancelled_phase")
|
|
assert actual == expected_phase, (
|
|
f"Expected cancelled_phase '{expected_phase}', got '{actual}'"
|
|
)
|
|
|
|
|
|
@then(
|
|
"the PLAN_CANCELLED event details should have last_completed_step {expected_step:d}"
|
|
)
|
|
def step_event_details_last_completed_step(
|
|
context: Context, expected_step: int
|
|
) -> None:
|
|
"""Assert the last_completed_step field matches the expected value."""
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
event = _get_cancelled_event(context)
|
|
actual = event.details.get("last_completed_step")
|
|
assert actual == expected_step, (
|
|
f"Expected last_completed_step {expected_step}, got {actual}"
|
|
)
|
|
|
|
|
|
@then(
|
|
"the PLAN_CANCELLED event details should have sandbox_refs count {expected_count:d}"
|
|
)
|
|
def step_event_details_sandbox_refs_count(
|
|
context: Context, expected_count: int
|
|
) -> None:
|
|
"""Assert the sandbox_refs list has the expected number of entries."""
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
event = _get_cancelled_event(context)
|
|
sandbox_refs = event.details.get("sandbox_refs", [])
|
|
actual_count = len(sandbox_refs)
|
|
assert actual_count == expected_count, (
|
|
f"Expected {expected_count} sandbox_refs, got {actual_count}: {sandbox_refs}"
|
|
)
|
|
|
|
|
|
@then(
|
|
"the PLAN_CANCELLED event details should have resources_pending_cleanup"
|
|
" {expected_count:d}"
|
|
)
|
|
def step_event_details_resources_pending_cleanup(
|
|
context: Context, expected_count: int
|
|
) -> None:
|
|
"""Assert the resources_pending_cleanup count matches expected value."""
|
|
assert context.error is None, f"Unexpected error: {context.error}"
|
|
event = _get_cancelled_event(context)
|
|
actual = event.details.get("resources_pending_cleanup")
|
|
assert actual == expected_count, (
|
|
f"Expected resources_pending_cleanup {expected_count}, got {actual}"
|
|
)
|