forked from HAL9000/cleveragents-core
c2097ee2ed
Enriches the PLAN_APPLIED domain event with SEC7 audit-logging statistics per docs/specification.md §Audit Logging (issue #716). Changes: - PlanLifecycleService.complete_apply() now accepts optional keyword arguments: files_changed, lines_added, lines_removed, resources_modified, apply_duration_seconds (all default to 0). These are included in the PLAN_APPLIED event details dict alongside the existing action_name, phase, and project_names fields. - PlanApplyService.apply_with_validation_gate() computes changeset statistics from SpecChangeSet.summary() and measures apply duration, then passes all statistics to complete_apply(). - New BDD feature features/plan_applied_event_enrichment.feature with 13 scenarios. Closes #716 Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me> Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
490 lines
18 KiB
Python
490 lines
18 KiB
Python
"""Step definitions for PLAN_APPLIED event enrichment with changeset statistics.
|
|
|
|
Tests that:
|
|
1. ``PlanLifecycleService.complete_apply`` accepts changeset statistics kwargs
|
|
and includes them in the emitted ``PLAN_APPLIED`` domain event.
|
|
2. ``PlanApplyService.apply_with_validation_gate`` computes and passes
|
|
changeset statistics to ``complete_apply``.
|
|
|
|
Based on issue #716 (SEC7 audit logging enrichment).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import structlog
|
|
from behave import given, then, when # type: ignore[import-untyped]
|
|
from behave.runner import Context # type: ignore[import-untyped]
|
|
|
|
from cleveragents.application.services.plan_apply_service import (
|
|
PlanApplyService,
|
|
)
|
|
from cleveragents.application.services.plan_lifecycle_service import (
|
|
PlanLifecycleService,
|
|
)
|
|
from cleveragents.domain.models.core.change import (
|
|
ChangeEntry,
|
|
ChangeOperation,
|
|
SpecChangeSet,
|
|
)
|
|
from cleveragents.domain.models.core.plan import (
|
|
PlanPhase,
|
|
PlanTimestamps,
|
|
ProcessingState,
|
|
)
|
|
from cleveragents.infrastructure.events.models import DomainEvent
|
|
from cleveragents.infrastructure.events.types import EventType
|
|
|
|
__all__: list[str] = []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PLAN_ID = "01ENRICHTEST00000000001"
|
|
_CHANGESET_ID = "01CSENRICH000000000001"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_mock_plan(
|
|
*,
|
|
plan_id: str = _PLAN_ID,
|
|
phase: PlanPhase = PlanPhase.APPLY,
|
|
state: ProcessingState = ProcessingState.PROCESSING,
|
|
changeset_id: str | None = _CHANGESET_ID,
|
|
action_name: str = "test-action",
|
|
project_links: list[Any] | None = None,
|
|
created_by: str = "test-actor",
|
|
validation_summary: dict[str, Any] | None = None,
|
|
is_terminal: bool = False,
|
|
error_details: dict[str, str] | None = None,
|
|
) -> MagicMock:
|
|
"""Create a mock Plan object for enrichment tests."""
|
|
plan = MagicMock()
|
|
plan.identity.plan_id = plan_id
|
|
plan.phase = phase
|
|
plan.processing_state = state
|
|
plan.state = state
|
|
plan.changeset_id = changeset_id
|
|
plan.action_name = action_name
|
|
plan.project_links = project_links or []
|
|
plan.created_by = created_by
|
|
plan.validation_summary = validation_summary
|
|
plan.is_terminal = is_terminal
|
|
plan.error_details = error_details
|
|
plan.timestamps = PlanTimestamps()
|
|
return plan
|
|
|
|
|
|
def _make_changeset(
|
|
plan_id: str = _PLAN_ID,
|
|
n_modify: int = 0,
|
|
n_create: int = 0,
|
|
n_delete: int = 0,
|
|
resource_ids: list[str] | None = None,
|
|
) -> SpecChangeSet:
|
|
"""Create a SpecChangeSet with specified entry counts."""
|
|
cs = SpecChangeSet(plan_id=plan_id, changeset_id=_CHANGESET_ID)
|
|
idx = 0
|
|
|
|
def _res(i: int) -> str:
|
|
if resource_ids:
|
|
return resource_ids[i % len(resource_ids)]
|
|
return f"RES{i:03d}"
|
|
|
|
for i in range(n_create):
|
|
cs.add_change(
|
|
ChangeEntry(
|
|
plan_id=plan_id,
|
|
resource_id=_res(idx),
|
|
tool_name="builtin/test",
|
|
operation=ChangeOperation.CREATE,
|
|
path=f"src/new_file_{i}.py",
|
|
after_hash=f"after-{i}",
|
|
)
|
|
)
|
|
idx += 1
|
|
|
|
for i in range(n_modify):
|
|
cs.add_change(
|
|
ChangeEntry(
|
|
plan_id=plan_id,
|
|
resource_id=_res(idx),
|
|
tool_name="builtin/test",
|
|
operation=ChangeOperation.MODIFY,
|
|
path=f"src/file_{i}.py",
|
|
before_hash=f"before-{i}",
|
|
after_hash=f"after-{i}",
|
|
)
|
|
)
|
|
idx += 1
|
|
|
|
for i in range(n_delete):
|
|
cs.add_change(
|
|
ChangeEntry(
|
|
plan_id=plan_id,
|
|
resource_id=_res(idx),
|
|
tool_name="builtin/test",
|
|
operation=ChangeOperation.DELETE,
|
|
path=f"src/old_file_{i}.py",
|
|
before_hash=f"before-del-{i}",
|
|
)
|
|
)
|
|
idx += 1
|
|
|
|
return cs
|
|
|
|
|
|
def _build_lifecycle_with_capturing_bus(
|
|
plan: MagicMock,
|
|
) -> tuple[MagicMock, list[DomainEvent]]:
|
|
"""Build a lifecycle mock that calls the real complete_apply and captures events.
|
|
|
|
Returns:
|
|
Tuple of (lifecycle_mock, emitted_events_list).
|
|
"""
|
|
emitted_events: list[DomainEvent] = []
|
|
|
|
event_bus = MagicMock()
|
|
event_bus.emit.side_effect = lambda e: emitted_events.append(e)
|
|
|
|
# We need a lifecycle object that has all the attributes complete_apply
|
|
# accesses, but calls the real implementation.
|
|
lifecycle = MagicMock(spec=PlanLifecycleService)
|
|
lifecycle.event_bus = event_bus
|
|
lifecycle.get_plan.return_value = plan
|
|
lifecycle._commit_plan = MagicMock()
|
|
lifecycle._cleanup_devcontainers = MagicMock()
|
|
lifecycle._logger = structlog.get_logger("test.lifecycle")
|
|
|
|
# Bind the real complete_apply to this mock instance
|
|
def _real_complete_apply(plan_id: str, **kwargs: Any) -> Any:
|
|
return PlanLifecycleService.complete_apply(lifecycle, plan_id, **kwargs)
|
|
|
|
lifecycle.complete_apply = _real_complete_apply
|
|
|
|
return lifecycle, emitted_events
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan applied event enrichment test environment")
|
|
def step_enrichment_env(context: Context) -> None:
|
|
"""Initialise shared context for enrichment tests."""
|
|
context.emitted_events = []
|
|
context.complete_apply_calls = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps — lifecycle service scenarios
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan in Apply/PROCESSING state")
|
|
def step_plan_apply_processing(context: Context) -> None:
|
|
"""Set up a plan in Apply/PROCESSING state with a capturing event bus."""
|
|
context.plan = _make_mock_plan()
|
|
lifecycle, emitted_events = _build_lifecycle_with_capturing_bus(context.plan)
|
|
context.lifecycle = lifecycle
|
|
context.emitted_events = emitted_events
|
|
|
|
|
|
@given('a plan in Apply/PROCESSING state with action_name "{action_name}"')
|
|
def step_plan_apply_processing_with_action(context: Context, action_name: str) -> None:
|
|
"""Set up a plan in Apply/PROCESSING state with a specific action_name."""
|
|
context.plan = _make_mock_plan(action_name=action_name)
|
|
lifecycle, emitted_events = _build_lifecycle_with_capturing_bus(context.plan)
|
|
context.lifecycle = lifecycle
|
|
context.emitted_events = emitted_events
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps — apply_with_validation_gate scenarios
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan with changeset containing {n:d} entries")
|
|
def step_plan_with_n_entries(context: Context, n: int) -> None:
|
|
"""Set up a plan with a changeset of n MODIFY entries."""
|
|
context.plan = _make_mock_plan()
|
|
context.changeset = _make_changeset(n_modify=n)
|
|
context.complete_apply_kwargs = {}
|
|
context.apply_result = None
|
|
|
|
|
|
@given("a plan with changeset containing entries for {n:d} distinct resources")
|
|
def step_plan_with_n_resources(context: Context, n: int) -> None:
|
|
"""Set up a plan with a changeset spanning n distinct resources."""
|
|
resource_ids = [f"RES{i:03d}" for i in range(n)]
|
|
context.plan = _make_mock_plan()
|
|
context.changeset = _make_changeset(
|
|
n_modify=n,
|
|
resource_ids=resource_ids,
|
|
)
|
|
context.complete_apply_kwargs = {}
|
|
context.apply_result = None
|
|
|
|
|
|
@given("a plan with changeset containing {n:d} CREATE entries")
|
|
def step_plan_with_n_creates(context: Context, n: int) -> None:
|
|
"""Set up a plan with a changeset of n CREATE entries."""
|
|
context.plan = _make_mock_plan()
|
|
context.changeset = _make_changeset(n_create=n)
|
|
context.complete_apply_kwargs = {}
|
|
context.apply_result = None
|
|
|
|
|
|
@given("a plan with changeset containing {n:d} DELETE entries")
|
|
def step_plan_with_n_deletes(context: Context, n: int) -> None:
|
|
"""Set up a plan with a changeset of n DELETE entries."""
|
|
context.plan = _make_mock_plan()
|
|
context.changeset = _make_changeset(n_delete=n)
|
|
context.complete_apply_kwargs = {}
|
|
context.apply_result = None
|
|
|
|
|
|
@given("the plan has no validation failures")
|
|
def step_no_validation_failures(context: Context) -> None:
|
|
"""Ensure the plan has no validation failures."""
|
|
context.plan.validation_summary = {
|
|
"required_passed": 0,
|
|
"required_failed": 0,
|
|
"total": 0,
|
|
}
|
|
context.plan.is_terminal = False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — lifecycle service scenarios
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("complete_apply is called with files_changed={n:d}")
|
|
def step_complete_apply_files_changed(context: Context, n: int) -> None:
|
|
"""Call complete_apply with files_changed kwarg."""
|
|
context.lifecycle.complete_apply(_PLAN_ID, files_changed=n)
|
|
|
|
|
|
@when("complete_apply is called with lines_added={n:d}")
|
|
def step_complete_apply_lines_added(context: Context, n: int) -> None:
|
|
"""Call complete_apply with lines_added kwarg."""
|
|
context.lifecycle.complete_apply(_PLAN_ID, lines_added=n)
|
|
|
|
|
|
@when("complete_apply is called with lines_removed={n:d}")
|
|
def step_complete_apply_lines_removed(context: Context, n: int) -> None:
|
|
"""Call complete_apply with lines_removed kwarg."""
|
|
context.lifecycle.complete_apply(_PLAN_ID, lines_removed=n)
|
|
|
|
|
|
@when("complete_apply is called with resources_modified={n:d}")
|
|
def step_complete_apply_resources_modified(context: Context, n: int) -> None:
|
|
"""Call complete_apply with resources_modified kwarg."""
|
|
context.lifecycle.complete_apply(_PLAN_ID, resources_modified=n)
|
|
|
|
|
|
@when("complete_apply is called with apply_duration_seconds={v:f}")
|
|
def step_complete_apply_duration(context: Context, v: float) -> None:
|
|
"""Call complete_apply with apply_duration_seconds kwarg."""
|
|
context.lifecycle.complete_apply(_PLAN_ID, apply_duration_seconds=v)
|
|
|
|
|
|
@when("complete_apply is called with all changeset statistics")
|
|
def step_complete_apply_all_stats(context: Context) -> None:
|
|
"""Call complete_apply with all changeset statistics kwargs."""
|
|
context.lifecycle.complete_apply(
|
|
_PLAN_ID,
|
|
files_changed=10,
|
|
lines_added=200,
|
|
lines_removed=50,
|
|
resources_modified=4,
|
|
apply_duration_seconds=2.75,
|
|
)
|
|
context.expected_stats = {
|
|
"files_changed": 10,
|
|
"lines_added": 200,
|
|
"lines_removed": 50,
|
|
"resources_modified": 4,
|
|
"apply_duration_seconds": 2.75,
|
|
}
|
|
|
|
|
|
@when("complete_apply is called with no changeset statistics")
|
|
def step_complete_apply_no_stats(context: Context) -> None:
|
|
"""Call complete_apply with no changeset statistics (defaults)."""
|
|
context.lifecycle.complete_apply(_PLAN_ID)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — apply_with_validation_gate scenarios
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("apply_with_validation_gate is called")
|
|
def step_apply_with_validation_gate(context: Context) -> None:
|
|
"""Call apply_with_validation_gate and capture complete_apply kwargs."""
|
|
plan = context.plan
|
|
changeset = context.changeset
|
|
|
|
lifecycle = MagicMock()
|
|
lifecycle.get_plan.return_value = plan
|
|
lifecycle._commit_plan = MagicMock()
|
|
|
|
captured_kwargs: dict[str, Any] = {}
|
|
|
|
def _capture_complete_apply(plan_id: str, **kwargs: Any) -> MagicMock:
|
|
captured_kwargs.update(kwargs)
|
|
return plan
|
|
|
|
lifecycle.complete_apply.side_effect = _capture_complete_apply
|
|
|
|
store = MagicMock()
|
|
store.get.return_value = changeset
|
|
|
|
service = PlanApplyService(
|
|
lifecycle_service=lifecycle,
|
|
changeset_store=store,
|
|
)
|
|
|
|
context.apply_result = service.apply_with_validation_gate(plan.identity.plan_id)
|
|
context.complete_apply_kwargs = captured_kwargs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — event details assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_plan_applied_event(context: Context) -> DomainEvent:
|
|
"""Return the first PLAN_APPLIED event from the captured list."""
|
|
events = [
|
|
e for e in context.emitted_events if e.event_type == EventType.PLAN_APPLIED
|
|
]
|
|
assert events, "No PLAN_APPLIED event was emitted"
|
|
return events[0]
|
|
|
|
|
|
@then("the emitted PLAN_APPLIED event details should include files_changed={n:d}")
|
|
def step_assert_files_changed(context: Context, n: int) -> None:
|
|
"""Assert files_changed in PLAN_APPLIED event details."""
|
|
event = _get_plan_applied_event(context)
|
|
assert event.details.get("files_changed") == n, (
|
|
f"Expected files_changed={n}, got {event.details.get('files_changed')!r}"
|
|
)
|
|
|
|
|
|
@then("the emitted PLAN_APPLIED event details should include lines_added={n:d}")
|
|
def step_assert_lines_added(context: Context, n: int) -> None:
|
|
"""Assert lines_added in PLAN_APPLIED event details."""
|
|
event = _get_plan_applied_event(context)
|
|
assert event.details.get("lines_added") == n, (
|
|
f"Expected lines_added={n}, got {event.details.get('lines_added')!r}"
|
|
)
|
|
|
|
|
|
@then("the emitted PLAN_APPLIED event details should include lines_removed={n:d}")
|
|
def step_assert_lines_removed(context: Context, n: int) -> None:
|
|
"""Assert lines_removed in PLAN_APPLIED event details."""
|
|
event = _get_plan_applied_event(context)
|
|
assert event.details.get("lines_removed") == n, (
|
|
f"Expected lines_removed={n}, got {event.details.get('lines_removed')!r}"
|
|
)
|
|
|
|
|
|
@then("the emitted PLAN_APPLIED event details should include resources_modified={n:d}")
|
|
def step_assert_resources_modified(context: Context, n: int) -> None:
|
|
"""Assert resources_modified in PLAN_APPLIED event details."""
|
|
event = _get_plan_applied_event(context)
|
|
assert event.details.get("resources_modified") == n, (
|
|
f"Expected resources_modified={n}, got {event.details.get('resources_modified')!r}"
|
|
)
|
|
|
|
|
|
@then(
|
|
"the emitted PLAN_APPLIED event details should include apply_duration_seconds={v:f}"
|
|
)
|
|
def step_assert_apply_duration(context: Context, v: float) -> None:
|
|
"""Assert apply_duration_seconds in PLAN_APPLIED event details."""
|
|
event = _get_plan_applied_event(context)
|
|
actual = event.details.get("apply_duration_seconds")
|
|
assert actual == v, f"Expected apply_duration_seconds={v}, got {actual!r}"
|
|
|
|
|
|
@then("the emitted PLAN_APPLIED event details should include all changeset statistics")
|
|
def step_assert_all_stats(context: Context) -> None:
|
|
"""Assert all changeset statistics are present in PLAN_APPLIED event details."""
|
|
event = _get_plan_applied_event(context)
|
|
expected = context.expected_stats
|
|
for key, value in expected.items():
|
|
actual = event.details.get(key)
|
|
assert actual == value, f"Expected {key}={value}, got {actual!r}"
|
|
|
|
|
|
@then(
|
|
'the emitted PLAN_APPLIED event details should include action_name "{action_name}"'
|
|
)
|
|
def step_assert_action_name(context: Context, action_name: str) -> None:
|
|
"""Assert action_name is preserved in PLAN_APPLIED event details."""
|
|
event = _get_plan_applied_event(context)
|
|
assert event.details.get("action_name") == action_name, (
|
|
f"Expected action_name={action_name!r}, got {event.details.get('action_name')!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps — complete_apply call assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("complete_apply should be called with files_changed={n:d}")
|
|
def step_assert_complete_apply_files_changed(context: Context, n: int) -> None:
|
|
"""Assert complete_apply was called with files_changed=n."""
|
|
actual = context.complete_apply_kwargs.get("files_changed")
|
|
assert actual == n, (
|
|
f"Expected complete_apply called with files_changed={n}, got {actual!r}"
|
|
)
|
|
|
|
|
|
@then("complete_apply should be called with resources_modified={n:d}")
|
|
def step_assert_complete_apply_resources_modified(context: Context, n: int) -> None:
|
|
"""Assert complete_apply was called with resources_modified=n."""
|
|
actual = context.complete_apply_kwargs.get("resources_modified")
|
|
assert actual == n, (
|
|
f"Expected complete_apply called with resources_modified={n}, got {actual!r}"
|
|
)
|
|
|
|
|
|
@then("complete_apply should be called with a non-negative apply_duration_seconds")
|
|
def step_assert_complete_apply_duration_nonneg(context: Context) -> None:
|
|
"""Assert complete_apply was called with a non-negative apply_duration_seconds."""
|
|
actual = context.complete_apply_kwargs.get("apply_duration_seconds")
|
|
assert actual is not None, "apply_duration_seconds was not passed to complete_apply"
|
|
assert actual >= 0.0, f"Expected apply_duration_seconds >= 0.0, got {actual!r}"
|
|
|
|
|
|
@then("complete_apply should be called with lines_added={n:d}")
|
|
def step_assert_complete_apply_lines_added(context: Context, n: int) -> None:
|
|
"""Assert complete_apply was called with lines_added=n."""
|
|
actual = context.complete_apply_kwargs.get("lines_added")
|
|
assert actual == n, (
|
|
f"Expected complete_apply called with lines_added={n}, got {actual!r}"
|
|
)
|
|
|
|
|
|
@then("complete_apply should be called with lines_removed={n:d}")
|
|
def step_assert_complete_apply_lines_removed(context: Context, n: int) -> None:
|
|
"""Assert complete_apply was called with lines_removed=n."""
|
|
actual = context.complete_apply_kwargs.get("lines_removed")
|
|
assert actual == n, (
|
|
f"Expected complete_apply called with lines_removed={n}, got {actual!r}"
|
|
)
|