feat(spaces): implement Phase 2 Event System
Week 4 - Event Infrastructure: - Create SpaceEventType enum with 18 event types covering space lifecycle, document operations, variables, references, rendering, sync, and cache - Create SpaceEvent dataclass with serialization/deserialization - Create EventBus with sync/async handler support, priority ordering, global handlers, and optional event history - Add event factory functions for common events Week 5 - Event Integration: - Wire EventBus into SpaceService as optional dependency - Emit events for all space operations: - SPACE_CREATED, SPACE_UPDATED, SPACE_DELETED, SPACE_ACTIVATED, SPACE_ARCHIVED - DOCUMENT_ADDED, DOCUMENT_REMOVED, DOCUMENT_MOVED, DOCUMENT_CONTENT_CHANGED - VARIABLE_SET, VARIABLE_DELETED - Create integration tests for event propagation patterns Test coverage: 187 tests total - 43 unit tests for event system - 20 integration tests for event propagation - 124 existing tests continue to pass Capabilities delivered: - CAP-010: SpaceEvent base with type, payload, timestamp - CAP-011: EventBus with in-process publish/subscribe - CAP-012: Event handlers registry with priority support - CAP-013: Change detection via content hash comparison Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
407
tests/integration/spaces/test_event_propagation.py
Normal file
407
tests/integration/spaces/test_event_propagation.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
Integration tests for event propagation in SpaceService.
|
||||
|
||||
Tests that events are correctly emitted for all space operations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from unittest.mock import Mock
|
||||
|
||||
from markitect.spaces import (
|
||||
SpaceService,
|
||||
SqliteSpaceRepository,
|
||||
SqliteDocumentRepository,
|
||||
SqliteVariableRepository,
|
||||
SqliteReferenceRepository,
|
||||
)
|
||||
from markitect.spaces.events import (
|
||||
EventBus,
|
||||
SpaceEvent,
|
||||
SpaceEventType,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db():
|
||||
"""Create a temporary database file for testing."""
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
yield path
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_bus():
|
||||
"""Create an EventBus for testing."""
|
||||
return EventBus()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def space_service(temp_db, event_bus):
|
||||
"""Create a SpaceService with EventBus for testing."""
|
||||
return SpaceService(
|
||||
space_repo=SqliteSpaceRepository(temp_db),
|
||||
document_repo=SqliteDocumentRepository(temp_db),
|
||||
variable_repo=SqliteVariableRepository(temp_db),
|
||||
reference_repo=SqliteReferenceRepository(temp_db),
|
||||
event_bus=event_bus,
|
||||
)
|
||||
|
||||
|
||||
class TestSpaceEvents:
|
||||
"""Tests for space lifecycle events."""
|
||||
|
||||
def test_space_created_event(self, space_service, event_bus):
|
||||
"""Test that SPACE_CREATED event is emitted."""
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.SPACE_CREATED, received_events.append)
|
||||
|
||||
space = space_service.create_space(name="test-space")
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.SPACE_CREATED
|
||||
assert event.space_id == space.id
|
||||
assert event.payload["name"] == "test-space"
|
||||
|
||||
def test_space_updated_event(self, space_service, event_bus):
|
||||
"""Test that SPACE_UPDATED event is emitted."""
|
||||
space = space_service.create_space(name="original")
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.SPACE_UPDATED, received_events.append)
|
||||
|
||||
space_service.update_space(space.id, name="updated", description="New desc")
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.SPACE_UPDATED
|
||||
assert event.space_id == space.id
|
||||
assert event.payload["changes"]["name"] == "updated"
|
||||
assert event.payload["changes"]["description"] == "New desc"
|
||||
|
||||
def test_space_deleted_event(self, space_service, event_bus):
|
||||
"""Test that SPACE_DELETED event is emitted."""
|
||||
space = space_service.create_space(name="to-delete")
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.SPACE_DELETED, received_events.append)
|
||||
|
||||
space_service.delete_space(space.id)
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.SPACE_DELETED
|
||||
assert event.space_id == space.id
|
||||
assert event.payload["name"] == "to-delete"
|
||||
|
||||
def test_space_activated_event(self, space_service, event_bus):
|
||||
"""Test that SPACE_ACTIVATED event is emitted."""
|
||||
space = space_service.create_space(name="to-activate")
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.SPACE_ACTIVATED, received_events.append)
|
||||
|
||||
space_service.activate_space(space.id)
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.SPACE_ACTIVATED
|
||||
assert event.space_id == space.id
|
||||
|
||||
def test_space_archived_event(self, space_service, event_bus):
|
||||
"""Test that SPACE_ARCHIVED event is emitted."""
|
||||
space = space_service.create_space(name="to-archive")
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.SPACE_ARCHIVED, received_events.append)
|
||||
|
||||
space_service.archive_space(space.id)
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.SPACE_ARCHIVED
|
||||
assert event.space_id == space.id
|
||||
|
||||
def test_cascade_delete_emits_multiple_events(self, space_service, event_bus):
|
||||
"""Test that cascade delete emits events for all deleted spaces."""
|
||||
parent = space_service.create_space(name="parent")
|
||||
child1 = space_service.create_space(name="child1", parent_space_id=parent.id)
|
||||
child2 = space_service.create_space(name="child2", parent_space_id=parent.id)
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.SPACE_DELETED, received_events.append)
|
||||
|
||||
space_service.delete_space(parent.id, cascade=True)
|
||||
|
||||
# Should have events for parent and both children
|
||||
assert len(received_events) == 3
|
||||
deleted_ids = {e.space_id for e in received_events}
|
||||
assert parent.id in deleted_ids
|
||||
assert child1.id in deleted_ids
|
||||
assert child2.id in deleted_ids
|
||||
|
||||
|
||||
class TestDocumentEvents:
|
||||
"""Tests for document events."""
|
||||
|
||||
def test_document_added_event(self, space_service, event_bus):
|
||||
"""Test that DOCUMENT_ADDED event is emitted."""
|
||||
space = space_service.create_space(name="doc-space")
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.DOCUMENT_ADDED, received_events.append)
|
||||
|
||||
doc = space_service.add_document(space.id, "/intro.md")
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.DOCUMENT_ADDED
|
||||
assert event.space_id == space.id
|
||||
assert event.payload["document_id"] == doc.id
|
||||
assert event.payload["space_path"] == "/intro.md"
|
||||
|
||||
def test_document_removed_event(self, space_service, event_bus):
|
||||
"""Test that DOCUMENT_REMOVED event is emitted."""
|
||||
space = space_service.create_space(name="doc-space")
|
||||
doc = space_service.add_document(space.id, "/to-remove.md")
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.DOCUMENT_REMOVED, received_events.append)
|
||||
|
||||
space_service.remove_document(doc.id)
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.DOCUMENT_REMOVED
|
||||
assert event.space_id == space.id
|
||||
assert event.payload["document_id"] == doc.id
|
||||
assert event.payload["space_path"] == "/to-remove.md"
|
||||
|
||||
def test_document_moved_event(self, space_service, event_bus):
|
||||
"""Test that DOCUMENT_MOVED event is emitted."""
|
||||
space = space_service.create_space(name="doc-space")
|
||||
doc = space_service.add_document(space.id, "/old-path.md")
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.DOCUMENT_MOVED, received_events.append)
|
||||
|
||||
space_service.move_document(doc.id, "/new-path.md")
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.DOCUMENT_MOVED
|
||||
assert event.space_id == space.id
|
||||
assert event.payload["old_path"] == "/old-path.md"
|
||||
assert event.payload["new_path"] == "/new-path.md"
|
||||
|
||||
def test_document_content_changed_event(self, space_service, event_bus):
|
||||
"""Test that DOCUMENT_CONTENT_CHANGED event is emitted."""
|
||||
space = space_service.create_space(name="doc-space")
|
||||
doc = space_service.add_document(
|
||||
space.id, "/content.md", content_hash="hash-v1"
|
||||
)
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(
|
||||
SpaceEventType.DOCUMENT_CONTENT_CHANGED, received_events.append
|
||||
)
|
||||
|
||||
space_service.update_document_hash(doc.id, "hash-v2")
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.DOCUMENT_CONTENT_CHANGED
|
||||
assert event.space_id == space.id
|
||||
assert event.payload["old_hash"] == "hash-v1"
|
||||
assert event.payload["new_hash"] == "hash-v2"
|
||||
|
||||
def test_no_event_if_hash_unchanged(self, space_service, event_bus):
|
||||
"""Test that no event is emitted if hash is unchanged."""
|
||||
space = space_service.create_space(name="doc-space")
|
||||
doc = space_service.add_document(
|
||||
space.id, "/content.md", content_hash="same-hash"
|
||||
)
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(
|
||||
SpaceEventType.DOCUMENT_CONTENT_CHANGED, received_events.append
|
||||
)
|
||||
|
||||
space_service.update_document_hash(doc.id, "same-hash")
|
||||
|
||||
assert len(received_events) == 0
|
||||
|
||||
|
||||
class TestVariableEvents:
|
||||
"""Tests for variable events."""
|
||||
|
||||
def test_variable_set_event(self, space_service, event_bus):
|
||||
"""Test that VARIABLE_SET event is emitted."""
|
||||
space = space_service.create_space(name="var-space")
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.VARIABLE_SET, received_events.append)
|
||||
|
||||
space_service.set_variable(space.id, "version", "1.0.0")
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.VARIABLE_SET
|
||||
assert event.space_id == space.id
|
||||
assert event.payload["name"] == "version"
|
||||
|
||||
def test_variable_deleted_event(self, space_service, event_bus):
|
||||
"""Test that VARIABLE_DELETED event is emitted."""
|
||||
space = space_service.create_space(name="var-space")
|
||||
space_service.set_variable(space.id, "temp", "value")
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.VARIABLE_DELETED, received_events.append)
|
||||
|
||||
space_service.delete_variable(space.id, "temp")
|
||||
|
||||
assert len(received_events) == 1
|
||||
event = received_events[0]
|
||||
assert event.event_type == SpaceEventType.VARIABLE_DELETED
|
||||
assert event.space_id == space.id
|
||||
assert event.payload["name"] == "temp"
|
||||
|
||||
def test_no_event_if_variable_not_found(self, space_service, event_bus):
|
||||
"""Test that no event is emitted if variable doesn't exist."""
|
||||
space = space_service.create_space(name="var-space")
|
||||
|
||||
received_events = []
|
||||
event_bus.subscribe(SpaceEventType.VARIABLE_DELETED, received_events.append)
|
||||
|
||||
space_service.delete_variable(space.id, "non-existent")
|
||||
|
||||
assert len(received_events) == 0
|
||||
|
||||
|
||||
class TestEventHandlerIntegration:
|
||||
"""Tests for event handler integration patterns."""
|
||||
|
||||
def test_global_handler_receives_all_events(self, space_service, event_bus):
|
||||
"""Test that a global handler receives all event types."""
|
||||
received_events = []
|
||||
event_bus.subscribe_all(received_events.append)
|
||||
|
||||
space = space_service.create_space(name="test")
|
||||
space_service.add_document(space.id, "/doc.md")
|
||||
space_service.set_variable(space.id, "var", "value")
|
||||
space_service.delete_space(space.id)
|
||||
|
||||
# Should have: SPACE_CREATED, DOCUMENT_ADDED, VARIABLE_SET, SPACE_DELETED
|
||||
assert len(received_events) >= 4
|
||||
|
||||
event_types = {e.event_type for e in received_events}
|
||||
assert SpaceEventType.SPACE_CREATED in event_types
|
||||
assert SpaceEventType.DOCUMENT_ADDED in event_types
|
||||
assert SpaceEventType.VARIABLE_SET in event_types
|
||||
assert SpaceEventType.SPACE_DELETED in event_types
|
||||
|
||||
def test_event_history_records_all_events(self, space_service, event_bus):
|
||||
"""Test that event history captures all events."""
|
||||
event_bus.enable_history()
|
||||
|
||||
space = space_service.create_space(name="history-test")
|
||||
space_service.add_document(space.id, "/doc1.md")
|
||||
space_service.add_document(space.id, "/doc2.md")
|
||||
space_service.activate_space(space.id)
|
||||
|
||||
history = event_bus.get_history()
|
||||
assert len(history) == 4
|
||||
|
||||
# Newest first
|
||||
assert history[0].event_type == SpaceEventType.SPACE_ACTIVATED
|
||||
assert history[1].event_type == SpaceEventType.DOCUMENT_ADDED
|
||||
assert history[2].event_type == SpaceEventType.DOCUMENT_ADDED
|
||||
assert history[3].event_type == SpaceEventType.SPACE_CREATED
|
||||
|
||||
def test_filter_history_by_space(self, space_service, event_bus):
|
||||
"""Test filtering event history by space ID."""
|
||||
event_bus.enable_history()
|
||||
|
||||
space1 = space_service.create_space(name="space1")
|
||||
space2 = space_service.create_space(name="space2")
|
||||
space_service.add_document(space1.id, "/doc.md")
|
||||
space_service.add_document(space2.id, "/doc.md")
|
||||
|
||||
history_space1 = event_bus.get_history(space_id=space1.id)
|
||||
assert len(history_space1) == 2 # create + add_document
|
||||
|
||||
history_space2 = event_bus.get_history(space_id=space2.id)
|
||||
assert len(history_space2) == 2
|
||||
|
||||
def test_multiple_handlers_for_same_event(self, space_service, event_bus):
|
||||
"""Test that multiple handlers can listen to the same event."""
|
||||
handler1_events = []
|
||||
handler2_events = []
|
||||
|
||||
event_bus.subscribe(SpaceEventType.SPACE_CREATED, handler1_events.append)
|
||||
event_bus.subscribe(SpaceEventType.SPACE_CREATED, handler2_events.append)
|
||||
|
||||
space_service.create_space(name="multi-handler-test")
|
||||
|
||||
assert len(handler1_events) == 1
|
||||
assert len(handler2_events) == 1
|
||||
assert handler1_events[0].event_id == handler2_events[0].event_id
|
||||
|
||||
|
||||
class TestEventDrivenWorkflow:
|
||||
"""Tests for event-driven workflow patterns."""
|
||||
|
||||
def test_cache_invalidation_pattern(self, space_service, event_bus):
|
||||
"""Test a cache invalidation pattern using events."""
|
||||
cache = {}
|
||||
|
||||
def on_content_changed(event: SpaceEvent):
|
||||
doc_id = event.payload["document_id"]
|
||||
if doc_id in cache:
|
||||
del cache[doc_id]
|
||||
|
||||
event_bus.subscribe(
|
||||
SpaceEventType.DOCUMENT_CONTENT_CHANGED, on_content_changed
|
||||
)
|
||||
|
||||
# Setup
|
||||
space = space_service.create_space(name="cache-test")
|
||||
doc = space_service.add_document(space.id, "/doc.md", content_hash="v1")
|
||||
|
||||
# Simulate cached content
|
||||
cache[doc.id] = "cached content"
|
||||
|
||||
# Update triggers cache invalidation via event
|
||||
space_service.update_document_hash(doc.id, "v2")
|
||||
|
||||
assert doc.id not in cache
|
||||
|
||||
def test_audit_log_pattern(self, space_service, event_bus):
|
||||
"""Test an audit log pattern using events."""
|
||||
audit_log = []
|
||||
|
||||
def log_event(event: SpaceEvent):
|
||||
audit_log.append({
|
||||
"event_type": event.event_type.value,
|
||||
"space_id": event.space_id,
|
||||
"timestamp": event.timestamp.isoformat(),
|
||||
})
|
||||
|
||||
event_bus.subscribe_all(log_event)
|
||||
|
||||
space = space_service.create_space(name="audit-test")
|
||||
space_service.activate_space(space.id)
|
||||
space_service.archive_space(space.id)
|
||||
space_service.delete_space(space.id)
|
||||
|
||||
# Should have complete audit trail
|
||||
assert len(audit_log) == 4
|
||||
event_types = [entry["event_type"] for entry in audit_log]
|
||||
assert "space.created" in event_types
|
||||
assert "space.activated" in event_types
|
||||
assert "space.archived" in event_types
|
||||
assert "space.deleted" in event_types
|
||||
581
tests/unit/spaces/test_events.py
Normal file
581
tests/unit/spaces/test_events.py
Normal file
@@ -0,0 +1,581 @@
|
||||
"""
|
||||
Unit tests for the space event system.
|
||||
|
||||
Tests the event models and EventBus functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
|
||||
from markitect.spaces.events import (
|
||||
SpaceEvent,
|
||||
SpaceEventType,
|
||||
EventBus,
|
||||
get_event_bus,
|
||||
reset_event_bus,
|
||||
space_created_event,
|
||||
space_updated_event,
|
||||
space_deleted_event,
|
||||
document_added_event,
|
||||
document_updated_event,
|
||||
document_removed_event,
|
||||
document_content_changed_event,
|
||||
cache_invalidated_event,
|
||||
)
|
||||
|
||||
|
||||
class TestSpaceEventType:
|
||||
"""Tests for SpaceEventType enum."""
|
||||
|
||||
def test_event_type_values(self):
|
||||
"""Test that event types have expected string values."""
|
||||
assert SpaceEventType.SPACE_CREATED.value == "space.created"
|
||||
assert SpaceEventType.DOCUMENT_ADDED.value == "document.added"
|
||||
assert SpaceEventType.CACHE_INVALIDATED.value == "cache.invalidated"
|
||||
|
||||
def test_all_event_types_have_values(self):
|
||||
"""Test that all event types have non-empty values."""
|
||||
for event_type in SpaceEventType:
|
||||
assert event_type.value
|
||||
assert "." in event_type.value
|
||||
|
||||
|
||||
class TestSpaceEvent:
|
||||
"""Tests for SpaceEvent dataclass."""
|
||||
|
||||
def test_event_creation(self):
|
||||
"""Test basic event creation."""
|
||||
event = SpaceEvent(
|
||||
event_type=SpaceEventType.SPACE_CREATED,
|
||||
space_id="space-123",
|
||||
payload={"name": "test-space"},
|
||||
)
|
||||
|
||||
assert event.event_type == SpaceEventType.SPACE_CREATED
|
||||
assert event.space_id == "space-123"
|
||||
assert event.payload["name"] == "test-space"
|
||||
assert event.event_id is not None
|
||||
assert event.timestamp is not None
|
||||
|
||||
def test_event_with_source_and_correlation(self):
|
||||
"""Test event with source and correlation ID."""
|
||||
event = SpaceEvent(
|
||||
event_type=SpaceEventType.DOCUMENT_ADDED,
|
||||
space_id="space-123",
|
||||
source="api",
|
||||
correlation_id="req-456",
|
||||
)
|
||||
|
||||
assert event.source == "api"
|
||||
assert event.correlation_id == "req-456"
|
||||
|
||||
def test_event_to_dict(self):
|
||||
"""Test event serialization."""
|
||||
event = SpaceEvent(
|
||||
event_type=SpaceEventType.SPACE_UPDATED,
|
||||
space_id="space-123",
|
||||
payload={"changes": {"name": "new-name"}},
|
||||
source="cli",
|
||||
)
|
||||
|
||||
data = event.to_dict()
|
||||
|
||||
assert data["event_type"] == "space.updated"
|
||||
assert data["space_id"] == "space-123"
|
||||
assert data["payload"]["changes"]["name"] == "new-name"
|
||||
assert data["source"] == "cli"
|
||||
assert "event_id" in data
|
||||
assert "timestamp" in data
|
||||
|
||||
def test_event_from_dict(self):
|
||||
"""Test event deserialization."""
|
||||
data = {
|
||||
"event_id": "evt-123",
|
||||
"event_type": "document.added",
|
||||
"space_id": "space-456",
|
||||
"payload": {"document_id": "doc-1", "space_path": "/intro.md"},
|
||||
"timestamp": "2025-01-15T10:30:00",
|
||||
"source": "sync",
|
||||
}
|
||||
|
||||
event = SpaceEvent.from_dict(data)
|
||||
|
||||
assert event.event_id == "evt-123"
|
||||
assert event.event_type == SpaceEventType.DOCUMENT_ADDED
|
||||
assert event.space_id == "space-456"
|
||||
assert event.payload["document_id"] == "doc-1"
|
||||
assert event.source == "sync"
|
||||
|
||||
def test_event_roundtrip(self):
|
||||
"""Test that to_dict and from_dict are inverses."""
|
||||
original = SpaceEvent(
|
||||
event_type=SpaceEventType.RENDER_COMPLETED,
|
||||
space_id="space-123",
|
||||
payload={"output_path": "/tmp/output"},
|
||||
source="renderer",
|
||||
correlation_id="batch-1",
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = SpaceEvent.from_dict(data)
|
||||
|
||||
assert restored.event_type == original.event_type
|
||||
assert restored.space_id == original.space_id
|
||||
assert restored.payload == original.payload
|
||||
assert restored.source == original.source
|
||||
assert restored.correlation_id == original.correlation_id
|
||||
|
||||
|
||||
class TestEventFactories:
|
||||
"""Tests for event factory functions."""
|
||||
|
||||
def test_space_created_event(self):
|
||||
"""Test space_created_event factory."""
|
||||
event = space_created_event("space-1", "my-docs", source="api")
|
||||
|
||||
assert event.event_type == SpaceEventType.SPACE_CREATED
|
||||
assert event.space_id == "space-1"
|
||||
assert event.payload["name"] == "my-docs"
|
||||
assert event.source == "api"
|
||||
|
||||
def test_space_updated_event(self):
|
||||
"""Test space_updated_event factory."""
|
||||
event = space_updated_event(
|
||||
"space-1",
|
||||
changes={"description": "Updated description"},
|
||||
)
|
||||
|
||||
assert event.event_type == SpaceEventType.SPACE_UPDATED
|
||||
assert event.payload["changes"]["description"] == "Updated description"
|
||||
|
||||
def test_space_deleted_event(self):
|
||||
"""Test space_deleted_event factory."""
|
||||
event = space_deleted_event("space-1", "old-space")
|
||||
|
||||
assert event.event_type == SpaceEventType.SPACE_DELETED
|
||||
assert event.payload["name"] == "old-space"
|
||||
|
||||
def test_document_added_event(self):
|
||||
"""Test document_added_event factory."""
|
||||
event = document_added_event("space-1", "doc-1", "/intro.md")
|
||||
|
||||
assert event.event_type == SpaceEventType.DOCUMENT_ADDED
|
||||
assert event.payload["document_id"] == "doc-1"
|
||||
assert event.payload["space_path"] == "/intro.md"
|
||||
|
||||
def test_document_content_changed_event(self):
|
||||
"""Test document_content_changed_event factory."""
|
||||
event = document_content_changed_event(
|
||||
"space-1", "doc-1", old_hash="abc", new_hash="xyz"
|
||||
)
|
||||
|
||||
assert event.event_type == SpaceEventType.DOCUMENT_CONTENT_CHANGED
|
||||
assert event.payload["old_hash"] == "abc"
|
||||
assert event.payload["new_hash"] == "xyz"
|
||||
|
||||
def test_cache_invalidated_event(self):
|
||||
"""Test cache_invalidated_event factory."""
|
||||
event = cache_invalidated_event(
|
||||
"space-1",
|
||||
document_ids=["doc-1", "doc-2"],
|
||||
reason="dependency_changed",
|
||||
)
|
||||
|
||||
assert event.event_type == SpaceEventType.CACHE_INVALIDATED
|
||||
assert event.payload["document_ids"] == ["doc-1", "doc-2"]
|
||||
assert event.payload["reason"] == "dependency_changed"
|
||||
|
||||
|
||||
class TestEventBus:
|
||||
"""Tests for EventBus."""
|
||||
|
||||
@pytest.fixture
|
||||
def bus(self):
|
||||
"""Create a fresh EventBus for each test."""
|
||||
return EventBus()
|
||||
|
||||
def test_subscribe_and_emit(self, bus):
|
||||
"""Test basic subscribe and emit."""
|
||||
received_events = []
|
||||
|
||||
def handler(event: SpaceEvent):
|
||||
received_events.append(event)
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, handler)
|
||||
|
||||
event = space_created_event("space-1", "test")
|
||||
bus.emit(event)
|
||||
|
||||
assert len(received_events) == 1
|
||||
assert received_events[0].event_type == SpaceEventType.SPACE_CREATED
|
||||
|
||||
def test_subscribe_returns_handler_id(self, bus):
|
||||
"""Test that subscribe returns a handler ID."""
|
||||
handler_id = bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None)
|
||||
assert handler_id is not None
|
||||
assert isinstance(handler_id, str)
|
||||
|
||||
def test_unsubscribe(self, bus):
|
||||
"""Test unsubscribing a handler."""
|
||||
received_events = []
|
||||
|
||||
def handler(event: SpaceEvent):
|
||||
received_events.append(event)
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, handler)
|
||||
bus.emit(space_created_event("s1", "test1"))
|
||||
|
||||
assert len(received_events) == 1
|
||||
|
||||
result = bus.unsubscribe(SpaceEventType.SPACE_CREATED, handler)
|
||||
assert result is True
|
||||
|
||||
bus.emit(space_created_event("s2", "test2"))
|
||||
assert len(received_events) == 1 # No new events
|
||||
|
||||
def test_unsubscribe_by_id(self, bus):
|
||||
"""Test unsubscribing by handler ID."""
|
||||
handler = Mock()
|
||||
handler_id = bus.subscribe(SpaceEventType.SPACE_CREATED, handler)
|
||||
|
||||
bus.emit(space_created_event("s1", "test"))
|
||||
assert handler.call_count == 1
|
||||
|
||||
result = bus.unsubscribe_by_id(handler_id)
|
||||
assert result is True
|
||||
|
||||
bus.emit(space_created_event("s2", "test"))
|
||||
assert handler.call_count == 1 # No additional calls
|
||||
|
||||
def test_multiple_handlers(self, bus):
|
||||
"""Test multiple handlers for same event type."""
|
||||
handler1 = Mock()
|
||||
handler2 = Mock()
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, handler1)
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, handler2)
|
||||
|
||||
bus.emit(space_created_event("s1", "test"))
|
||||
|
||||
handler1.assert_called_once()
|
||||
handler2.assert_called_once()
|
||||
|
||||
def test_handler_receives_correct_event(self, bus):
|
||||
"""Test that handlers receive the emitted event."""
|
||||
received = []
|
||||
|
||||
def handler(event: SpaceEvent):
|
||||
received.append(event)
|
||||
|
||||
bus.subscribe(SpaceEventType.DOCUMENT_ADDED, handler)
|
||||
|
||||
event = document_added_event("s1", "doc-1", "/intro.md")
|
||||
bus.emit(event)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].event_id == event.event_id
|
||||
|
||||
def test_handler_isolation_by_event_type(self, bus):
|
||||
"""Test that handlers only receive their subscribed event types."""
|
||||
space_handler = Mock()
|
||||
doc_handler = Mock()
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, space_handler)
|
||||
bus.subscribe(SpaceEventType.DOCUMENT_ADDED, doc_handler)
|
||||
|
||||
bus.emit(space_created_event("s1", "test"))
|
||||
|
||||
space_handler.assert_called_once()
|
||||
doc_handler.assert_not_called()
|
||||
|
||||
def test_global_handler(self, bus):
|
||||
"""Test handlers subscribed to all events."""
|
||||
global_handler = Mock()
|
||||
specific_handler = Mock()
|
||||
|
||||
bus.subscribe_all(global_handler)
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, specific_handler)
|
||||
|
||||
bus.emit(space_created_event("s1", "test"))
|
||||
bus.emit(document_added_event("s1", "doc-1", "/intro.md"))
|
||||
|
||||
assert global_handler.call_count == 2
|
||||
assert specific_handler.call_count == 1
|
||||
|
||||
def test_handler_priority(self, bus):
|
||||
"""Test that handlers are called in priority order."""
|
||||
call_order = []
|
||||
|
||||
def handler_low(event):
|
||||
call_order.append("low")
|
||||
|
||||
def handler_high(event):
|
||||
call_order.append("high")
|
||||
|
||||
def handler_medium(event):
|
||||
call_order.append("medium")
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, handler_low, priority=100)
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, handler_high, priority=10)
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, handler_medium, priority=50)
|
||||
|
||||
bus.emit(space_created_event("s1", "test"))
|
||||
|
||||
assert call_order == ["high", "medium", "low"]
|
||||
|
||||
def test_handler_exception_does_not_stop_others(self, bus):
|
||||
"""Test that one handler's exception doesn't prevent other handlers."""
|
||||
handler1 = Mock()
|
||||
handler2 = Mock(side_effect=ValueError("Test error"))
|
||||
handler3 = Mock()
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, handler1, priority=1)
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, handler2, priority=2)
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, handler3, priority=3)
|
||||
|
||||
exceptions = bus.emit(space_created_event("s1", "test"))
|
||||
|
||||
handler1.assert_called_once()
|
||||
handler2.assert_called_once()
|
||||
handler3.assert_called_once()
|
||||
assert len(exceptions) == 1
|
||||
assert isinstance(exceptions[0], ValueError)
|
||||
|
||||
def test_has_handlers(self, bus):
|
||||
"""Test checking for handlers."""
|
||||
assert bus.has_handlers(SpaceEventType.SPACE_CREATED) is False
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None)
|
||||
|
||||
assert bus.has_handlers(SpaceEventType.SPACE_CREATED) is True
|
||||
assert bus.has_handlers(SpaceEventType.DOCUMENT_ADDED) is False
|
||||
|
||||
def test_has_handlers_with_global(self, bus):
|
||||
"""Test that has_handlers considers global handlers."""
|
||||
assert bus.has_handlers(SpaceEventType.DOCUMENT_ADDED) is False
|
||||
|
||||
bus.subscribe_all(lambda e: None)
|
||||
|
||||
assert bus.has_handlers(SpaceEventType.DOCUMENT_ADDED) is True
|
||||
|
||||
def test_handler_count(self, bus):
|
||||
"""Test counting handlers."""
|
||||
assert bus.handler_count() == 0
|
||||
assert bus.handler_count(SpaceEventType.SPACE_CREATED) == 0
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None)
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None)
|
||||
bus.subscribe(SpaceEventType.DOCUMENT_ADDED, lambda e: None)
|
||||
|
||||
assert bus.handler_count() == 3
|
||||
assert bus.handler_count(SpaceEventType.SPACE_CREATED) == 2
|
||||
assert bus.handler_count(SpaceEventType.DOCUMENT_ADDED) == 1
|
||||
|
||||
def test_clear_all(self, bus):
|
||||
"""Test clearing all handlers."""
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None)
|
||||
bus.subscribe(SpaceEventType.DOCUMENT_ADDED, lambda e: None)
|
||||
bus.subscribe_all(lambda e: None)
|
||||
|
||||
count = bus.clear()
|
||||
|
||||
assert count == 3
|
||||
assert bus.handler_count() == 0
|
||||
|
||||
def test_clear_by_event_type(self, bus):
|
||||
"""Test clearing handlers for specific event type."""
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None)
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None)
|
||||
bus.subscribe(SpaceEventType.DOCUMENT_ADDED, lambda e: None)
|
||||
|
||||
count = bus.clear(SpaceEventType.SPACE_CREATED)
|
||||
|
||||
assert count == 2
|
||||
assert bus.handler_count(SpaceEventType.SPACE_CREATED) == 0
|
||||
assert bus.handler_count(SpaceEventType.DOCUMENT_ADDED) == 1
|
||||
|
||||
|
||||
class TestEventBusHistory:
|
||||
"""Tests for EventBus history feature."""
|
||||
|
||||
@pytest.fixture
|
||||
def bus(self):
|
||||
"""Create a fresh EventBus with history enabled."""
|
||||
bus = EventBus()
|
||||
bus.enable_history()
|
||||
return bus
|
||||
|
||||
def test_history_disabled_by_default(self):
|
||||
"""Test that history is disabled by default."""
|
||||
bus = EventBus()
|
||||
bus.emit(space_created_event("s1", "test"))
|
||||
|
||||
history = bus.get_history()
|
||||
assert len(history) == 0
|
||||
|
||||
def test_history_records_events(self, bus):
|
||||
"""Test that history records emitted events."""
|
||||
bus.emit(space_created_event("s1", "test1"))
|
||||
bus.emit(space_created_event("s2", "test2"))
|
||||
|
||||
history = bus.get_history()
|
||||
assert len(history) == 2
|
||||
|
||||
def test_history_newest_first(self, bus):
|
||||
"""Test that history returns newest events first."""
|
||||
bus.emit(space_created_event("s1", "first"))
|
||||
bus.emit(space_created_event("s2", "second"))
|
||||
|
||||
history = bus.get_history()
|
||||
assert history[0].payload["name"] == "second"
|
||||
assert history[1].payload["name"] == "first"
|
||||
|
||||
def test_history_filter_by_event_type(self, bus):
|
||||
"""Test filtering history by event type."""
|
||||
bus.emit(space_created_event("s1", "test"))
|
||||
bus.emit(document_added_event("s1", "doc-1", "/intro.md"))
|
||||
bus.emit(space_created_event("s2", "test2"))
|
||||
|
||||
history = bus.get_history(event_type=SpaceEventType.SPACE_CREATED)
|
||||
assert len(history) == 2
|
||||
|
||||
def test_history_filter_by_space_id(self, bus):
|
||||
"""Test filtering history by space ID."""
|
||||
bus.emit(space_created_event("s1", "test1"))
|
||||
bus.emit(space_created_event("s2", "test2"))
|
||||
bus.emit(document_added_event("s1", "doc-1", "/intro.md"))
|
||||
|
||||
history = bus.get_history(space_id="s1")
|
||||
assert len(history) == 2
|
||||
|
||||
def test_history_limit(self, bus):
|
||||
"""Test history limit."""
|
||||
for i in range(10):
|
||||
bus.emit(space_created_event(f"s{i}", f"test{i}"))
|
||||
|
||||
history = bus.get_history(limit=3)
|
||||
assert len(history) == 3
|
||||
|
||||
def test_history_max_size(self):
|
||||
"""Test that history respects max size."""
|
||||
bus = EventBus()
|
||||
bus.enable_history(max_events=5)
|
||||
|
||||
for i in range(10):
|
||||
bus.emit(space_created_event(f"s{i}", f"test{i}"))
|
||||
|
||||
history = bus.get_history()
|
||||
assert len(history) == 5
|
||||
# Should have the most recent events
|
||||
assert history[0].payload["name"] == "test9"
|
||||
|
||||
def test_clear_history(self, bus):
|
||||
"""Test clearing history."""
|
||||
bus.emit(space_created_event("s1", "test"))
|
||||
bus.emit(space_created_event("s2", "test"))
|
||||
|
||||
bus.clear_history()
|
||||
|
||||
history = bus.get_history()
|
||||
assert len(history) == 0
|
||||
|
||||
def test_disable_history(self, bus):
|
||||
"""Test disabling history."""
|
||||
bus.emit(space_created_event("s1", "test"))
|
||||
bus.disable_history()
|
||||
bus.emit(space_created_event("s2", "test"))
|
||||
|
||||
history = bus.get_history()
|
||||
assert len(history) == 1
|
||||
|
||||
|
||||
class TestEventBusAsync:
|
||||
"""Tests for async EventBus functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def bus(self):
|
||||
"""Create a fresh EventBus."""
|
||||
return EventBus()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_handler(self, bus):
|
||||
"""Test async event handler."""
|
||||
received = []
|
||||
|
||||
async def async_handler(event: SpaceEvent):
|
||||
await asyncio.sleep(0.01)
|
||||
received.append(event)
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, async_handler)
|
||||
|
||||
await bus.emit_async(space_created_event("s1", "test"))
|
||||
|
||||
assert len(received) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_sync_async_handlers(self, bus):
|
||||
"""Test mixing sync and async handlers."""
|
||||
sync_received = []
|
||||
async_received = []
|
||||
|
||||
def sync_handler(event: SpaceEvent):
|
||||
sync_received.append(event)
|
||||
|
||||
async def async_handler(event: SpaceEvent):
|
||||
await asyncio.sleep(0.01)
|
||||
async_received.append(event)
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, sync_handler)
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, async_handler)
|
||||
|
||||
await bus.emit_async(space_created_event("s1", "test"))
|
||||
|
||||
assert len(sync_received) == 1
|
||||
assert len(async_received) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_handler_exception(self, bus):
|
||||
"""Test that async handler exceptions are caught."""
|
||||
async def failing_handler(event: SpaceEvent):
|
||||
raise ValueError("Async error")
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, failing_handler)
|
||||
|
||||
exceptions = await bus.emit_async(space_created_event("s1", "test"))
|
||||
|
||||
assert len(exceptions) == 1
|
||||
assert isinstance(exceptions[0], ValueError)
|
||||
|
||||
|
||||
class TestGlobalEventBus:
|
||||
"""Tests for global event bus singleton."""
|
||||
|
||||
def test_get_event_bus_returns_same_instance(self):
|
||||
"""Test that get_event_bus returns the same instance."""
|
||||
reset_event_bus()
|
||||
bus1 = get_event_bus()
|
||||
bus2 = get_event_bus()
|
||||
|
||||
assert bus1 is bus2
|
||||
|
||||
def test_reset_event_bus(self):
|
||||
"""Test that reset_event_bus creates new instance."""
|
||||
bus1 = get_event_bus()
|
||||
reset_event_bus()
|
||||
bus2 = get_event_bus()
|
||||
|
||||
assert bus1 is not bus2
|
||||
|
||||
def test_reset_clears_handlers(self):
|
||||
"""Test that reset clears all handlers."""
|
||||
bus = get_event_bus()
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, lambda e: None)
|
||||
|
||||
assert bus.handler_count() == 1
|
||||
|
||||
reset_event_bus()
|
||||
new_bus = get_event_bus()
|
||||
|
||||
assert new_bus.handler_count() == 0
|
||||
Reference in New Issue
Block a user