Add event metadata support for distributed tracing and auditing
All checks were successful
CI / build (push) Successful in 15s

- Add Metadata field (map[string]string) to Event struct with omitempty
- Add helper methods for common metadata: SetCorrelationID/GetCorrelationID,
  SetCausationID/GetCausationID, SetUserID/GetUserID, SetTraceID/GetTraceID,
  SetSpanID/GetSpanID
- Add WithMetadataFrom helper for copying metadata between events
- Add metadata key constants for standard fields
- Add comprehensive unit tests for metadata serialization and helpers
- Add store tests verifying metadata persistence

Closes #7

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit was merged in pull request #33.
This commit is contained in:
2026-01-09 17:53:05 +01:00
parent 02847bdaf5
commit 7d3acd89ed
3 changed files with 781 additions and 0 deletions

View File

@@ -740,3 +740,471 @@ func TestActorSnapshot_VersionEdgeCases(t *testing.T) {
})
}
}
// Tests for Event Metadata support
func TestEvent_MetadataJSONSerialization(t *testing.T) {
ts := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
event := &Event{
ID: "evt-meta",
EventType: "OrderPlaced",
ActorID: "order-456",
Version: 1,
Data: map[string]interface{}{"total": 100.50},
Metadata: map[string]string{
"correlationId": "corr-123",
"causationId": "cause-456",
"userId": "user-789",
},
Timestamp: ts,
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event with metadata: %v", err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal Event with metadata: %v", err)
}
if decoded.Metadata == nil {
t.Fatal("expected Metadata to be present after unmarshal")
}
if decoded.Metadata["correlationId"] != "corr-123" {
t.Errorf("correlationId mismatch: got %q, want %q", decoded.Metadata["correlationId"], "corr-123")
}
if decoded.Metadata["causationId"] != "cause-456" {
t.Errorf("causationId mismatch: got %q, want %q", decoded.Metadata["causationId"], "cause-456")
}
if decoded.Metadata["userId"] != "user-789" {
t.Errorf("userId mismatch: got %q, want %q", decoded.Metadata["userId"], "user-789")
}
}
func TestEvent_MetadataOmitEmpty(t *testing.T) {
event := &Event{
ID: "evt-no-meta",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
Data: map[string]interface{}{},
Metadata: nil,
Timestamp: time.Now(),
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event: %v", err)
}
if strings.Contains(string(data), `"metadata"`) {
t.Error("expected metadata to be omitted when nil")
}
}
func TestEvent_EmptyMetadata(t *testing.T) {
event := &Event{
ID: "evt-empty-meta",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
Data: map[string]interface{}{},
Metadata: map[string]string{},
Timestamp: time.Now(),
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event with empty metadata: %v", err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal Event with empty metadata: %v", err)
}
// Empty map should be omitted with omitempty
if strings.Contains(string(data), `"metadata"`) {
t.Error("expected empty metadata to be omitted")
}
}
func TestEvent_SetMetadata(t *testing.T) {
event := &Event{
ID: "evt-set-meta",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
// Metadata should be nil initially
if event.Metadata != nil {
t.Error("expected Metadata to be nil initially")
}
// SetMetadata should initialize the map
event.SetMetadata("key1", "value1")
if event.Metadata == nil {
t.Fatal("expected Metadata to be initialized after SetMetadata")
}
if event.Metadata["key1"] != "value1" {
t.Errorf("key1 mismatch: got %q, want %q", event.Metadata["key1"], "value1")
}
// SetMetadata should work with existing map
event.SetMetadata("key2", "value2")
if event.Metadata["key2"] != "value2" {
t.Errorf("key2 mismatch: got %q, want %q", event.Metadata["key2"], "value2")
}
if event.Metadata["key1"] != "value1" {
t.Errorf("key1 was overwritten: got %q", event.Metadata["key1"])
}
}
func TestEvent_GetMetadata(t *testing.T) {
event := &Event{
ID: "evt-get-meta",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
// GetMetadata on nil map should return empty string
if got := event.GetMetadata("nonexistent"); got != "" {
t.Errorf("expected empty string for nil Metadata, got %q", got)
}
// Initialize metadata and test
event.Metadata = map[string]string{"key": "value"}
if got := event.GetMetadata("key"); got != "value" {
t.Errorf("GetMetadata mismatch: got %q, want %q", got, "value")
}
// Non-existent key should return empty string
if got := event.GetMetadata("nonexistent"); got != "" {
t.Errorf("expected empty string for nonexistent key, got %q", got)
}
}
func TestEvent_CorrelationIDHelpers(t *testing.T) {
event := &Event{
ID: "evt-corr",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
// GetCorrelationID on nil metadata
if got := event.GetCorrelationID(); got != "" {
t.Errorf("expected empty correlation ID, got %q", got)
}
// SetCorrelationID
event.SetCorrelationID("corr-abc-123")
if got := event.GetCorrelationID(); got != "corr-abc-123" {
t.Errorf("GetCorrelationID mismatch: got %q, want %q", got, "corr-abc-123")
}
// Verify it uses the correct metadata key
if event.Metadata[MetadataKeyCorrelationID] != "corr-abc-123" {
t.Errorf("expected correlationId key to be set")
}
}
func TestEvent_CausationIDHelpers(t *testing.T) {
event := &Event{
ID: "evt-cause",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
// GetCausationID on nil metadata
if got := event.GetCausationID(); got != "" {
t.Errorf("expected empty causation ID, got %q", got)
}
// SetCausationID
event.SetCausationID("cause-xyz-789")
if got := event.GetCausationID(); got != "cause-xyz-789" {
t.Errorf("GetCausationID mismatch: got %q, want %q", got, "cause-xyz-789")
}
// Verify it uses the correct metadata key
if event.Metadata[MetadataKeyCausationID] != "cause-xyz-789" {
t.Errorf("expected causationId key to be set")
}
}
func TestEvent_UserIDHelpers(t *testing.T) {
event := &Event{
ID: "evt-user",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
event.SetUserID("user-john-doe")
if got := event.GetUserID(); got != "user-john-doe" {
t.Errorf("GetUserID mismatch: got %q, want %q", got, "user-john-doe")
}
if event.Metadata[MetadataKeyUserID] != "user-john-doe" {
t.Errorf("expected userId key to be set")
}
}
func TestEvent_TraceIDHelpers(t *testing.T) {
event := &Event{
ID: "evt-trace",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
event.SetTraceID("trace-abc-def-ghi")
if got := event.GetTraceID(); got != "trace-abc-def-ghi" {
t.Errorf("GetTraceID mismatch: got %q, want %q", got, "trace-abc-def-ghi")
}
if event.Metadata[MetadataKeyTraceID] != "trace-abc-def-ghi" {
t.Errorf("expected traceId key to be set")
}
}
func TestEvent_SpanIDHelpers(t *testing.T) {
event := &Event{
ID: "evt-span",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
event.SetSpanID("span-123-456")
if got := event.GetSpanID(); got != "span-123-456" {
t.Errorf("GetSpanID mismatch: got %q, want %q", got, "span-123-456")
}
if event.Metadata[MetadataKeySpanID] != "span-123-456" {
t.Errorf("expected spanId key to be set")
}
}
func TestEvent_WithMetadataFrom(t *testing.T) {
source := &Event{
ID: "evt-source",
EventType: "SourceEvent",
ActorID: "actor-123",
Version: 1,
Metadata: map[string]string{
"correlationId": "corr-123",
"causationId": "cause-456",
"customKey": "customValue",
},
}
target := &Event{
ID: "evt-target",
EventType: "TargetEvent",
ActorID: "actor-456",
Version: 2,
}
// Copy metadata from source to target
target.WithMetadataFrom(source)
if target.Metadata == nil {
t.Fatal("expected Metadata to be initialized after WithMetadataFrom")
}
if target.Metadata["correlationId"] != "corr-123" {
t.Errorf("correlationId not copied: got %q", target.Metadata["correlationId"])
}
if target.Metadata["causationId"] != "cause-456" {
t.Errorf("causationId not copied: got %q", target.Metadata["causationId"])
}
if target.Metadata["customKey"] != "customValue" {
t.Errorf("customKey not copied: got %q", target.Metadata["customKey"])
}
}
func TestEvent_WithMetadataFromNilSource(t *testing.T) {
target := &Event{
ID: "evt-target",
EventType: "TargetEvent",
ActorID: "actor-456",
Version: 2,
}
// WithMetadataFrom should handle nil source gracefully
target.WithMetadataFrom(nil)
if target.Metadata != nil {
t.Error("expected Metadata to remain nil after WithMetadataFrom(nil)")
}
// WithMetadataFrom should handle source with nil metadata
source := &Event{
ID: "evt-source",
EventType: "SourceEvent",
ActorID: "actor-123",
Version: 1,
Metadata: nil,
}
target.WithMetadataFrom(source)
if target.Metadata != nil {
t.Error("expected Metadata to remain nil when source has nil Metadata")
}
}
func TestEvent_WithMetadataFromPreservesExisting(t *testing.T) {
source := &Event{
ID: "evt-source",
EventType: "SourceEvent",
ActorID: "actor-123",
Version: 1,
Metadata: map[string]string{
"sourceKey": "sourceValue",
},
}
target := &Event{
ID: "evt-target",
EventType: "TargetEvent",
ActorID: "actor-456",
Version: 2,
Metadata: map[string]string{
"existingKey": "existingValue",
},
}
target.WithMetadataFrom(source)
// Both keys should be present
if target.Metadata["existingKey"] != "existingValue" {
t.Errorf("existingKey was lost: got %q", target.Metadata["existingKey"])
}
if target.Metadata["sourceKey"] != "sourceValue" {
t.Errorf("sourceKey not copied: got %q", target.Metadata["sourceKey"])
}
}
func TestEvent_MetadataSpecialCharacters(t *testing.T) {
event := &Event{
ID: "evt-special-meta",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
Metadata: map[string]string{
"unicode": "Hello, \u4e16\u754c!",
"newlines": "line1\nline2",
"quotes": `"quoted"`,
"backslash": `path\to\file`,
"empty": "",
"whitespace": " spaces ",
},
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event with special metadata: %v", err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal Event with special metadata: %v", err)
}
for key, expected := range event.Metadata {
if decoded.Metadata[key] != expected {
t.Errorf("Metadata[%q] mismatch: got %q, want %q", key, decoded.Metadata[key], expected)
}
}
}
func TestMetadataKeyConstants(t *testing.T) {
// Verify the constant values are as expected
if MetadataKeyCorrelationID != "correlationId" {
t.Errorf("MetadataKeyCorrelationID wrong: got %q", MetadataKeyCorrelationID)
}
if MetadataKeyCausationID != "causationId" {
t.Errorf("MetadataKeyCausationID wrong: got %q", MetadataKeyCausationID)
}
if MetadataKeyUserID != "userId" {
t.Errorf("MetadataKeyUserID wrong: got %q", MetadataKeyUserID)
}
if MetadataKeyTraceID != "traceId" {
t.Errorf("MetadataKeyTraceID wrong: got %q", MetadataKeyTraceID)
}
if MetadataKeySpanID != "spanId" {
t.Errorf("MetadataKeySpanID wrong: got %q", MetadataKeySpanID)
}
}
func TestEvent_MetadataJSONFieldName(t *testing.T) {
event := &Event{
ID: "evt-field-name",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
Data: map[string]interface{}{},
Metadata: map[string]string{
"key": "value",
},
Timestamp: time.Now(),
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event: %v", err)
}
// Check for correct JSON field name (camelCase)
if !strings.Contains(string(data), `"metadata"`) {
t.Errorf("expected 'metadata' JSON field, got: %s", string(data))
}
}
func TestEvent_MetadataAllHelpersRoundTrip(t *testing.T) {
// Test that all helper methods work together in a roundtrip
event := &Event{
ID: "evt-all-helpers",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
Data: map[string]interface{}{},
Timestamp: time.Now(),
}
event.SetCorrelationID("corr-1")
event.SetCausationID("cause-2")
event.SetUserID("user-3")
event.SetTraceID("trace-4")
event.SetSpanID("span-5")
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event: %v", err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal Event: %v", err)
}
if decoded.GetCorrelationID() != "corr-1" {
t.Errorf("GetCorrelationID mismatch: got %q", decoded.GetCorrelationID())
}
if decoded.GetCausationID() != "cause-2" {
t.Errorf("GetCausationID mismatch: got %q", decoded.GetCausationID())
}
if decoded.GetUserID() != "user-3" {
t.Errorf("GetUserID mismatch: got %q", decoded.GetUserID())
}
if decoded.GetTraceID() != "trace-4" {
t.Errorf("GetTraceID mismatch: got %q", decoded.GetTraceID())
}
if decoded.GetSpanID() != "span-5" {
t.Errorf("GetSpanID mismatch: got %q", decoded.GetSpanID())
}
}