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>
363 lines
9.3 KiB
Go
363 lines
9.3 KiB
Go
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"])
|
|
}
|
|
}
|
|
}
|