feat(event sourcing): Publish EventStored event after successful SaveEvent
Implement EventStored infrastructure event that notifies subscribers when an event is successfully persisted. This enables observability and triggers downstream workflows (caching, metrics, projections) without coupling to application events. Changes: - Add EventStored type to event.go containing EventID, ActorID, Version, Timestamp - Update InMemoryEventStore with optional EventBus and metrics support via builder methods - Update JetStreamEventStore with optional EventBus and metrics support via builder methods - Publish EventStored to __internal__ namespace after successful SaveEvent - EventStored not published if SaveEvent fails (e.g., version conflict) - EventStored publishing is optional - stores work without EventBus configured - Metrics are recorded for each EventStored publication - Add comprehensive test suite covering all acceptance criteria Meets acceptance criteria: - EventStored published after SaveEvent succeeds - EventStored contains EventID, ActorID, Version, Timestamp - No EventStored published if SaveEvent fails - EventBus receives EventStored in same operation - Metrics increment for each EventStored published Closes #61 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
362
store/eventstored_test.go
Normal file
362
store/eventstored_test.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.flowmade.one/flowmade-one/aether"
|
||||
)
|
||||
|
||||
// MockEventBroadcaster captures published events for testing
|
||||
type MockEventBroadcaster struct {
|
||||
mu sync.RWMutex
|
||||
events []*aether.Event
|
||||
namespaces map[string]int
|
||||
}
|
||||
|
||||
func NewMockEventBroadcaster() *MockEventBroadcaster {
|
||||
return &MockEventBroadcaster{
|
||||
events: make([]*aether.Event, 0),
|
||||
namespaces: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockEventBroadcaster) Subscribe(namespacePattern string) <-chan *aether.Event {
|
||||
return make(chan *aether.Event)
|
||||
}
|
||||
|
||||
func (m *MockEventBroadcaster) SubscribeWithFilter(namespacePattern string, filter *aether.SubscriptionFilter) <-chan *aether.Event {
|
||||
return make(chan *aether.Event)
|
||||
}
|
||||
|
||||
func (m *MockEventBroadcaster) Unsubscribe(namespacePattern string, ch <-chan *aether.Event) {}
|
||||
|
||||
func (m *MockEventBroadcaster) Publish(namespaceID string, event *aether.Event) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.events = append(m.events, event)
|
||||
m.namespaces[namespaceID]++
|
||||
}
|
||||
|
||||
func (m *MockEventBroadcaster) Stop() {}
|
||||
|
||||
func (m *MockEventBroadcaster) SubscriberCount(namespaceID string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *MockEventBroadcaster) GetPublishedEvents() []*aether.Event {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
events := make([]*aether.Event, len(m.events))
|
||||
copy(events, m.events)
|
||||
return events
|
||||
}
|
||||
|
||||
// === InMemoryEventStore EventStored Tests ===
|
||||
|
||||
func TestEventStored_PublishedOnSaveSuccess(t *testing.T) {
|
||||
store := NewInMemoryEventStore()
|
||||
mockBus := NewMockEventBroadcaster()
|
||||
store.WithEventBus(mockBus)
|
||||
|
||||
event := &aether.Event{
|
||||
ID: "evt-123",
|
||||
EventType: "OrderPlaced",
|
||||
ActorID: "order-456",
|
||||
Version: 1,
|
||||
Data: map[string]interface{}{"total": 100.50},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
err := store.SaveEvent(event)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveEvent failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify EventStored was published
|
||||
published := mockBus.GetPublishedEvents()
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("expected 1 published event, got %d", len(published))
|
||||
}
|
||||
|
||||
storedEvent := published[0]
|
||||
if storedEvent.EventType != "EventStored" {
|
||||
t.Errorf("expected EventType 'EventStored', got %q", storedEvent.EventType)
|
||||
}
|
||||
if storedEvent.ActorID != "order-456" {
|
||||
t.Errorf("expected ActorID 'order-456', got %q", storedEvent.ActorID)
|
||||
}
|
||||
if storedEvent.Data["eventId"] != "evt-123" {
|
||||
t.Errorf("expected eventId 'evt-123', got %v", storedEvent.Data["eventId"])
|
||||
}
|
||||
if storedEvent.Data["version"] != int64(1) {
|
||||
t.Errorf("expected version 1, got %v", storedEvent.Data["version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStored_NotPublishedOnVersionConflict(t *testing.T) {
|
||||
store := NewInMemoryEventStore()
|
||||
mockBus := NewMockEventBroadcaster()
|
||||
store.WithEventBus(mockBus)
|
||||
|
||||
// Save first event
|
||||
event1 := &aether.Event{
|
||||
ID: "evt-1",
|
||||
EventType: "TestEvent",
|
||||
ActorID: "actor-1",
|
||||
Version: 1,
|
||||
Data: map[string]interface{}{},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := store.SaveEvent(event1); err != nil {
|
||||
t.Fatalf("First SaveEvent failed: %v", err)
|
||||
}
|
||||
|
||||
// Try to save event with same version (conflict)
|
||||
event2 := &aether.Event{
|
||||
ID: "evt-2",
|
||||
EventType: "TestEvent",
|
||||
ActorID: "actor-1",
|
||||
Version: 1, // Same version - should conflict
|
||||
Data: map[string]interface{}{},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
err := store.SaveEvent(event2)
|
||||
if err == nil {
|
||||
t.Fatal("expected VersionConflictError, got nil")
|
||||
}
|
||||
|
||||
// Verify only 1 EventStored was published (from first event)
|
||||
published := mockBus.GetPublishedEvents()
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("expected 1 published event after conflict, got %d", len(published))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStored_MultipleEventsPublished(t *testing.T) {
|
||||
store := NewInMemoryEventStore()
|
||||
mockBus := NewMockEventBroadcaster()
|
||||
store.WithEventBus(mockBus)
|
||||
|
||||
// Save 5 events
|
||||
for i := 1; i <= 5; i++ {
|
||||
event := &aether.Event{
|
||||
ID: fmt.Sprintf("evt-%d", i),
|
||||
EventType: "TestEvent",
|
||||
ActorID: "actor-1",
|
||||
Version: int64(i),
|
||||
Data: map[string]interface{}{},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := store.SaveEvent(event); err != nil {
|
||||
t.Fatalf("SaveEvent %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify 5 EventStored events were published
|
||||
published := mockBus.GetPublishedEvents()
|
||||
if len(published) != 5 {
|
||||
t.Fatalf("expected 5 published events, got %d", len(published))
|
||||
}
|
||||
|
||||
// Verify each has correct data
|
||||
for i := 0; i < 5; i++ {
|
||||
if published[i].Data["version"] != int64(i+1) {
|
||||
t.Errorf("event %d: expected version %d, got %v", i, i+1, published[i].Data["version"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStored_NotPublishedWithoutEventBus(t *testing.T) {
|
||||
store := NewInMemoryEventStore()
|
||||
// Don't set event bus
|
||||
|
||||
event := &aether.Event{
|
||||
ID: "evt-123",
|
||||
EventType: "OrderPlaced",
|
||||
ActorID: "order-456",
|
||||
Version: 1,
|
||||
Data: map[string]interface{}{},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// Should succeed without publishing (no-op)
|
||||
err := store.SaveEvent(event)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveEvent failed: %v", err)
|
||||
}
|
||||
|
||||
// Event should be persisted normally
|
||||
retrieved, err := store.GetEvents("order-456", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEvents failed: %v", err)
|
||||
}
|
||||
if len(retrieved) != 1 {
|
||||
t.Errorf("expected 1 event, got %d", len(retrieved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStored_ContainsRequiredFields(t *testing.T) {
|
||||
store := NewInMemoryEventStore()
|
||||
mockBus := NewMockEventBroadcaster()
|
||||
store.WithEventBus(mockBus)
|
||||
|
||||
event := &aether.Event{
|
||||
ID: "evt-abc",
|
||||
EventType: "TestEvent",
|
||||
ActorID: "actor-xyz",
|
||||
Version: 42,
|
||||
Data: map[string]interface{}{},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if err := store.SaveEvent(event); err != nil {
|
||||
t.Fatalf("SaveEvent failed: %v", err)
|
||||
}
|
||||
|
||||
published := mockBus.GetPublishedEvents()
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(published))
|
||||
}
|
||||
|
||||
storedEvent := published[0]
|
||||
|
||||
// Verify required fields
|
||||
if storedEvent.Data["eventId"] != "evt-abc" {
|
||||
t.Error("missing or incorrect eventId")
|
||||
}
|
||||
if storedEvent.Data["actorId"] != "actor-xyz" {
|
||||
t.Error("missing or incorrect actorId")
|
||||
}
|
||||
if storedEvent.Data["version"] != int64(42) {
|
||||
t.Error("missing or incorrect version")
|
||||
}
|
||||
if _, hasTimestamp := storedEvent.Data["timestamp"]; !hasTimestamp {
|
||||
t.Error("missing timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStored_PublishedToCorrectNamespace(t *testing.T) {
|
||||
store := NewInMemoryEventStore()
|
||||
mockBus := NewMockEventBroadcaster()
|
||||
store.WithEventBus(mockBus)
|
||||
|
||||
event := &aether.Event{
|
||||
ID: "evt-1",
|
||||
EventType: "TestEvent",
|
||||
ActorID: "actor-1",
|
||||
Version: 1,
|
||||
Data: map[string]interface{}{},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if err := store.SaveEvent(event); err != nil {
|
||||
t.Fatalf("SaveEvent failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify published to __internal__ namespace
|
||||
namespaces := mockBus.namespaces
|
||||
if count, ok := namespaces["__internal__"]; !ok || count != 1 {
|
||||
t.Errorf("expected 1 event published to __internal__, got %v", namespaces)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStored_WithMetricsRecording(t *testing.T) {
|
||||
store := NewInMemoryEventStore()
|
||||
mockBus := NewMockEventBroadcaster()
|
||||
mockMetrics := aether.NewMetricsCollector()
|
||||
|
||||
store.WithEventBus(mockBus)
|
||||
store.WithMetrics(mockMetrics)
|
||||
|
||||
event := &aether.Event{
|
||||
ID: "evt-1",
|
||||
EventType: "TestEvent",
|
||||
ActorID: "actor-1",
|
||||
Version: 1,
|
||||
Data: map[string]interface{}{},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if err := store.SaveEvent(event); err != nil {
|
||||
t.Fatalf("SaveEvent failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify metrics were recorded
|
||||
published := mockMetrics.EventsPublished("__internal__")
|
||||
if published != 1 {
|
||||
t.Errorf("expected 1 published metric, got %d", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStored_ConcurrentPublishing(t *testing.T) {
|
||||
store := NewInMemoryEventStore()
|
||||
mockBus := NewMockEventBroadcaster()
|
||||
store.WithEventBus(mockBus)
|
||||
|
||||
numGoroutines := 10
|
||||
eventsPerGoroutine := 5
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for g := 0; g < numGoroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < eventsPerGoroutine; i++ {
|
||||
version := int64(goroutineID*eventsPerGoroutine + i + 1)
|
||||
event := &aether.Event{
|
||||
ID: fmt.Sprintf("evt-%d-%d", goroutineID, i),
|
||||
EventType: "TestEvent",
|
||||
ActorID: fmt.Sprintf("actor-%d", goroutineID),
|
||||
Version: version,
|
||||
Data: map[string]interface{}{},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
_ = store.SaveEvent(event) // Ignore errors (some may conflict)
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify EventStored events were published for successful saves
|
||||
published := mockBus.GetPublishedEvents()
|
||||
if len(published) != numGoroutines*eventsPerGoroutine {
|
||||
t.Logf("Note: got %d published events (some saves may have conflicted)", len(published))
|
||||
}
|
||||
if len(published) == 0 {
|
||||
t.Fatal("expected at least some published events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStored_OrderPreserved(t *testing.T) {
|
||||
store := NewInMemoryEventStore()
|
||||
mockBus := NewMockEventBroadcaster()
|
||||
store.WithEventBus(mockBus)
|
||||
|
||||
// Save 3 events in order
|
||||
for i := 1; i <= 3; i++ {
|
||||
event := &aether.Event{
|
||||
ID: fmt.Sprintf("evt-%d", i),
|
||||
EventType: "TestEvent",
|
||||
ActorID: "actor-1",
|
||||
Version: int64(i),
|
||||
Data: map[string]interface{}{},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := store.SaveEvent(event); err != nil {
|
||||
t.Fatalf("SaveEvent %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
published := mockBus.GetPublishedEvents()
|
||||
|
||||
// Verify order is preserved
|
||||
for i := 0; i < 3; i++ {
|
||||
if published[i].Data["eventId"] != fmt.Sprintf("evt-%d", i+1) {
|
||||
t.Errorf("event %d: expected evt-%d, got %v", i, i+1, published[i].Data["eventId"])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user