Browse Source
[Notifications] Improving webhook notifications (#6965)
[Notifications] Improving webhook notifications (#6965)
* worker setup * fix tests * start worker * graceful worker drain * retry queue * migrate queue to watermill * adding filters and improvements * add the event type to the webhook message * eliminating redundant JSON serialization * resolve review comments * trigger actions * fix tests * typo fixes * read max_backoff_seconds from config * add more context to the dead letter * close the http response on errors * drain the http response body in case not empty * eliminate exported typesπpull/6993/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1236 additions and 40 deletions
-
7go.mod
-
6go.sum
-
64weed/notification/webhook/filter.go
-
225weed/notification/webhook/filter_test.go
-
44weed/notification/webhook/http.go
-
12weed/notification/webhook/http_test.go
-
182weed/notification/webhook/types.go
-
200weed/notification/webhook/webhook_queue.go
-
536weed/notification/webhook/webhook_queue_test.go
@ -0,0 +1,64 @@ |
|||
package webhook |
|||
|
|||
import ( |
|||
"strings" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
) |
|||
|
|||
type filter struct { |
|||
eventTypes map[eventType]bool |
|||
pathPrefixes []string |
|||
} |
|||
|
|||
func newFilter(cfg *config) *filter { |
|||
f := &filter{ |
|||
eventTypes: make(map[eventType]bool), |
|||
pathPrefixes: cfg.pathPrefixes, |
|||
} |
|||
|
|||
if len(cfg.eventTypes) == 0 { |
|||
f.eventTypes[eventTypeCreate] = true |
|||
f.eventTypes[eventTypeDelete] = true |
|||
f.eventTypes[eventTypeUpdate] = true |
|||
f.eventTypes[eventTypeRename] = true |
|||
} else { |
|||
for _, et := range cfg.eventTypes { |
|||
t := eventType(et) |
|||
if !t.valid() { |
|||
glog.Warningf("invalid event type: %v", t) |
|||
|
|||
continue |
|||
} |
|||
|
|||
f.eventTypes[t] = true |
|||
} |
|||
} |
|||
|
|||
return f |
|||
} |
|||
|
|||
func (f *filter) shouldPublish(key string, notification *filer_pb.EventNotification) bool { |
|||
if !f.matchesPath(key) { |
|||
return false |
|||
} |
|||
|
|||
eventType := detectEventType(notification) |
|||
|
|||
return f.eventTypes[eventType] |
|||
} |
|||
|
|||
func (f *filter) matchesPath(key string) bool { |
|||
if len(f.pathPrefixes) == 0 { |
|||
return true |
|||
} |
|||
|
|||
for _, prefix := range f.pathPrefixes { |
|||
if strings.HasPrefix(key, prefix) { |
|||
return true |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
@ -0,0 +1,225 @@ |
|||
package webhook |
|||
|
|||
import ( |
|||
"testing" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
) |
|||
|
|||
func TestFilterEventTypes(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
eventTypes []string |
|||
notification *filer_pb.EventNotification |
|||
expectedType eventType |
|||
shouldPublish bool |
|||
}{ |
|||
{ |
|||
name: "create event - allowed", |
|||
eventTypes: []string{"create", "delete"}, |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
|||
}, |
|||
expectedType: eventTypeCreate, |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "create event - not allowed", |
|||
eventTypes: []string{"delete", "update"}, |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
|||
}, |
|||
expectedType: eventTypeCreate, |
|||
shouldPublish: false, |
|||
}, |
|||
{ |
|||
name: "delete event - allowed", |
|||
eventTypes: []string{"create", "delete"}, |
|||
notification: &filer_pb.EventNotification{ |
|||
OldEntry: &filer_pb.Entry{Name: "test.txt"}, |
|||
}, |
|||
expectedType: eventTypeDelete, |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "update event - allowed", |
|||
eventTypes: []string{"update"}, |
|||
notification: &filer_pb.EventNotification{ |
|||
OldEntry: &filer_pb.Entry{Name: "test.txt"}, |
|||
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
|||
}, |
|||
expectedType: eventTypeUpdate, |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "rename event - allowed", |
|||
eventTypes: []string{"rename"}, |
|||
notification: &filer_pb.EventNotification{ |
|||
OldEntry: &filer_pb.Entry{Name: "old.txt"}, |
|||
NewEntry: &filer_pb.Entry{Name: "new.txt"}, |
|||
NewParentPath: "/new/path", |
|||
}, |
|||
expectedType: eventTypeRename, |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "rename event - not allowed", |
|||
eventTypes: []string{"create", "delete", "update"}, |
|||
notification: &filer_pb.EventNotification{ |
|||
OldEntry: &filer_pb.Entry{Name: "old.txt"}, |
|||
NewEntry: &filer_pb.Entry{Name: "new.txt"}, |
|||
NewParentPath: "/new/path", |
|||
}, |
|||
expectedType: eventTypeRename, |
|||
shouldPublish: false, |
|||
}, |
|||
{ |
|||
name: "all events allowed when empty", |
|||
eventTypes: []string{}, |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
|||
}, |
|||
expectedType: eventTypeCreate, |
|||
shouldPublish: true, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
cfg := &config{eventTypes: tt.eventTypes} |
|||
f := newFilter(cfg) |
|||
|
|||
eventType := detectEventType(tt.notification) |
|||
if eventType != tt.expectedType { |
|||
t.Errorf("detectEventType() = %v, want %v", eventType, tt.expectedType) |
|||
} |
|||
|
|||
shouldPublish := f.shouldPublish("/test/path", tt.notification) |
|||
if shouldPublish != tt.shouldPublish { |
|||
t.Errorf("shouldPublish() = %v, want %v", shouldPublish, tt.shouldPublish) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestFilterPathPrefixes(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
pathPrefixes []string |
|||
key string |
|||
shouldPublish bool |
|||
}{ |
|||
{ |
|||
name: "matches single prefix", |
|||
pathPrefixes: []string{"/data/"}, |
|||
key: "/data/file.txt", |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "matches one of multiple prefixes", |
|||
pathPrefixes: []string{"/data/", "/logs/", "/tmp/"}, |
|||
key: "/logs/app.log", |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "no match", |
|||
pathPrefixes: []string{"/data/", "/logs/"}, |
|||
key: "/other/file.txt", |
|||
shouldPublish: false, |
|||
}, |
|||
{ |
|||
name: "empty prefixes allows all", |
|||
pathPrefixes: []string{}, |
|||
key: "/any/path/file.txt", |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "exact prefix match", |
|||
pathPrefixes: []string{"/data"}, |
|||
key: "/data", |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "partial match not allowed", |
|||
pathPrefixes: []string{"/data/"}, |
|||
key: "/database/file.txt", |
|||
shouldPublish: false, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
cfg := &config{ |
|||
pathPrefixes: tt.pathPrefixes, |
|||
eventTypes: []string{"create"}, |
|||
} |
|||
f := newFilter(cfg) |
|||
|
|||
notification := &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
|||
} |
|||
|
|||
shouldPublish := f.shouldPublish(tt.key, notification) |
|||
if shouldPublish != tt.shouldPublish { |
|||
t.Errorf("shouldPublish() = %v, want %v", shouldPublish, tt.shouldPublish) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestFilterCombined(t *testing.T) { |
|||
cfg := &config{ |
|||
eventTypes: []string{"create", "update"}, |
|||
pathPrefixes: []string{"/data/", "/logs/"}, |
|||
} |
|||
f := newFilter(cfg) |
|||
|
|||
tests := []struct { |
|||
name string |
|||
key string |
|||
notification *filer_pb.EventNotification |
|||
shouldPublish bool |
|||
}{ |
|||
{ |
|||
name: "allowed event and path", |
|||
key: "/data/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "allowed event but wrong path", |
|||
key: "/other/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: false, |
|||
}, |
|||
{ |
|||
name: "wrong event but allowed path", |
|||
key: "/data/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
OldEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: false, |
|||
}, |
|||
{ |
|||
name: "wrong event and wrong path", |
|||
key: "/other/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
OldEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: false, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
shouldPublish := f.shouldPublish(tt.key, tt.notification) |
|||
if shouldPublish != tt.shouldPublish { |
|||
t.Errorf("shouldPublish() = %v, want %v", shouldPublish, tt.shouldPublish) |
|||
} |
|||
}) |
|||
} |
|||
} |
@ -0,0 +1,182 @@ |
|||
package webhook |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net/url" |
|||
"slices" |
|||
"strconv" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
"github.com/seaweedfs/seaweedfs/weed/util" |
|||
"google.golang.org/protobuf/proto" |
|||
) |
|||
|
|||
const ( |
|||
queueName = "webhook" |
|||
pubSubTopicName = "webhook_topic" |
|||
deadLetterTopic = "webhook_dead_letter" |
|||
) |
|||
|
|||
type eventType string |
|||
|
|||
const ( |
|||
eventTypeCreate eventType = "create" |
|||
eventTypeDelete eventType = "delete" |
|||
eventTypeUpdate eventType = "update" |
|||
eventTypeRename eventType = "rename" |
|||
) |
|||
|
|||
func (e eventType) valid() bool { |
|||
return slices.Contains([]eventType{ |
|||
eventTypeCreate, |
|||
eventTypeDelete, |
|||
eventTypeUpdate, |
|||
eventTypeRename, |
|||
}, |
|||
e, |
|||
) |
|||
} |
|||
|
|||
var ( |
|||
pubSubHandlerNameTemplate = func(n int) string { |
|||
return "webhook_handler_" + strconv.Itoa(n) |
|||
} |
|||
) |
|||
|
|||
type client interface { |
|||
sendMessage(message *webhookMessage) error |
|||
} |
|||
|
|||
type webhookMessage struct { |
|||
Key string `json:"key"` |
|||
EventType string `json:"event_type"` |
|||
Notification *filer_pb.EventNotification `json:"message_data"` |
|||
} |
|||
|
|||
func newWebhookMessage(key string, message proto.Message) *webhookMessage { |
|||
notification, ok := message.(*filer_pb.EventNotification) |
|||
if !ok { |
|||
return nil |
|||
} |
|||
|
|||
eventType := string(detectEventType(notification)) |
|||
|
|||
return &webhookMessage{ |
|||
Key: key, |
|||
EventType: eventType, |
|||
Notification: notification, |
|||
} |
|||
} |
|||
|
|||
type config struct { |
|||
endpoint string |
|||
authBearerToken string |
|||
timeoutSeconds int |
|||
|
|||
maxRetries int |
|||
backoffSeconds int |
|||
maxBackoffSeconds int |
|||
nWorkers int |
|||
bufferSize int |
|||
|
|||
eventTypes []string |
|||
pathPrefixes []string |
|||
} |
|||
|
|||
func newConfigWithDefaults(configuration util.Configuration, prefix string) *config { |
|||
c := &config{ |
|||
endpoint: configuration.GetString(prefix + "endpoint"), |
|||
authBearerToken: configuration.GetString(prefix + "bearer_token"), |
|||
timeoutSeconds: 10, |
|||
maxRetries: 3, |
|||
backoffSeconds: 3, |
|||
maxBackoffSeconds: 30, |
|||
nWorkers: 5, |
|||
bufferSize: 10_000, |
|||
} |
|||
|
|||
if bufferSize := configuration.GetInt(prefix + "buffer_size"); bufferSize > 0 { |
|||
c.bufferSize = bufferSize |
|||
} |
|||
if workers := configuration.GetInt(prefix + "workers"); workers > 0 { |
|||
c.nWorkers = workers |
|||
} |
|||
if maxRetries := configuration.GetInt(prefix + "max_retries"); maxRetries > 0 { |
|||
c.maxRetries = maxRetries |
|||
} |
|||
if backoffSeconds := configuration.GetInt(prefix + "backoff_seconds"); backoffSeconds > 0 { |
|||
c.backoffSeconds = backoffSeconds |
|||
} |
|||
if maxBackoffSeconds := configuration.GetInt(prefix + "max_backoff_seconds"); maxBackoffSeconds > 0 { |
|||
c.maxBackoffSeconds = maxBackoffSeconds |
|||
} |
|||
if timeout := configuration.GetInt(prefix + "timeout_seconds"); timeout > 0 { |
|||
c.timeoutSeconds = timeout |
|||
} |
|||
|
|||
c.eventTypes = configuration.GetStringSlice(prefix + "event_types") |
|||
c.pathPrefixes = configuration.GetStringSlice(prefix + "path_prefixes") |
|||
|
|||
return c |
|||
} |
|||
|
|||
func (c *config) validate() error { |
|||
if c.endpoint == "" { |
|||
return fmt.Errorf("webhook endpoint is required") |
|||
} |
|||
|
|||
_, err := url.Parse(c.endpoint) |
|||
if err != nil { |
|||
return fmt.Errorf("invalid webhook endpoint: %w", err) |
|||
} |
|||
|
|||
if c.timeoutSeconds < 1 || c.timeoutSeconds > 300 { |
|||
return fmt.Errorf("timeout must be between 1 and 300 seconds, got %d", c.timeoutSeconds) |
|||
} |
|||
|
|||
if c.maxRetries < 0 || c.maxRetries > 10 { |
|||
return fmt.Errorf("max retries must be between 0 and 10, got %d", c.maxRetries) |
|||
} |
|||
|
|||
if c.backoffSeconds < 1 || c.backoffSeconds > 60 { |
|||
return fmt.Errorf("backoff seconds must be between 1 and 60, got %d", c.backoffSeconds) |
|||
} |
|||
|
|||
if c.maxBackoffSeconds < c.backoffSeconds || c.maxBackoffSeconds > 300 { |
|||
return fmt.Errorf("max backoff seconds must be between %d and 300, got %d", c.backoffSeconds, c.maxBackoffSeconds) |
|||
} |
|||
|
|||
if c.nWorkers < 1 || c.nWorkers > 100 { |
|||
return fmt.Errorf("workers must be between 1 and 100, got %d", c.nWorkers) |
|||
} |
|||
|
|||
if c.bufferSize < 100 || c.bufferSize > 1_000_000 { |
|||
return fmt.Errorf("buffer size must be between 100 and 1,000,000, got %d", c.bufferSize) |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func detectEventType(notification *filer_pb.EventNotification) eventType { |
|||
hasOldEntry := notification.OldEntry != nil |
|||
hasNewEntry := notification.NewEntry != nil |
|||
hasNewParentPath := notification.NewParentPath != "" |
|||
|
|||
if !hasOldEntry && hasNewEntry { |
|||
return eventTypeCreate |
|||
} |
|||
|
|||
if hasOldEntry && !hasNewEntry { |
|||
return eventTypeDelete |
|||
} |
|||
|
|||
if hasOldEntry && hasNewEntry { |
|||
if hasNewParentPath { |
|||
return eventTypeRename |
|||
} |
|||
|
|||
return eventTypeUpdate |
|||
} |
|||
|
|||
return eventTypeUpdate |
|||
} |
@ -0,0 +1,536 @@ |
|||
package webhook |
|||
|
|||
import ( |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"strings" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
"google.golang.org/protobuf/proto" |
|||
) |
|||
|
|||
func TestConfigValidation(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
config *config |
|||
wantErr bool |
|||
errMsg string |
|||
}{ |
|||
{ |
|||
name: "valid config", |
|||
config: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
authBearerToken: "test-token", |
|||
timeoutSeconds: 30, |
|||
maxRetries: 3, |
|||
backoffSeconds: 5, |
|||
maxBackoffSeconds: 30, |
|||
nWorkers: 5, |
|||
bufferSize: 10000, |
|||
}, |
|||
wantErr: false, |
|||
}, |
|||
{ |
|||
name: "empty endpoint", |
|||
config: &config{ |
|||
endpoint: "", |
|||
timeoutSeconds: 30, |
|||
maxRetries: 3, |
|||
backoffSeconds: 5, |
|||
maxBackoffSeconds: 30, |
|||
nWorkers: 5, |
|||
bufferSize: 10000, |
|||
}, |
|||
wantErr: true, |
|||
errMsg: "endpoint is required", |
|||
}, |
|||
{ |
|||
name: "invalid URL", |
|||
config: &config{ |
|||
endpoint: "://invalid-url", |
|||
timeoutSeconds: 30, |
|||
maxRetries: 3, |
|||
backoffSeconds: 5, |
|||
maxBackoffSeconds: 30, |
|||
nWorkers: 5, |
|||
bufferSize: 10000, |
|||
}, |
|||
wantErr: true, |
|||
errMsg: "invalid webhook endpoint", |
|||
}, |
|||
{ |
|||
name: "timeout too large", |
|||
config: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 301, |
|||
maxRetries: 3, |
|||
backoffSeconds: 5, |
|||
maxBackoffSeconds: 30, |
|||
nWorkers: 5, |
|||
bufferSize: 10000, |
|||
}, |
|||
wantErr: true, |
|||
errMsg: "timeout must be between", |
|||
}, |
|||
{ |
|||
name: "too many retries", |
|||
config: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 30, |
|||
maxRetries: 11, |
|||
backoffSeconds: 5, |
|||
maxBackoffSeconds: 30, |
|||
nWorkers: 5, |
|||
bufferSize: 10000, |
|||
}, |
|||
wantErr: true, |
|||
errMsg: "max retries must be between", |
|||
}, |
|||
{ |
|||
name: "too many workers", |
|||
config: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 30, |
|||
maxRetries: 3, |
|||
backoffSeconds: 5, |
|||
maxBackoffSeconds: 30, |
|||
nWorkers: 101, |
|||
bufferSize: 10000, |
|||
}, |
|||
wantErr: true, |
|||
errMsg: "workers must be between", |
|||
}, |
|||
{ |
|||
name: "buffer too large", |
|||
config: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 30, |
|||
maxRetries: 3, |
|||
backoffSeconds: 5, |
|||
maxBackoffSeconds: 30, |
|||
nWorkers: 5, |
|||
bufferSize: 1000001, |
|||
}, |
|||
wantErr: true, |
|||
errMsg: "buffer size must be between", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
err := tt.config.validate() |
|||
if (err != nil) != tt.wantErr { |
|||
t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) |
|||
} |
|||
if err != nil && tt.errMsg != "" { |
|||
if err.Error() == "" || !strings.Contains(err.Error(), tt.errMsg) { |
|||
t.Errorf("validate() error message = %v, want to contain %v", err.Error(), tt.errMsg) |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestWebhookMessageSerialization(t *testing.T) { |
|||
msg := &filer_pb.EventNotification{ |
|||
OldEntry: nil, |
|||
NewEntry: &filer_pb.Entry{ |
|||
Name: "test.txt", |
|||
IsDirectory: false, |
|||
}, |
|||
} |
|||
|
|||
webhookMsg := newWebhookMessage("/test/path", msg) |
|||
|
|||
wmMsg, err := webhookMsg.toWaterMillMessage() |
|||
if err != nil { |
|||
t.Fatalf("Failed to convert to watermill message: %v", err) |
|||
} |
|||
|
|||
// Unmarshal the protobuf payload directly
|
|||
var eventNotification filer_pb.EventNotification |
|||
err = proto.Unmarshal(wmMsg.Payload, &eventNotification) |
|||
if err != nil { |
|||
t.Fatalf("Failed to unmarshal protobuf message: %v", err) |
|||
} |
|||
|
|||
// Check metadata
|
|||
if wmMsg.Metadata.Get("key") != "/test/path" { |
|||
t.Errorf("Expected key '/test/path', got %v", wmMsg.Metadata.Get("key")) |
|||
} |
|||
|
|||
if wmMsg.Metadata.Get("event_type") != "create" { |
|||
t.Errorf("Expected event type 'create', got %v", wmMsg.Metadata.Get("event_type")) |
|||
} |
|||
|
|||
if eventNotification.NewEntry.Name != "test.txt" { |
|||
t.Errorf("Expected file name 'test.txt', got %v", eventNotification.NewEntry.Name) |
|||
} |
|||
} |
|||
|
|||
func TestQueueInitialize(t *testing.T) { |
|||
cfg := &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
authBearerToken: "test-token", |
|||
timeoutSeconds: 10, |
|||
maxRetries: 3, |
|||
backoffSeconds: 3, |
|||
maxBackoffSeconds: 60, |
|||
nWorkers: 1, |
|||
bufferSize: 100, |
|||
} |
|||
|
|||
q := &Queue{} |
|||
err := q.initialize(cfg) |
|||
if err != nil { |
|||
t.Errorf("Initialize() error = %v", err) |
|||
} |
|||
|
|||
defer func() { |
|||
if q.cancel != nil { |
|||
q.cancel() |
|||
} |
|||
time.Sleep(100 * time.Millisecond) |
|||
if q.router != nil { |
|||
q.router.Close() |
|||
} |
|||
}() |
|||
|
|||
if q.router == nil { |
|||
t.Error("Expected router to be initialized") |
|||
} |
|||
if q.queueChannel == nil { |
|||
t.Error("Expected queueChannel to be initialized") |
|||
} |
|||
if q.client == nil { |
|||
t.Error("Expected client to be initialized") |
|||
} |
|||
if q.config == nil { |
|||
t.Error("Expected config to be initialized") |
|||
} |
|||
} |
|||
|
|||
// TestQueueSendMessage test sending messages to the queue
|
|||
func TestQueueSendMessage(t *testing.T) { |
|||
cfg := &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
authBearerToken: "test-token", |
|||
timeoutSeconds: 1, |
|||
maxRetries: 1, |
|||
backoffSeconds: 1, |
|||
maxBackoffSeconds: 1, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
} |
|||
|
|||
q := &Queue{} |
|||
err := q.initialize(cfg) |
|||
if err != nil { |
|||
t.Fatalf("Failed to initialize queue: %v", err) |
|||
} |
|||
|
|||
defer func() { |
|||
if q.cancel != nil { |
|||
q.cancel() |
|||
} |
|||
time.Sleep(100 * time.Millisecond) |
|||
if q.router != nil { |
|||
q.router.Close() |
|||
} |
|||
}() |
|||
|
|||
msg := &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{ |
|||
Name: "test.txt", |
|||
}, |
|||
} |
|||
|
|||
err = q.SendMessage("/test/path", msg) |
|||
if err != nil { |
|||
t.Errorf("SendMessage() error = %v", err) |
|||
} |
|||
} |
|||
|
|||
func TestQueueHandleWebhook(t *testing.T) { |
|||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
|||
w.WriteHeader(http.StatusOK) |
|||
})) |
|||
defer server.Close() |
|||
|
|||
cfg := &config{ |
|||
endpoint: server.URL, |
|||
authBearerToken: "test-token", |
|||
timeoutSeconds: 1, |
|||
maxRetries: 0, |
|||
backoffSeconds: 1, |
|||
maxBackoffSeconds: 1, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
} |
|||
|
|||
client, _ := newHTTPClient(cfg) |
|||
q := &Queue{ |
|||
client: client, |
|||
} |
|||
|
|||
message := newWebhookMessage("/test/path", &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{ |
|||
Name: "test.txt", |
|||
}, |
|||
}) |
|||
|
|||
wmMsg, err := message.toWaterMillMessage() |
|||
if err != nil { |
|||
t.Fatalf("Failed to create watermill message: %v", err) |
|||
} |
|||
|
|||
err = q.handleWebhook(wmMsg) |
|||
if err != nil { |
|||
t.Errorf("handleWebhook() error = %v", err) |
|||
} |
|||
} |
|||
|
|||
func TestQueueEndToEnd(t *testing.T) { |
|||
// Simplified test - just verify the queue can be created and message can be sent
|
|||
// without needing full end-to-end processing
|
|||
cfg := &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
authBearerToken: "test-token", |
|||
timeoutSeconds: 1, |
|||
maxRetries: 0, |
|||
backoffSeconds: 1, |
|||
maxBackoffSeconds: 1, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
} |
|||
|
|||
q := &Queue{} |
|||
err := q.initialize(cfg) |
|||
if err != nil { |
|||
t.Fatalf("Failed to initialize queue: %v", err) |
|||
} |
|||
|
|||
defer func() { |
|||
if q.cancel != nil { |
|||
q.cancel() |
|||
} |
|||
time.Sleep(100 * time.Millisecond) |
|||
if q.router != nil { |
|||
q.router.Close() |
|||
} |
|||
}() |
|||
|
|||
msg := &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{ |
|||
Name: "test.txt", |
|||
}, |
|||
} |
|||
|
|||
err = q.SendMessage("/test/path", msg) |
|||
if err != nil { |
|||
t.Errorf("SendMessage() error = %v", err) |
|||
} |
|||
} |
|||
|
|||
func TestQueueRetryMechanism(t *testing.T) { |
|||
cfg := &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
authBearerToken: "test-token", |
|||
timeoutSeconds: 1, |
|||
maxRetries: 3, // Test that this config is used
|
|||
backoffSeconds: 2, |
|||
maxBackoffSeconds: 10, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
} |
|||
|
|||
q := &Queue{} |
|||
err := q.initialize(cfg) |
|||
if err != nil { |
|||
t.Fatalf("Failed to initialize queue: %v", err) |
|||
} |
|||
|
|||
defer func() { |
|||
if q.cancel != nil { |
|||
q.cancel() |
|||
} |
|||
time.Sleep(100 * time.Millisecond) |
|||
if q.router != nil { |
|||
q.router.Close() |
|||
} |
|||
}() |
|||
|
|||
// Verify that the queue is properly configured for retries
|
|||
if q.config.maxRetries != 3 { |
|||
t.Errorf("Expected maxRetries=3, got %d", q.config.maxRetries) |
|||
} |
|||
|
|||
if q.config.backoffSeconds != 2 { |
|||
t.Errorf("Expected backoffSeconds=2, got %d", q.config.backoffSeconds) |
|||
} |
|||
|
|||
if q.config.maxBackoffSeconds != 10 { |
|||
t.Errorf("Expected maxBackoffSeconds=10, got %d", q.config.maxBackoffSeconds) |
|||
} |
|||
|
|||
// Test that we can send a message (retry behavior is handled by Watermill middleware)
|
|||
msg := &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "test.txt"}, |
|||
} |
|||
|
|||
err = q.SendMessage("/test/retry", msg) |
|||
if err != nil { |
|||
t.Errorf("SendMessage() error = %v", err) |
|||
} |
|||
} |
|||
|
|||
func TestQueueSendMessageWithFilter(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
cfg *config |
|||
key string |
|||
notification *filer_pb.EventNotification |
|||
shouldPublish bool |
|||
}{ |
|||
{ |
|||
name: "allowed event type", |
|||
cfg: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 10, |
|||
maxRetries: 1, |
|||
backoffSeconds: 1, |
|||
maxBackoffSeconds: 1, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
eventTypes: []string{"create"}, |
|||
}, |
|||
key: "/data/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "filtered event type", |
|||
cfg: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 10, |
|||
maxRetries: 1, |
|||
backoffSeconds: 1, |
|||
maxBackoffSeconds: 1, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
eventTypes: []string{"update", "rename"}, |
|||
}, |
|||
key: "/data/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: false, |
|||
}, |
|||
{ |
|||
name: "allowed path prefix", |
|||
cfg: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 10, |
|||
maxRetries: 1, |
|||
backoffSeconds: 1, |
|||
maxBackoffSeconds: 1, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
pathPrefixes: []string{"/data/"}, |
|||
}, |
|||
key: "/data/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "filtered path prefix", |
|||
cfg: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 10, |
|||
maxRetries: 1, |
|||
backoffSeconds: 1, |
|||
maxBackoffSeconds: 1, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
pathPrefixes: []string{"/logs/"}, |
|||
}, |
|||
key: "/data/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: false, |
|||
}, |
|||
{ |
|||
name: "combined filters - both pass", |
|||
cfg: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 10, |
|||
maxRetries: 1, |
|||
backoffSeconds: 1, |
|||
maxBackoffSeconds: 1, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
eventTypes: []string{"create", "delete"}, |
|||
pathPrefixes: []string{"/data/", "/logs/"}, |
|||
}, |
|||
key: "/data/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: true, |
|||
}, |
|||
{ |
|||
name: "combined filters - event fails", |
|||
cfg: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 10, |
|||
maxRetries: 1, |
|||
backoffSeconds: 1, |
|||
maxBackoffSeconds: 1, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
eventTypes: []string{"update", "delete"}, |
|||
pathPrefixes: []string{"/data/", "/logs/"}, |
|||
}, |
|||
key: "/data/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: false, |
|||
}, |
|||
{ |
|||
name: "combined filters - path fails", |
|||
cfg: &config{ |
|||
endpoint: "https://example.com/webhook", |
|||
timeoutSeconds: 10, |
|||
maxRetries: 1, |
|||
backoffSeconds: 1, |
|||
maxBackoffSeconds: 1, |
|||
nWorkers: 1, |
|||
bufferSize: 10, |
|||
eventTypes: []string{"create", "delete"}, |
|||
pathPrefixes: []string{"/logs/"}, |
|||
}, |
|||
key: "/data/file.txt", |
|||
notification: &filer_pb.EventNotification{ |
|||
NewEntry: &filer_pb.Entry{Name: "file.txt"}, |
|||
}, |
|||
shouldPublish: false, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
shouldPublish := newFilter(tt.cfg).shouldPublish(tt.key, tt.notification) |
|||
if shouldPublish != tt.shouldPublish { |
|||
t.Errorf("Expected shouldPublish=%v, got %v", tt.shouldPublish, shouldPublish) |
|||
} |
|||
}) |
|||
} |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue