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>
243 lines
8.8 KiB
Go
243 lines
8.8 KiB
Go
package aether
|
|
|
|
import "testing"
|
|
|
|
func TestMatchNamespacePattern(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pattern string
|
|
namespace string
|
|
expected bool
|
|
}{
|
|
// Exact matches
|
|
{"exact match", "tenant-a", "tenant-a", true},
|
|
{"exact mismatch", "tenant-a", "tenant-b", false},
|
|
{"exact match with dots", "prod.tenant.a", "prod.tenant.a", true},
|
|
{"exact mismatch with dots", "prod.tenant.a", "prod.tenant.b", false},
|
|
|
|
// Empty cases
|
|
{"empty pattern", "", "tenant-a", false},
|
|
{"empty namespace exact", "tenant-a", "", false},
|
|
{"empty namespace catch-all", ">", "", false},
|
|
{"both empty", "", "", false},
|
|
|
|
// Single wildcard (*) - matches one token (NATS semantics: tokens are dot-separated)
|
|
{"star matches any single token", "*", "tenant-a", true},
|
|
{"star matches any single token 2", "*", "anything", true},
|
|
{"star does not match multi-token", "*", "prod.tenant", false},
|
|
{"prefix with star", "prod.*", "prod.tenant", true},
|
|
{"prefix with star 2", "prod.*", "prod.orders", true},
|
|
{"prefix with star no match extra tokens", "prod.*", "prod.tenant.orders", false},
|
|
{"prefix with star no match wrong prefix", "prod.*", "staging.tenant", false},
|
|
{"middle wildcard", "prod.*.orders", "prod.tenant.orders", true},
|
|
{"middle wildcard no match", "prod.*.orders", "prod.tenant.events", false},
|
|
{"multiple stars", "*.tenant.*", "prod.tenant.orders", true},
|
|
{"multiple stars 2", "*.*.orders", "prod.tenant.orders", true},
|
|
{"multiple stars no match", "*.*.orders", "prod.orders", false},
|
|
|
|
// Multi-token wildcard (>) - matches one or more tokens
|
|
{"greater matches one", ">", "tenant", true},
|
|
{"greater matches multi", ">", "prod.tenant.orders", true},
|
|
{"prefix greater", "prod.>", "prod.tenant", true},
|
|
{"prefix greater multi", "prod.>", "prod.tenant.orders.items", true},
|
|
{"prefix greater no match different prefix", "prod.>", "staging.tenant", false},
|
|
{"prefix greater requires at least one", "prod.>", "prod", false},
|
|
{"deep prefix greater", "prod.tenant.>", "prod.tenant.orders", true},
|
|
|
|
// Combined wildcards
|
|
{"star then greater", "*.>", "prod.tenant", true},
|
|
{"star then greater multi", "*.>", "prod.tenant.orders", true},
|
|
{"star then greater no match single", "*.>", "prod", false},
|
|
|
|
// Edge cases
|
|
{"trailing dot in pattern", "tenant.", "tenant.", true},
|
|
{"just dots", "..", "..", true},
|
|
{"star at end", "prod.tenant.*", "prod.tenant.a", true},
|
|
{"star at end no match", "prod.tenant.*", "prod.other.a", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := MatchNamespacePattern(tt.pattern, tt.namespace)
|
|
if result != tt.expected {
|
|
t.Errorf("MatchNamespacePattern(%q, %q) = %v, want %v",
|
|
tt.pattern, tt.namespace, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsWildcardPattern(t *testing.T) {
|
|
tests := []struct {
|
|
pattern string
|
|
expected bool
|
|
}{
|
|
{"tenant-a", false},
|
|
{"prod.tenant.orders", false},
|
|
{"*", true},
|
|
{"prod.*", true},
|
|
{"*.orders", true},
|
|
{">", true},
|
|
{"prod.>", true},
|
|
{"*.>", true},
|
|
{"prod.*.orders", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.pattern, func(t *testing.T) {
|
|
result := IsWildcardPattern(tt.pattern)
|
|
if result != tt.expected {
|
|
t.Errorf("IsWildcardPattern(%q) = %v, want %v",
|
|
tt.pattern, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func BenchmarkMatchNamespacePattern(b *testing.B) {
|
|
benchmarks := []struct {
|
|
name string
|
|
pattern string
|
|
namespace string
|
|
}{
|
|
{"exact", "tenant-a", "tenant-a"},
|
|
{"star", "*", "tenant-a"},
|
|
{"prefix_star", "prod.*", "prod.tenant"},
|
|
{"greater", ">", "prod.tenant.orders"},
|
|
{"complex", "prod.*.>", "prod.tenant.orders.items"},
|
|
}
|
|
|
|
for _, bm := range benchmarks {
|
|
b.Run(bm.name, func(b *testing.B) {
|
|
for i := 0; i < b.N; i++ {
|
|
MatchNamespacePattern(bm.pattern, bm.namespace)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|