Files
markitect-main/tests/integration/spaces/test_event_propagation.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

408 lines
15 KiB
Python

"""
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