Files
cleveragents-core/features/steps/event_system_taxonomy_steps.py
T
brent.edwards e3d3c7926d
CI / integration_tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / e2e_tests (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / quality (push) Has been cancelled
CI / status-check (push) Has been cancelled
CI / security (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / unit_tests (push) Has been cancelled
CI / helm (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled
CI / docker (push) Has been cancelled
feat(events): add user_identity field to DomainEvent and propagate through event pipeline (#1257)
Co-authored-by: Brent Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent Edwards <brent.edwards@cleverthis.com>
2026-04-02 16:59:07 +00:00

550 lines
20 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
@then("the taxonomy event user_identity should be None")
def step_taxonomy_event_user_identity_none(ctx: Context) -> None:
assert ctx.taxonomy_event.user_identity is None, (
f"Expected user_identity=None, got {ctx.taxonomy_event.user_identity!r}"
)
@when('I create a DomainEvent for taxonomy with user_identity "{uid}"')
def step_create_taxonomy_event_with_user_identity(ctx: Context, uid: str) -> None:
ctx.taxonomy_event = DomainEvent(
event_type=EventType.PLAN_CREATED,
user_identity=uid,
)
@then('the taxonomy event user_identity should be "{expected}"')
def step_taxonomy_event_user_identity_value(ctx: Context, expected: str) -> None:
assert ctx.taxonomy_event.user_identity == expected, (
f"Expected user_identity={expected!r}, got {ctx.taxonomy_event.user_identity!r}"
)
@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"