3459f821c5
Add _closed flag and close() method to ReactiveEventBus to complete the RxPY Subject, preventing subscriber resource leaks. close() is idempotent and raises RuntimeError when called on a closed bus. Also adds: - emit() guard against post-close calls (RuntimeError) - __enter__/__exit__ context manager protocol for automatic cleanup - BDD scenarios in event_bus.feature and TDD tests in tdd_reactive_event_bus_close.feature ISSUES CLOSED: #10378
189 lines
8.2 KiB
Gherkin
189 lines
8.2 KiB
Gherkin
Feature: EventBus protocol and domain event emission
|
|
As a domain service
|
|
I want to emit typed domain events through an EventBus
|
|
So that audit logging, observability, and reactive subscribers work correctly
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EventType enum
|
|
# ---------------------------------------------------------------------------
|
|
|
|
Scenario: EventType enum covers required lifecycle values
|
|
Then EventType should include "plan.created"
|
|
And EventType should include "decision.created"
|
|
And EventType should include "tool.invoked"
|
|
And EventType should include "actor.completed"
|
|
And EventType should include "validation.passed"
|
|
And EventType should include "budget.exceeded"
|
|
|
|
Scenario: EventType string values compare equal to their members
|
|
Then EventType member PLAN_CREATED should equal string "plan.created"
|
|
And EventType member DECISION_CREATED should equal string "decision.created"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DomainEvent model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
Scenario: DomainEvent is created with required event_type
|
|
When I create a minimal DomainEvent with event_type "plan.created"
|
|
Then the DomainEvent event_type should be "plan.created"
|
|
And the DomainEvent should have a non-empty correlation_id
|
|
And the DomainEvent should have a timestamp
|
|
|
|
Scenario: DomainEvent defaults are applied for optional fields
|
|
When I create a minimal DomainEvent with event_type "decision.created"
|
|
Then the DomainEvent plan_id should be None
|
|
And the DomainEvent actor_name should be None
|
|
And the DomainEvent details should be empty
|
|
|
|
Scenario: DomainEvent accepts plan_id and details
|
|
When I create a DomainEvent with event_type "plan.created" plan_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and details key "phase" value "strategize"
|
|
Then the DomainEvent plan_id should be "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
|
And the DomainEvent details should contain key "phase"
|
|
|
|
Scenario: DomainEvent is immutable (frozen)
|
|
When I create a minimal DomainEvent with event_type "plan.created"
|
|
Then modifying the DomainEvent should raise an error
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EventBus Protocol
|
|
# ---------------------------------------------------------------------------
|
|
|
|
Scenario: ReactiveEventBus satisfies the EventBus protocol
|
|
Then ReactiveEventBus should satisfy the EventBus protocol
|
|
|
|
Scenario: LoggingEventBus satisfies the EventBus protocol
|
|
Then LoggingEventBus should satisfy the EventBus protocol
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ReactiveEventBus
|
|
# ---------------------------------------------------------------------------
|
|
|
|
Scenario: ReactiveEventBus emits events to subscribed handlers
|
|
Given a ReactiveEventBus
|
|
When I subscribe to "plan.created" events
|
|
And I emit a "plan.created" DomainEvent
|
|
Then the handler should have received 1 event
|
|
And the received event type should be "plan.created"
|
|
|
|
Scenario: ReactiveEventBus dispatches only to matching event type
|
|
Given a ReactiveEventBus
|
|
When I subscribe to "plan.created" events
|
|
And I emit a "decision.created" DomainEvent
|
|
Then the handler should have received 0 event
|
|
|
|
Scenario: ReactiveEventBus supports multiple handlers per event type
|
|
Given a ReactiveEventBus
|
|
When I subscribe two handlers to "plan.created" events
|
|
And I emit a "plan.created" DomainEvent
|
|
Then each handler should have received 1 event
|
|
|
|
Scenario: ReactiveEventBus exposes an observable stream
|
|
Given a ReactiveEventBus
|
|
Then the bus should expose an observable stream
|
|
|
|
Scenario: ReactiveEventBus rejects non-DomainEvent inputs
|
|
Given a ReactiveEventBus
|
|
Then emitting a non-DomainEvent should raise TypeError
|
|
|
|
Scenario: ReactiveEventBus rejects invalid event_type in subscribe
|
|
Given a ReactiveEventBus
|
|
Then subscribing with a non-EventType should raise TypeError
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LoggingEventBus
|
|
# ---------------------------------------------------------------------------
|
|
|
|
Scenario: LoggingEventBus emits events to subscribed handlers
|
|
Given a LoggingEventBus
|
|
When I subscribe to "decision.created" events
|
|
And I emit a "decision.created" DomainEvent
|
|
Then the handler should have received 1 event
|
|
|
|
Scenario: LoggingEventBus rejects non-DomainEvent inputs
|
|
Given a LoggingEventBus
|
|
Then emitting a non-DomainEvent should raise TypeError
|
|
|
|
Scenario: LoggingEventBus rejects invalid event_type in subscribe
|
|
Given a LoggingEventBus
|
|
Then subscribing with a non-EventType should raise TypeError
|
|
|
|
Scenario: LoggingEventBus rejects non-callable handler in subscribe
|
|
Given a LoggingEventBus
|
|
Then subscribing with a non-callable handler should raise TypeError
|
|
|
|
Scenario: ReactiveEventBus rejects non-callable handler in subscribe
|
|
Given a ReactiveEventBus
|
|
Then subscribing with a non-callable handler should raise TypeError
|
|
|
|
Scenario: EventBus Protocol emit and subscribe stubs are executable
|
|
Then the EventBus Protocol stubs should be callable
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DecisionService event emission
|
|
# ---------------------------------------------------------------------------
|
|
|
|
Scenario: DecisionService emits DECISION_CREATED when recording a decision
|
|
Given a DecisionService with an injected event bus
|
|
When I call record_decision
|
|
Then the event bus should have received a "decision.created" event
|
|
And the emitted event plan_id should match the decision plan_id
|
|
|
|
Scenario: DecisionService works without an event bus (no-op)
|
|
Given a DecisionService without an event bus
|
|
When I call record_decision
|
|
Then no event bus exception should be raised
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PlanLifecycleService event emission
|
|
# ---------------------------------------------------------------------------
|
|
|
|
Scenario: PlanLifecycleService emits PLAN_CREATED on use_action
|
|
Given a PlanLifecycleService with an injected event bus
|
|
When I call use_action to create a plan
|
|
Then the event bus should have received a "plan.created" event
|
|
|
|
Scenario: PlanLifecycleService emits PLAN_PHASE_CHANGED on execute_plan
|
|
Given a PlanLifecycleService with an injected event bus and a strategized plan
|
|
When I call execute_plan
|
|
Then the event bus should have received a "plan.phase_changed" event
|
|
|
|
Scenario: PlanLifecycleService works without an event bus (no-op)
|
|
Given a PlanLifecycleService without an event bus
|
|
When I call use_action to create a plan
|
|
Then no event bus exception should be raised
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DI container
|
|
# ---------------------------------------------------------------------------
|
|
|
|
Scenario: Container provides ReactiveEventBus as a Singleton
|
|
When I resolve event_bus from the DI container twice
|
|
Then both resolutions should return the same instance
|
|
|
|
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
|