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>
408 lines
15 KiB
Python
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
|