Files
cleveragents-core/features/steps/event_bus_steps.py
T

555 lines
19 KiB
Python

"""Step definitions for event_bus.feature.
Tests EventType, DomainEvent, EventBus protocol, ReactiveEventBus,
LoggingEventBus, service event emission, and DI container registration.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from ulid import ULID
from cleveragents.application.container import Container
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.domain.models.core.decision import DecisionType
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState
from cleveragents.infrastructure.events import (
DomainEvent,
EventBus,
EventType,
LoggingEventBus,
ReactiveEventBus,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_event(event_type_str: str, **kwargs: Any) -> DomainEvent:
return DomainEvent(event_type=EventType(event_type_str), **kwargs)
class _EventCollector:
"""Simple in-test stub that collects emitted events."""
def __init__(self) -> None:
self.received: list[DomainEvent] = []
def __call__(self, event: DomainEvent) -> None:
self.received.append(event)
# ---------------------------------------------------------------------------
# EventType enum steps
# ---------------------------------------------------------------------------
@then('EventType should include "{value}"')
def step_event_type_includes(ctx: Context, value: str) -> None:
values = {e.value for e in EventType}
assert value in values, f"{value!r} not found in EventType values: {values}"
@then('EventType member {member} should equal string "{value}"')
def step_event_type_member_equals(ctx: Context, member: str, value: str) -> None:
et = EventType[member]
assert et == value, f"EventType.{member} = {et!r}, expected {value!r}"
# ---------------------------------------------------------------------------
# DomainEvent steps
# ---------------------------------------------------------------------------
@when('I create a minimal DomainEvent with event_type "{et}"')
def step_create_domain_event(ctx: Context, et: str) -> None:
ctx.domain_event = _make_event(et)
@when(
'I create a DomainEvent with event_type "{et}" plan_id "{pid}"'
' and details key "{key}" value "{val}"'
)
def step_create_domain_event_with_details(
ctx: Context, et: str, pid: str, key: str, val: str
) -> None:
ctx.domain_event = _make_event(et, plan_id=pid, details={key: val})
@then('the DomainEvent event_type should be "{et}"')
def step_domain_event_type(ctx: Context, et: str) -> None:
assert str(ctx.domain_event.event_type) == et
@then("the DomainEvent should have a non-empty correlation_id")
def step_domain_event_correlation_id(ctx: Context) -> None:
cid = ctx.domain_event.correlation_id
assert cid and len(cid) == 26, f"Expected 26-char ULID, got {cid!r}"
@then("the DomainEvent should have a timestamp")
def step_domain_event_timestamp(ctx: Context) -> None:
assert ctx.domain_event.timestamp is not None
@then("the DomainEvent plan_id should be None")
def step_domain_event_plan_id_none(ctx: Context) -> None:
assert ctx.domain_event.plan_id is None
@then("the DomainEvent actor_name should be None")
def step_domain_event_actor_name_none(ctx: Context) -> None:
assert ctx.domain_event.actor_name is None
@then("the DomainEvent details should be empty")
def step_domain_event_details_empty(ctx: Context) -> None:
assert ctx.domain_event.details == {}
@then('the DomainEvent plan_id should be "{pid}"')
def step_domain_event_plan_id(ctx: Context, pid: str) -> None:
assert ctx.domain_event.plan_id == pid
@then('the DomainEvent details should contain key "{key}"')
def step_domain_event_details_key(ctx: Context, key: str) -> None:
assert key in ctx.domain_event.details
@then("modifying the DomainEvent should raise an error")
def step_domain_event_immutable(ctx: Context) -> None:
raised = False
try:
ctx.domain_event.event_type = EventType.PLAN_CANCELLED # type: ignore[misc]
except Exception:
raised = True
assert raised, "Expected an exception when mutating a frozen DomainEvent"
# ---------------------------------------------------------------------------
# Protocol conformance
# ---------------------------------------------------------------------------
@then("ReactiveEventBus should satisfy the EventBus protocol")
def step_reactive_satisfies_protocol(ctx: Context) -> None:
bus = ReactiveEventBus()
assert isinstance(bus, EventBus), "ReactiveEventBus does not satisfy EventBus"
@then("LoggingEventBus should satisfy the EventBus protocol")
def step_logging_satisfies_protocol(ctx: Context) -> None:
bus = LoggingEventBus()
assert isinstance(bus, EventBus), "LoggingEventBus does not satisfy EventBus"
# ---------------------------------------------------------------------------
# ReactiveEventBus given
# ---------------------------------------------------------------------------
@given("a ReactiveEventBus")
def step_given_reactive_bus(ctx: Context) -> None:
ctx.bus = ReactiveEventBus()
ctx.collectors: list[_EventCollector] = []
@given("a LoggingEventBus")
def step_given_logging_bus(ctx: Context) -> None:
ctx.bus = LoggingEventBus()
ctx.collectors = []
# ---------------------------------------------------------------------------
# ReactiveEventBus / LoggingEventBus when
# ---------------------------------------------------------------------------
@when('I subscribe to "{et}" events')
def step_subscribe_single(ctx: Context, et: str) -> None:
collector = _EventCollector()
ctx.collectors.append(collector)
ctx.bus.subscribe(EventType(et), collector)
@when('I subscribe two handlers to "{et}" events')
def step_subscribe_two(ctx: Context, et: str) -> None:
for _ in range(2):
collector = _EventCollector()
ctx.collectors.append(collector)
ctx.bus.subscribe(EventType(et), collector)
@when('I unsubscribe from "{et}" with the same handler')
def step_unsubscribe_handler(ctx: Context, et: str) -> None:
collector = ctx.collectors[0]
ctx.unsubscribe_result = ctx.bus.unsubscribe(EventType(et), collector)
@when('I unsubscribe only the first handler from "{et}"')
def step_unsubscribe_first_handler(ctx: Context, et: str) -> None:
collector = ctx.collectors[0]
ctx.bus.unsubscribe(EventType(et), collector)
@when('I emit a "{et}" DomainEvent')
def step_emit_event(ctx: Context, et: str) -> None:
ctx.bus.emit(_make_event(et))
# ---------------------------------------------------------------------------
# ReactiveEventBus then
# ---------------------------------------------------------------------------
@then("the handler should have received {n:d} event")
def step_handler_received_n(ctx: Context, n: int) -> None:
assert ctx.collectors, "No collectors registered"
count = len(ctx.collectors[0].received)
assert count == n, f"Expected {n} events, got {count}"
@then('the received event type should be "{et}"')
def step_received_event_type(ctx: Context, et: str) -> None:
assert ctx.collectors[0].received, "No events received"
assert str(ctx.collectors[0].received[0].event_type) == et
@then("each handler should have received {n:d} event")
def step_each_handler_received(ctx: Context, n: int) -> None:
for i, col in enumerate(ctx.collectors):
assert len(col.received) == n, (
f"Handler {i} received {len(col.received)}, expected {n}"
)
@then("exactly one handler should have received 1 event")
def step_exactly_one_handler_received(ctx: Context) -> None:
counts = [len(col.received) for col in ctx.collectors]
assert sum(1 for c in counts if c == 1) == 1, (
f"Expected exactly one handler with 1 event, got {counts}"
)
@then('the handler count for "{et}" should be 0')
def step_handler_count_zero(ctx: Context, et: str) -> None:
actual = len(ctx.bus._subscriptions.get(EventType(et), []))
assert actual == 0, f"Expected 0 handlers for {et}, got {actual}"
@then("the bus should expose an observable stream")
def step_bus_has_stream(ctx: Context) -> None:
stream = ctx.bus.stream
assert stream is not None, "Expected a non-None observable stream"
@then("emitting a non-DomainEvent should raise TypeError")
def step_emit_non_domain_event(ctx: Context) -> None:
raised = False
try:
ctx.bus.emit("not-an-event") # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-DomainEvent"
@then("subscribing with a non-EventType should raise TypeError")
def step_subscribe_bad_type(ctx: Context) -> None:
raised = False
try:
ctx.bus.subscribe("plan.created", lambda e: None) # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-EventType event_type"
@then("subscribing with a non-callable handler should raise TypeError")
def step_subscribe_non_callable(ctx: Context) -> None:
raised = False
try:
ctx.bus.subscribe(EventType.PLAN_CREATED, "not-callable") # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-callable handler"
@then("unsubscribing should return True")
def step_unsubscribe_returns_true(ctx: Context) -> None:
result: bool = getattr(ctx, "unsubscribe_result", False)
assert result is True, f"Expected unsubscribe to return True, got {result}"
@then("unsubscribing a non-existent handler should return False")
def step_unsubscribe_nonexistent_returns_false(ctx: Context) -> None:
ctx.exception = None
try:
ctx.unsubscribe_result = ctx.bus.unsubscribe(
EventType.PLAN_CREATED, lambda e: None
)
except Exception as exc:
ctx.exception = exc
assert ctx.unsubscribe_result is False
@then("unsubscribing with a non-EventType should raise TypeError")
def step_unsubscribe_bad_type(ctx: Context) -> None:
raised = False
try:
ctx.bus.unsubscribe("plan.created", lambda e: None) # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-EventType event_type in unsubscribe"
@then("unsubscribing with a non-callable handler should raise TypeError")
def step_unsubscribe_non_callable(ctx: Context) -> None:
raised = False
try:
ctx.bus.unsubscribe(EventType.PLAN_CREATED, "not-callable") # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-callable handler in unsubscribe"
@then("the EventBus Protocol stubs should be callable")
def step_protocol_stubs_callable(ctx: Context) -> None:
"""Exercise Protocol method stubs directly to ensure 100% coverage."""
class _BareImpl(EventBus): # type: ignore[misc]
pass
obj = _BareImpl()
event = _make_event("plan.created")
obj.emit(event)
obj.subscribe(EventType.PLAN_CREATED, lambda e: None)
@then("the EventBus Protocol unsubscribe stub should be callable")
def step_protocol_unsubscribe_callable(ctx: Context) -> None:
"""Exercise Protocol unsubscribe stub directly to ensure coverage."""
class _BareImpl(EventBus): # type: ignore[misc]
pass
obj = _BareImpl()
obj.unsubscribe(EventType.PLAN_CREATED, lambda e: None)
# ---------------------------------------------------------------------------
# DecisionService event emission steps
# ---------------------------------------------------------------------------
@given("a DecisionService with an injected event bus")
def step_given_decision_service_with_bus(ctx: Context) -> None:
from unittest.mock import MagicMock, create_autospec
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
ctx.all_events: list[DomainEvent] = []
ctx.event_bus = ReactiveEventBus()
ctx.event_bus.subscribe(EventType.DECISION_CREATED, ctx.all_events.append)
mock_uow = create_autospec(UnitOfWork, instance=True)
mock_tx_ctx = MagicMock()
mock_tx_ctx.decisions = MagicMock()
mock_uow.transaction.return_value.__enter__.return_value = mock_tx_ctx
mock_uow.transaction.return_value.__exit__.return_value = False
ctx.decision_service = DecisionService(
settings=create_autospec(Settings, instance=True),
unit_of_work=mock_uow,
event_bus=ctx.event_bus,
)
ctx.test_plan_id = str(ULID())
@given("a DecisionService without an event bus")
def step_given_decision_service_no_bus(ctx: Context) -> None:
from unittest.mock import MagicMock, create_autospec
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
mock_uow = create_autospec(UnitOfWork, instance=True)
mock_tx_ctx = MagicMock()
mock_tx_ctx.decisions = MagicMock()
mock_uow.transaction.return_value.__enter__.return_value = mock_tx_ctx
mock_uow.transaction.return_value.__exit__.return_value = False
ctx.decision_service = DecisionService(
settings=create_autospec(Settings, instance=True),
unit_of_work=mock_uow,
)
ctx.test_plan_id = str(ULID())
@when("I call record_decision")
def step_call_record_decision(ctx: Context) -> None:
ctx.exception = None
try:
ctx.decision_service.record_decision(
plan_id=ctx.test_plan_id,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Which approach?",
chosen_option="TDD",
rationale="Test-first",
)
except Exception as exc:
ctx.exception = exc
@then('the event bus should have received a "{et}" event')
def step_event_bus_received(ctx: Context, et: str) -> None:
all_events: list[DomainEvent] = getattr(ctx, "all_events", [])
received_types = [str(e.event_type) for e in all_events]
assert et in received_types, (
f"Expected {et!r} in received events, got {received_types}"
)
@then("the emitted event plan_id should match the decision plan_id")
def step_emitted_plan_id_matches(ctx: Context) -> None:
all_events: list[DomainEvent] = getattr(ctx, "all_events", [])
assert all_events, "No events received"
emitted_plan_id = all_events[0].plan_id
assert emitted_plan_id == ctx.test_plan_id, (
f"Expected plan_id {ctx.test_plan_id!r}, got {emitted_plan_id!r}"
)
@then("no event bus exception should be raised")
def step_no_event_bus_exception(ctx: Context) -> None:
assert ctx.exception is None, f"Unexpected exception: {ctx.exception}"
# ---------------------------------------------------------------------------
# PlanLifecycleService event emission steps
# ---------------------------------------------------------------------------
def _make_settings_stub() -> MagicMock:
from unittest.mock import create_autospec
from cleveragents.config.settings import Settings
return create_autospec(Settings, instance=True)
@given("a PlanLifecycleService with an injected event bus")
def step_given_lifecycle_service_with_bus(ctx: Context) -> None:
ctx.all_events: list[DomainEvent] = []
ctx.event_bus = ReactiveEventBus()
ctx.event_bus.subscribe(EventType.PLAN_CREATED, ctx.all_events.append)
ctx.lifecycle_service = PlanLifecycleService(
settings=_make_settings_stub(),
event_bus=ctx.event_bus,
)
ctx.exception = None
@given("a PlanLifecycleService with an injected event bus and a strategized plan")
def step_given_lifecycle_with_strategized_plan(ctx: Context) -> None:
ctx.all_events: list[DomainEvent] = []
ctx.event_bus = ReactiveEventBus()
ctx.event_bus.subscribe(EventType.PLAN_PHASE_CHANGED, ctx.all_events.append)
ctx.lifecycle_service = PlanLifecycleService(
settings=_make_settings_stub(),
event_bus=ctx.event_bus,
)
ctx.exception = None
ctx.lifecycle_service.create_action(
name="test/action",
description="benchmark action",
definition_of_done="Done",
strategy_actor="local/strategist",
execution_actor="local/executor",
)
plan = ctx.lifecycle_service.use_action(action_name="test/action")
plan_id = plan.identity.plan_id
ctx.lifecycle_service._plans[plan_id] = plan.model_copy(
update={
"phase": PlanPhase.STRATEGIZE,
"processing_state": ProcessingState.COMPLETE,
}
)
ctx.test_plan_id = plan_id
@given("a PlanLifecycleService without an event bus")
def step_given_lifecycle_service_no_bus(ctx: Context) -> None:
ctx.lifecycle_service = PlanLifecycleService(
settings=_make_settings_stub(),
)
ctx.exception = None
@when("I call use_action to create a plan")
def step_call_use_action(ctx: Context) -> None:
try:
ctx.lifecycle_service.create_action(
name="test/action",
description="test action",
definition_of_done="Done when implemented",
strategy_actor="local/strategist",
execution_actor="local/executor",
)
ctx.lifecycle_service.use_action(action_name="test/action")
except Exception as exc:
ctx.exception = exc
@when("I call execute_plan")
def step_call_execute_plan(ctx: Context) -> None:
try:
ctx.lifecycle_service.execute_plan(ctx.test_plan_id)
except Exception as exc:
ctx.exception = exc
# ---------------------------------------------------------------------------
# DI container steps
# ---------------------------------------------------------------------------
@when("I resolve event_bus from the DI container twice")
def step_resolve_event_bus_twice(ctx: Context) -> None:
container = Container()
ctx.event_bus_a = container.event_bus()
ctx.event_bus_b = container.event_bus()
@then("both resolutions should return the same instance")
def step_same_instance(ctx: Context) -> None:
assert ctx.event_bus_a is ctx.event_bus_b, (
"event_bus Singleton returned different instances"
)
@when("I resolve decision_service from the DI container")
def step_resolve_decision_service(ctx: Context) -> None:
container = Container()
ctx.resolved_decision_service = container.decision_service()
@then("the decision_service should have an event_bus attribute")
def step_decision_service_has_event_bus(ctx: Context) -> None:
svc = ctx.resolved_decision_service
assert hasattr(svc, "event_bus"), (
"DecisionService resolved from DI has no event_bus attribute"
)
assert svc.event_bus is not None, "DecisionService.event_bus should not be None"