Files
cleveragents-core/features/steps/event_system_taxonomy_steps.py
T
CoreRasurae 00881a3e5f
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 3m26s
CI / build (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 4m2s
CI / quality (pull_request) Successful in 4m2s
CI / security (pull_request) Successful in 4m23s
CI / integration_tests (pull_request) Successful in 9m35s
CI / unit_tests (pull_request) Successful in 9m39s
CI / docker (pull_request) Successful in 2m15s
CI / e2e_tests (pull_request) Successful in 12m32s
CI / coverage (pull_request) Successful in 11m41s
CI / status-check (pull_request) Successful in 1s
CI / build (push) Successful in 23s
CI / lint (push) Successful in 3m17s
CI / typecheck (push) Successful in 4m11s
CI / quality (push) Successful in 4m11s
CI / security (push) Successful in 4m28s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 9m13s
CI / unit_tests (push) Successful in 9m26s
CI / e2e_tests (push) Successful in 10m20s
CI / docker (push) Successful in 1m16s
CI / coverage (push) Successful in 12m2s
CI / status-check (push) Successful in 1s
CI / benchmark-publish (push) Successful in 33m17s
CI / benchmark-regression (pull_request) Successful in 57m23s
feat(observability): implement Event System Domain Event Taxonomy (full EventType enum + DomainEvent model)
Verified and completed the Event System Domain Event Taxonomy implementation.

Gap analysis:
- EventType enum (38 types across 12 domains): ALREADY EXISTED, verified complete
- DomainEvent Pydantic model (all 9 fields): ALREADY EXISTED, verified complete
- EventBus Protocol (emit + subscribe): ALREADY EXISTED, verified complete
- ReactiveEventBus (RxPY Subject, stream property): ALREADY EXISTED, verified
- LoggingEventBus (structlog-based): ALREADY EXISTED, verified complete

Added:
- In-memory audit_log on ReactiveEventBus (list[DomainEvent] with defensive copy)
- Emit ordering aligned with specification: RxPY stream push, then audit append,
  then handler dispatch (§Event System)
- Clarified audit_log docstring: volatile in-memory log, not durable SQLite
  persistence (durable persistence wired separately via audit service layer)
- Behave feature: features/observability/event_system_taxonomy.feature (36 scenarios)
- Step definitions: features/steps/event_system_taxonomy_steps.py
- Robot integration test: robot/event_system_taxonomy_integration.robot (13 test cases)
- Robot helper: robot/helper_event_system_taxonomy.py (13 subcommands)
- ASV benchmark: benchmarks/bench_event_bus.py (5 benchmark suites)
- vulture_whitelist.py: added audit_log property

Code review fixes applied:
- Reordered emit() to match spec: on_next → audit_log → handlers (was: audit → on_next → handlers)
- Clarified audit_log docstring to distinguish volatile in-memory log from spec SQLite table
- Fixed benchmark TaxonomyAuditLogSuite: parameterized setup instead of inline construction
- Added teardown to TaxonomyLoggingEmitSuite to restore logging.disable(NOTSET)
- Clear _received list between benchmark iterations to reduce noise
- Catch specific pydantic.ValidationError in frozen model mutation tests
- Consolidated duplicate singular/plural step definitions
- Tightened EventType count threshold from >=30 to >=38

Quality gates:
- lint: PASSED
- typecheck: PASSED (0 errors)
- unit_tests: 36/36 new scenarios PASSED (pre-existing 12 flaky failures unchanged)
- integration_tests: 1483/1483 PASSED
- security_scan: PASSED
- dead_code: PASSED

ISSUES CLOSED: #587
2026-03-26 11:52:52 +00:00

528 lines
19 KiB
Python

"""Step definitions for event_system_taxonomy.feature.
Tests the complete Event System Domain Event Taxonomy: EventType enum
completeness, DomainEvent model fields and serialisation, EventBus Protocol,
ReactiveEventBus emit/subscribe/stream/audit_log behaviour, and log
correlation.
"""
from __future__ import annotations
import re
from datetime import UTC
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from pydantic import ValidationError
from rx.core.observable.observable import Observable
from ulid import ULID
from cleveragents.infrastructure.events import (
DomainEvent,
EventBus,
EventType,
LoggingEventBus,
ReactiveEventBus,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_DOT_PATTERN = re.compile(r"^[a-z_]+\.[a-z_]+$")
def _make_event(event_type_str: str, **kwargs: Any) -> DomainEvent:
return DomainEvent(event_type=EventType(event_type_str), **kwargs)
class _TaxonomyCollector:
"""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 completeness
# ---------------------------------------------------------------------------
@then("the EventType enum should have at least {n:d} members")
def step_event_type_min_members(ctx: Context, n: int) -> None:
count = len(EventType)
assert count >= n, f"EventType has {count} members, expected >= {n}"
@then('EventType should contain "{value}"')
def step_event_type_contains_value(ctx: Context, value: str) -> None:
values = {e.value for e in EventType}
assert value in values, f"{value!r} not in EventType values: {sorted(values)}"
@then('every EventType value should match the pattern "<domain>.<action>"')
def step_event_type_dot_pattern(ctx: Context) -> None:
for member in EventType:
assert _DOT_PATTERN.match(member.value), (
f"EventType.{member.name} value {member.value!r} does not match "
f"the <domain>.<action> pattern"
)
# ---------------------------------------------------------------------------
# DomainEvent model
# ---------------------------------------------------------------------------
@when('I create a DomainEvent for taxonomy with event_type "{et}"')
def step_create_taxonomy_event(ctx: Context, et: str) -> None:
ctx.taxonomy_event = _make_event(et)
@then('the taxonomy event should have field "{field}"')
def step_taxonomy_event_has_field(ctx: Context, field: str) -> None:
assert field in ctx.taxonomy_event.model_fields, (
f"DomainEvent missing field {field!r}"
)
@then("the taxonomy event correlation_id should be {n:d} characters long")
def step_taxonomy_correlation_id_length(ctx: Context, n: int) -> None:
cid = ctx.taxonomy_event.correlation_id
assert len(cid) == n, f"correlation_id length {len(cid)}, expected {n}"
@then("the taxonomy event timestamp should be UTC")
def step_taxonomy_timestamp_utc(ctx: Context) -> None:
ts = ctx.taxonomy_event.timestamp
assert ts.tzinfo is not None, "timestamp has no timezone"
assert ts.tzinfo == UTC, f"timestamp timezone is {ts.tzinfo!r}, expected UTC"
@then("mutating the taxonomy event should raise a validation error")
def step_taxonomy_event_frozen(ctx: Context) -> None:
raised = False
try:
ctx.taxonomy_event.event_type = EventType.PLAN_CANCELLED # type: ignore[misc]
except ValidationError:
raised = True
assert raised, "Expected ValidationError when mutating frozen DomainEvent"
@when("I create a DomainEvent for taxonomy with all fields populated")
def step_create_taxonomy_full_event(ctx: Context) -> None:
ctx.taxonomy_event = DomainEvent(
event_type=EventType.PLAN_CREATED,
plan_id=str(ULID()),
root_plan_id=str(ULID()),
session_id=str(ULID()),
actor_name="local/strategist",
project_name="my-project",
details={"phase": "strategize", "actor": "strategists/planner"},
)
@then("the taxonomy event should serialize to JSON and deserialize back identically")
def step_taxonomy_event_json_roundtrip(ctx: Context) -> None:
json_str = ctx.taxonomy_event.model_dump_json()
restored = DomainEvent.model_validate_json(json_str)
assert restored == ctx.taxonomy_event, (
f"Round-trip mismatch:\n original={ctx.taxonomy_event}\n restored={restored}"
)
@when("I create a DomainEvent for taxonomy with correlation fields")
def step_create_taxonomy_event_correlation(ctx: Context) -> None:
ctx.taxonomy_event = DomainEvent(
event_type=EventType.PLAN_CREATED,
plan_id=str(ULID()),
root_plan_id=str(ULID()),
session_id=str(ULID()),
)
@then("the taxonomy event plan_id should be set")
def step_taxonomy_plan_id_set(ctx: Context) -> None:
assert ctx.taxonomy_event.plan_id is not None
@then("the taxonomy event root_plan_id should be set")
def step_taxonomy_root_plan_id_set(ctx: Context) -> None:
assert ctx.taxonomy_event.root_plan_id is not None
@then("the taxonomy event session_id should be set")
def step_taxonomy_session_id_set(ctx: Context) -> None:
assert ctx.taxonomy_event.session_id is not None
@when("I create a DomainEvent for taxonomy with typed details")
def step_create_taxonomy_event_with_details(ctx: Context) -> None:
ctx.taxonomy_event = DomainEvent(
event_type=EventType.PLAN_PHASE_CHANGED,
details={"old_phase": "strategize", "new_phase": "execute"},
)
@then("the taxonomy event details should contain the expected keys")
def step_taxonomy_event_details_keys(ctx: Context) -> None:
details = ctx.taxonomy_event.details
assert "old_phase" in details, "Missing 'old_phase' in details"
assert "new_phase" in details, "Missing 'new_phase' in details"
# ---------------------------------------------------------------------------
# EventBus Protocol
# ---------------------------------------------------------------------------
@then('the EventBus Protocol should define method "{method}"')
def step_protocol_has_method(ctx: Context, method: str) -> None:
assert hasattr(EventBus, method), f"EventBus Protocol missing method {method!r}"
@then("a ReactiveEventBus instance 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("a LoggingEventBus instance 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 — emit / subscribe / stream
# ---------------------------------------------------------------------------
@given("a fresh ReactiveEventBus for taxonomy tests")
def step_given_fresh_bus(ctx: Context) -> None:
ctx.taxonomy_bus = ReactiveEventBus()
ctx.taxonomy_collectors = []
@given("a ReactiveEventBus for taxonomy tests with max_audit_log_size {n:d}")
def step_given_capped_bus(ctx: Context, n: int) -> None:
ctx.taxonomy_bus = ReactiveEventBus(max_audit_log_size=n)
ctx.taxonomy_collectors = []
@then(
"creating a ReactiveEventBus with max_audit_log_size {n:d} should raise ValueError"
)
def step_reactive_bus_invalid_audit_log_size(ctx: Context, n: int) -> None:
raised = False
try:
ReactiveEventBus(max_audit_log_size=n)
except ValueError:
raised = True
assert raised, f"Expected ValueError for max_audit_log_size={n}"
@when('I taxonomy-subscribe to "{et}" events')
def step_taxonomy_subscribe(ctx: Context, et: str) -> None:
collector = _TaxonomyCollector()
ctx.taxonomy_collectors.append(collector)
ctx.taxonomy_bus.subscribe(EventType(et), collector)
@when('I taxonomy-emit a "{et}" DomainEvent')
def step_taxonomy_emit(ctx: Context, et: str) -> None:
ctx.taxonomy_bus.emit(_make_event(et))
@then("the taxonomy handler should have received {n:d} event(s)")
def step_taxonomy_handler_received(ctx: Context, n: int) -> None:
assert ctx.taxonomy_collectors, "No taxonomy collectors registered"
count = len(ctx.taxonomy_collectors[0].received)
assert count == n, f"Expected {n} event(s), got {count}"
@when('I taxonomy-subscribe two handlers to "{et}" events')
def step_taxonomy_subscribe_two(ctx: Context, et: str) -> None:
for _ in range(2):
collector = _TaxonomyCollector()
ctx.taxonomy_collectors.append(collector)
ctx.taxonomy_bus.subscribe(EventType(et), collector)
@then("each taxonomy handler should have received {n:d} event")
def step_each_taxonomy_handler_received(ctx: Context, n: int) -> None:
for i, col in enumerate(ctx.taxonomy_collectors):
assert len(col.received) == n, (
f"Handler {i} received {len(col.received)}, expected {n}"
)
@then("the taxonomy bus stream should be an Observable")
def step_taxonomy_bus_stream_observable(ctx: Context) -> None:
stream = ctx.taxonomy_bus.stream
assert isinstance(stream, Observable), (
f"Expected Observable, got {type(stream).__name__}"
)
@then('the taxonomy bus stream should not expose method "{method}"')
def step_taxonomy_bus_stream_not_expose_method(ctx: Context, method: str) -> None:
stream = ctx.taxonomy_bus.stream
assert not hasattr(stream, method), (
f"Expected stream to hide {method!r}, but it is present"
)
@when('I taxonomy-subscribe a failing handler and a normal handler to "{et}" events')
def step_taxonomy_subscribe_failing_and_normal(ctx: Context, et: str) -> None:
normal_collector = _TaxonomyCollector()
ctx.taxonomy_collectors.append(normal_collector)
def _failing_handler(event: DomainEvent) -> None:
raise RuntimeError(f"failing taxonomy handler for {event.event_type.value}")
ctx.taxonomy_bus.subscribe(EventType(et), _failing_handler)
ctx.taxonomy_bus.subscribe(EventType(et), normal_collector)
@then("the taxonomy normal handler should have received {n:d} event(s)")
def step_taxonomy_normal_handler_received(ctx: Context, n: int) -> None:
assert ctx.taxonomy_collectors, "No taxonomy normal collector registered"
count = len(ctx.taxonomy_collectors[-1].received)
assert count == n, f"Expected normal handler to receive {n} event(s), got {count}"
@when('I observe the taxonomy bus stream and emit a "{et}" event')
def step_taxonomy_observe_and_emit(ctx: Context, et: str) -> None:
ctx.stream_events = []
ctx.taxonomy_bus.stream.subscribe(on_next=ctx.stream_events.append)
ctx.taxonomy_bus.emit(_make_event(et))
@when('I observe taxonomy stream with a failing observer and emit a "{et}" event')
def step_taxonomy_stream_failing_observer(ctx: Context, et: str) -> None:
def _failing_observer(event: DomainEvent) -> None:
raise RuntimeError(f"failing stream observer for {event.event_type.value}")
ctx.taxonomy_bus.stream.subscribe(on_next=_failing_observer)
ctx.taxonomy_bus.emit(_make_event(et))
@then("the stream observer should have captured the event")
def step_stream_observer_captured(ctx: Context) -> None:
assert len(ctx.stream_events) == 1, (
f"Expected 1 stream event, got {len(ctx.stream_events)}"
)
@then("taxonomy-emitting a non-DomainEvent should raise TypeError")
def step_taxonomy_emit_bad_type(ctx: Context) -> None:
raised = False
try:
ctx.taxonomy_bus.emit("not-an-event") # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-DomainEvent"
@then("taxonomy-subscribing with a non-EventType should raise TypeError")
def step_taxonomy_subscribe_bad_type(ctx: Context) -> None:
raised = False
try:
ctx.taxonomy_bus.subscribe("plan.created", lambda e: None) # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-EventType"
@then("taxonomy-subscribing with a non-callable should raise TypeError")
def step_taxonomy_subscribe_non_callable(ctx: Context) -> None:
raised = False
try:
ctx.taxonomy_bus.subscribe(EventType.PLAN_CREATED, "not-callable") # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-callable handler"
# ---------------------------------------------------------------------------
# In-memory event trail (audit_log)
# ---------------------------------------------------------------------------
@then("the audit_log should contain {n:d} events")
def step_audit_log_count(ctx: Context, n: int) -> None:
log = ctx.taxonomy_bus.audit_log
assert len(log) == n, f"audit_log has {len(log)} events, expected {n}"
@then('the audit_log should include event types "{event_types}"')
def step_audit_log_contains_types(ctx: Context, event_types: str) -> None:
expected = [value.strip() for value in event_types.split(",")]
actual = [event.event_type.value for event in ctx.taxonomy_bus.audit_log]
assert actual == expected, f"audit_log types {actual} != expected {expected}"
@then('the audit_log events should be in order "{order}"')
def step_audit_log_order(ctx: Context, order: str) -> None:
expected = [value.strip() for value in order.split(",")]
log = ctx.taxonomy_bus.audit_log
actual = [e.event_type.value for e in log]
assert actual == expected, f"audit_log order {actual} != expected {expected}"
@then("mutating the returned audit_log should not affect the bus")
def step_audit_log_defensive_copy(ctx: Context) -> None:
log1 = ctx.taxonomy_bus.audit_log
original_len = len(log1)
log1.clear() # mutate the returned list
log2 = ctx.taxonomy_bus.audit_log
assert len(log2) == original_len, (
f"audit_log was mutated: expected {original_len}, got {len(log2)}"
)
@when("I clear the taxonomy bus audit_log")
def step_clear_taxonomy_audit_log(ctx: Context) -> None:
ctx.taxonomy_bus.clear_audit_log()
# ---------------------------------------------------------------------------
# Log correlation
# ---------------------------------------------------------------------------
@when("I emit two taxonomy events with the same correlation_id")
def step_emit_correlated_events(ctx: Context) -> None:
shared_cid = str(ULID())
ctx.taxonomy_bus.emit(
DomainEvent(
event_type=EventType.PLAN_CREATED,
correlation_id=shared_cid,
)
)
ctx.taxonomy_bus.emit(
DomainEvent(
event_type=EventType.PLAN_PHASE_CHANGED,
correlation_id=shared_cid,
)
)
ctx.shared_correlation_id = shared_cid
@then("both events in the audit_log should share the same correlation_id")
def step_correlated_events_in_log(ctx: Context) -> None:
log = ctx.taxonomy_bus.audit_log
assert len(log) == 2, f"Expected 2 events, got {len(log)}"
assert log[0].correlation_id == ctx.shared_correlation_id
assert log[1].correlation_id == ctx.shared_correlation_id
@when("I create two independent DomainEvents for taxonomy")
def step_create_two_independent_events(ctx: Context) -> None:
ctx.taxonomy_event_one = DomainEvent(event_type=EventType.PLAN_CREATED)
ctx.taxonomy_event_two = DomainEvent(event_type=EventType.DECISION_CREATED)
@then("the two taxonomy events should have different correlation_ids")
def step_taxonomy_events_have_different_correlation_ids(ctx: Context) -> None:
cid_one = ctx.taxonomy_event_one.correlation_id
cid_two = ctx.taxonomy_event_two.correlation_id
assert cid_one != cid_two, (
"Expected independently created DomainEvents to have unique "
f"correlation_ids, got {cid_one!r} and {cid_two!r}"
)
# ---------------------------------------------------------------------------
# LoggingEventBus — emit / subscribe
# ---------------------------------------------------------------------------
@given("a fresh LoggingEventBus for taxonomy tests")
def step_given_fresh_logging_bus(ctx: Context) -> None:
ctx.logging_taxonomy_bus = LoggingEventBus()
ctx.logging_taxonomy_collectors = []
@when('I logging-taxonomy-subscribe to "{et}" events')
def step_logging_taxonomy_subscribe(ctx: Context, et: str) -> None:
collector = _TaxonomyCollector()
ctx.logging_taxonomy_collectors.append(collector)
ctx.logging_taxonomy_bus.subscribe(EventType(et), collector)
@when('I logging-taxonomy-emit a "{et}" DomainEvent')
def step_logging_taxonomy_emit(ctx: Context, et: str) -> None:
ctx.logging_taxonomy_bus.emit(_make_event(et))
@then("the logging taxonomy handler should have received {n:d} event(s)")
def step_logging_taxonomy_handler_received(ctx: Context, n: int) -> None:
assert ctx.logging_taxonomy_collectors, "No logging taxonomy collectors registered"
count = len(ctx.logging_taxonomy_collectors[0].received)
assert count == n, f"Expected {n} event(s), got {count}"
@when(
'I logging-taxonomy-subscribe a failing handler and a normal handler to "{et}" events'
)
def step_logging_taxonomy_subscribe_failing_and_normal(ctx: Context, et: str) -> None:
normal_collector = _TaxonomyCollector()
ctx.logging_taxonomy_collectors.append(normal_collector)
def _failing_handler(event: DomainEvent) -> None:
raise RuntimeError(f"failing logging handler for {event.event_type.value}")
ctx.logging_taxonomy_bus.subscribe(EventType(et), _failing_handler)
ctx.logging_taxonomy_bus.subscribe(EventType(et), normal_collector)
@then("the logging taxonomy normal handler should have received {n:d} event(s)")
def step_logging_taxonomy_normal_handler_received(ctx: Context, n: int) -> None:
assert ctx.logging_taxonomy_collectors, (
"No logging taxonomy normal collector registered"
)
count = len(ctx.logging_taxonomy_collectors[-1].received)
assert count == n, f"Expected normal handler to receive {n} event(s), got {count}"
@then("logging-taxonomy-emitting a non-DomainEvent should raise TypeError")
def step_logging_taxonomy_emit_bad_type(ctx: Context) -> None:
raised = False
try:
ctx.logging_taxonomy_bus.emit("not-an-event") # type: ignore[arg-type]
except TypeError:
raised = True
assert raised, "Expected TypeError for non-DomainEvent"
@then("logging-taxonomy-subscribing with a non-EventType should raise TypeError")
def step_logging_taxonomy_subscribe_bad_type(ctx: Context) -> None:
raised = False
try:
ctx.logging_taxonomy_bus.subscribe(
"plan.created",
lambda e: None, # type: ignore[arg-type]
)
except TypeError:
raised = True
assert raised, "Expected TypeError for non-EventType"
@then("logging-taxonomy-subscribing with a non-callable should raise TypeError")
def step_logging_taxonomy_subscribe_non_callable(ctx: Context) -> None:
raised = False
try:
ctx.logging_taxonomy_bus.subscribe(
EventType.PLAN_CREATED,
"not-callable", # type: ignore[arg-type]
)
except TypeError:
raised = True
assert raised, "Expected TypeError for non-callable handler"