Files
markitect-main/tests/unit/spaces/test_events.py
tegwick 0a494b2011 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>
2026-02-08 07:41:47 +01:00

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