Files
aether/store/eventstored_test.go
Claude Code 0f89b07c0b
Some checks failed
CI / build (pull_request) Successful in 21s
CI / integration (pull_request) Failing after 2m1s
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>
2026-01-13 21:25:51 +01:00

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"])
}
}
}