Add namespace event filtering (SubscribeWithFilter)
All checks were successful
CI / build (pull_request) Successful in 19s
CI / build (push) Successful in 39s

Adds support for filtering events by type or actor pattern within namespace
subscriptions. Key changes:

- Add SubscriptionFilter type with EventTypes and ActorPattern fields
- Add SubscribeWithFilter to EventBroadcaster interface
- Implement filtering in EventBus with full wildcard pattern support preserved
- Implement filtering in NATSEventBus (server-side namespace, client-side filters)
- Add MatchActorPattern function for actor ID pattern matching
- Add comprehensive unit tests for all filtering scenarios

Filter Processing:
- EventTypes: Event must match at least one type in the list (OR within types)
- ActorPattern: Event's ActorID must match the pattern (supports * and > wildcards)
- Multiple filters are combined with AND logic

This implementation works alongside the existing wildcard subscription support:
- Namespace wildcards (* and >) work with event filters
- Filters are applied after namespace pattern matching
- Metrics are properly recorded for filtered subscriptions

Closes #21

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit was merged in pull request #54.
This commit is contained in:
2026-01-10 23:45:57 +01:00
parent e3dbe3d52d
commit ef73fb6bfd
5 changed files with 750 additions and 38 deletions

View File

@@ -115,3 +115,128 @@ func BenchmarkMatchNamespacePattern(b *testing.B) {
})
}
}
func TestMatchActorPattern(t *testing.T) {
tests := []struct {
name string
pattern string
actorID string
expected bool
}{
// Empty cases
{"empty pattern", "", "actor-123", false},
{"empty actorID", "actor-*", "", false},
{"both empty", "", "", false},
// Exact matches (no dots)
{"exact match", "actor-123", "actor-123", true},
{"exact mismatch", "actor-123", "actor-456", false},
// Suffix wildcard with * (simple, no dots)
{"prefix with star", "order-*", "order-123", true},
{"prefix with star 2", "order-*", "order-456-xyz", true},
{"prefix with star mismatch", "order-*", "user-123", false},
{"star alone", "*", "anything", true},
// Suffix wildcard with > (simple, no dots)
{"prefix with greater", "order->", "order-123", true},
{"greater alone", ">", "anything", true},
// Dot-separated actor IDs (uses MatchNamespacePattern)
{"dotted exact match", "order.us.123", "order.us.123", true},
{"dotted exact mismatch", "order.us.123", "order.eu.123", false},
{"dotted star", "order.*", "order.123", true},
{"dotted star deep", "order.*.*", "order.us.123", true},
{"dotted greater", "order.>", "order.us.123.456", true},
{"dotted star mismatch depth", "order.*", "order.us.123", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MatchActorPattern(tt.pattern, tt.actorID)
if result != tt.expected {
t.Errorf("MatchActorPattern(%q, %q) = %v, want %v",
tt.pattern, tt.actorID, result, tt.expected)
}
})
}
}
func TestSubscriptionFilter_IsEmpty(t *testing.T) {
tests := []struct {
name string
filter *SubscriptionFilter
expected bool
}{
{"nil fields", &SubscriptionFilter{}, true},
{"empty slice", &SubscriptionFilter{EventTypes: []string{}}, true},
{"has event types", &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}}, false},
{"has actor pattern", &SubscriptionFilter{ActorPattern: "order-*"}, false},
{"has both", &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}, ActorPattern: "order-*"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.filter.IsEmpty()
if result != tt.expected {
t.Errorf("SubscriptionFilter.IsEmpty() = %v, want %v", result, tt.expected)
}
})
}
}
func TestSubscriptionFilter_Matches(t *testing.T) {
tests := []struct {
name string
filter *SubscriptionFilter
event *Event
expected bool
}{
// Nil event
{"nil event", &SubscriptionFilter{}, nil, false},
// Empty filter matches all
{"empty filter", &SubscriptionFilter{}, &Event{EventType: "Test", ActorID: "actor-1"}, true},
// Event type filtering
{"event type match", &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}},
&Event{EventType: "OrderPlaced", ActorID: "order-1"}, true},
{"event type mismatch", &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}},
&Event{EventType: "OrderShipped", ActorID: "order-1"}, false},
{"event type multiple match first", &SubscriptionFilter{EventTypes: []string{"OrderPlaced", "OrderShipped"}},
&Event{EventType: "OrderPlaced", ActorID: "order-1"}, true},
{"event type multiple match second", &SubscriptionFilter{EventTypes: []string{"OrderPlaced", "OrderShipped"}},
&Event{EventType: "OrderShipped", ActorID: "order-1"}, true},
{"event type multiple no match", &SubscriptionFilter{EventTypes: []string{"OrderPlaced", "OrderShipped"}},
&Event{EventType: "OrderCancelled", ActorID: "order-1"}, false},
// Actor pattern filtering
{"actor pattern exact match", &SubscriptionFilter{ActorPattern: "order-123"},
&Event{EventType: "Test", ActorID: "order-123"}, true},
{"actor pattern exact mismatch", &SubscriptionFilter{ActorPattern: "order-123"},
&Event{EventType: "Test", ActorID: "order-456"}, false},
{"actor pattern wildcard match", &SubscriptionFilter{ActorPattern: "order-*"},
&Event{EventType: "Test", ActorID: "order-123"}, true},
{"actor pattern wildcard mismatch", &SubscriptionFilter{ActorPattern: "order-*"},
&Event{EventType: "Test", ActorID: "user-123"}, false},
// Combined filters (AND logic)
{"combined both match", &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}, ActorPattern: "order-*"},
&Event{EventType: "OrderPlaced", ActorID: "order-123"}, true},
{"combined event matches actor does not", &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}, ActorPattern: "order-*"},
&Event{EventType: "OrderPlaced", ActorID: "user-123"}, false},
{"combined actor matches event does not", &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}, ActorPattern: "order-*"},
&Event{EventType: "OrderShipped", ActorID: "order-123"}, false},
{"combined neither matches", &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}, ActorPattern: "order-*"},
&Event{EventType: "OrderShipped", ActorID: "user-123"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.filter.Matches(tt.event)
if result != tt.expected {
t.Errorf("SubscriptionFilter.Matches() = %v, want %v", result, tt.expected)
}
})
}
}