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>
582 lines
19 KiB
Python
582 lines
19 KiB
Python
"""
|
|
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
|