fix(events): add unsubscribe() to EventBus protocol and implementations
This commit is contained in:
+62
-23
@@ -118,6 +118,68 @@ Feature: EventBus protocol and domain event emission
|
||||
Scenario: EventBus Protocol emit and subscribe stubs are executable
|
||||
Then the EventBus Protocol stubs should be callable
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EventBus unsubscribe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: ReactiveEventBus.unsubscribe() removes a handler
|
||||
Given a ReactiveEventBus
|
||||
When I subscribe to "plan.created" events
|
||||
And I unsubscribe from "plan.created" with the same handler
|
||||
Then the handler count for "plan.created" should be 0
|
||||
|
||||
Scenario: ReactiveEventBus.unsubscribe() returns True when found
|
||||
Given a ReactiveEventBus
|
||||
When I subscribe to "plan.created" events
|
||||
And I unsubscribe from "plan.created" with the same handler
|
||||
Then unsubscribing should return True
|
||||
|
||||
Scenario: ReactiveEventBus.unsubscribe() returns False when not found
|
||||
Given a ReactiveEventBus
|
||||
Then unsubscribing a non-existent handler should return False
|
||||
|
||||
Scenario: ReactiveEventBus.unsubscribe() rejects invalid event_type
|
||||
Given a ReactiveEventBus
|
||||
Then unsubscribing with a non-EventType should raise TypeError
|
||||
|
||||
Scenario: ReactiveEventBus.unsubscribe() rejects non-callable handler
|
||||
Given a ReactiveEventBus
|
||||
Then unsubscribing with a non-callable handler should raise TypeError
|
||||
|
||||
Scenario: LoggingEventBus.unsubscribe() removes a handler
|
||||
Given a LoggingEventBus
|
||||
When I subscribe to "decision.created" events
|
||||
And I unsubscribe from "decision.created" with the same handler
|
||||
Then the handler count for "decision.created" should be 0
|
||||
|
||||
Scenario: LoggingEventBus.unsubscribe() returns True when found
|
||||
Given a LoggingEventBus
|
||||
When I subscribe to "decision.created" events
|
||||
And I unsubscribe from "decision.created" with the same handler
|
||||
Then unsubscribing should return True
|
||||
|
||||
Scenario: LoggingEventBus.unsubscribe() returns False when not found
|
||||
Given a LoggingEventBus
|
||||
Then unsubscribing a non-existent handler should return False
|
||||
|
||||
Scenario: LoggingEventBus.unsubscribe() rejects invalid event_type
|
||||
Given a LoggingEventBus
|
||||
Then unsubscribing with a non-EventType should raise TypeError
|
||||
|
||||
Scenario: LoggingEventBus.unsubscribe() rejects non-callable handler
|
||||
Given a LoggingEventBus
|
||||
Then unsubscribing with a non-callable handler should raise TypeError
|
||||
|
||||
Scenario: EventBus Protocol unsubscribe stub is callable
|
||||
Then the EventBus Protocol unsubscribe stub should be callable
|
||||
|
||||
Scenario: Event bus supports selective unsubscription (only specific handler removed)
|
||||
Given a ReactiveEventBus
|
||||
When I subscribe two handlers to "plan.created" events
|
||||
And I unsubscribe only the first handler from "plan.created"
|
||||
And I emit a "plan.created" DomainEvent
|
||||
Then exactly one handler should have received 1 event
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DecisionService event emission
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -163,26 +225,3 @@ Feature: EventBus protocol and domain event emission
|
||||
Scenario: Container wires event_bus into DecisionService
|
||||
When I resolve decision_service from the DI container
|
||||
Then the decision_service should have an event_bus attribute
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ReactiveEventBus.close() and context manager (issue #10378)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: ReactiveEventBus.close() completes the RxPY stream
|
||||
Given a ReactiveEventBus
|
||||
When bus.close() is called on the bus
|
||||
Then the bus should be marked as closed
|
||||
|
||||
Scenario: ReactiveEventBus.close() prevents further emit() calls
|
||||
Given a ReactiveEventBus
|
||||
When bus.close() is called on the bus
|
||||
Then emitting after close should raise RuntimeError
|
||||
|
||||
Scenario: ReactiveEventBus.close() is idempotent
|
||||
Given a ReactiveEventBus
|
||||
When bus.close() is called twice on the bus
|
||||
Then no exception should be raised on double close
|
||||
|
||||
Scenario: ReactiveEventBus supports context manager protocol
|
||||
Given a ReactiveEventBus used as a context manager
|
||||
Then the bus should be closed after the context exits
|
||||
|
||||
@@ -189,6 +189,18 @@ def step_subscribe_two(ctx: Context, et: str) -> None:
|
||||
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))
|
||||
@@ -220,6 +232,20 @@ def step_each_handler_received(ctx: Context, n: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
@@ -256,6 +282,44 @@ def step_subscribe_non_callable(ctx: Context) -> None:
|
||||
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."""
|
||||
@@ -269,6 +333,17 @@ def step_protocol_stubs_callable(ctx: Context) -> None:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -477,56 +552,3 @@ def step_decision_service_has_event_bus(ctx: Context) -> None:
|
||||
"DecisionService resolved from DI has no event_bus attribute"
|
||||
)
|
||||
assert svc.event_bus is not None, "DecisionService.event_bus should not be None"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ReactiveEventBus.close() and context manager steps (issue #10378)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("bus.close() is called on the bus")
|
||||
def step_close_bus(ctx: Context) -> None:
|
||||
ctx.bus.close()
|
||||
|
||||
|
||||
@then("the bus should be marked as closed")
|
||||
def step_bus_is_closed(ctx: Context) -> None:
|
||||
assert ctx.bus._closed, "Expected bus._closed to be True after close()"
|
||||
|
||||
|
||||
@then("emitting after close should raise RuntimeError")
|
||||
def step_emit_after_close_raises(ctx: Context) -> None:
|
||||
from cleveragents.infrastructure.events.models import DomainEvent
|
||||
from cleveragents.infrastructure.events.types import EventType
|
||||
|
||||
raised = False
|
||||
try:
|
||||
ctx.bus.emit(DomainEvent(event_type=EventType.PLAN_CREATED))
|
||||
except RuntimeError:
|
||||
raised = True
|
||||
assert raised, "Expected RuntimeError when emitting after close()"
|
||||
|
||||
|
||||
@when("bus.close() is called twice on the bus")
|
||||
def step_close_bus_twice(ctx: Context) -> None:
|
||||
ctx.bus.close()
|
||||
ctx.bus.close() # should not raise
|
||||
|
||||
|
||||
@then("no exception should be raised on double close")
|
||||
def step_no_exception_on_double_close(ctx: Context) -> None:
|
||||
# If we reached here without exception, the test passes
|
||||
pass
|
||||
|
||||
|
||||
@given("a ReactiveEventBus used as a context manager")
|
||||
def step_given_bus_context_manager(ctx: Context) -> None:
|
||||
ctx.bus = ReactiveEventBus()
|
||||
ctx.cm_bus: ReactiveEventBus | None = None
|
||||
with ctx.bus as bus:
|
||||
ctx.cm_bus = bus
|
||||
|
||||
|
||||
@then("the bus should be closed after the context exits")
|
||||
def step_bus_closed_after_context(ctx: Context) -> None:
|
||||
assert ctx.bus._closed, "Expected bus._closed to be True after context manager exit"
|
||||
|
||||
Reference in New Issue
Block a user