fix(events): add unsubscribe() to EventBus protocol and implementations

This commit is contained in:
2026-05-14 05:24:49 +00:00
committed by Forgejo
parent 76b1a62f04
commit 432fd2c9a1
6 changed files with 242 additions and 132 deletions
+62 -23
View File
@@ -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
+75 -53
View File
@@ -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"