package webhooks import ( "context" "strings" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" svcerrors "github.com/sarathsp06/sparrow/internal/errors" "github.com/sarathsp06/sparrow/internal/webhooks/store" ) // --------------------------------------------------------------------------- // validateLabels tests // --------------------------------------------------------------------------- func TestValidateLabels_EmptyMap(t *testing.T) { err := validateLabels(nil, "labels") assert.NoError(t, err) err = validateLabels(map[string]string{}, "labels") assert.NoError(t, err) } func TestValidateLabels_ValidEntries(t *testing.T) { m := map[string]string{ "region": "us-east-1", "environment": "production", "tier": "premium", "app.version": "1.2.3", "team_name": "backend", "release-v2": "true", } err := validateLabels(m, "labels") assert.NoError(t, err) } func TestValidateLabels_EmptyKey(t *testing.T) { m := map[string]string{"": "value"} err := validateLabels(m, "labels") require.Error(t, err) assert.Contains(t, err.Error(), "key must not be empty") } func TestValidateLabels_KeyTooLong(t *testing.T) { longKey := strings.Repeat("a", maxLabelKeyLen+1) m := map[string]string{longKey: "value"} err := validateLabels(m, "labels") require.Error(t, err) assert.Contains(t, err.Error(), "exceeds maximum length") } func TestValidateLabels_KeyExactlyAtLimit(t *testing.T) { key := strings.Repeat("k", maxLabelKeyLen) m := map[string]string{key: "value"} err := validateLabels(m, "labels") assert.NoError(t, err) } func TestValidateLabels_InvalidKeyCharacters(t *testing.T) { tests := []struct { name string key string }{ {"space", "my key"}, {"exclamation", "key!"}, {"at sign", "key@host"}, {"slash", "path/key"}, {"colon", "ns:key"}, {"equals", "key=val"}, {"bracket", "key[0]"}, {"unicode", "clé"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := map[string]string{tt.key: "value"} err := validateLabels(m, "labels") require.Error(t, err) assert.Contains(t, err.Error(), "invalid characters") }) } } func TestValidateLabels_ValidKeyCharacters(t *testing.T) { // Alphanumeric, dot, underscore, hyphen are allowed. keys := []string{"abc", "ABC", "123", "a.b", "a_b", "a-b", "A.1_2-z"} for _, k := range keys { m := map[string]string{k: "value"} err := validateLabels(m, "labels") assert.NoError(t, err, "key %q should be valid", k) } } func TestValidateLabels_ValueTooLong(t *testing.T) { longVal := strings.Repeat("v", maxLabelValueLen+1) m := map[string]string{"key": longVal} err := validateLabels(m, "labels") require.Error(t, err) assert.Contains(t, err.Error(), "exceeds maximum length") } func TestValidateLabels_ValueExactlyAtLimit(t *testing.T) { val := strings.Repeat("v", maxLabelValueLen) m := map[string]string{"key": val} err := validateLabels(m, "labels") assert.NoError(t, err) } func TestValidateLabels_EmptyValue(t *testing.T) { // Empty values are allowed (only keys have non-empty constraint). m := map[string]string{"key": ""} err := validateLabels(m, "labels") assert.NoError(t, err) } func TestValidateLabels_TooManyEntries(t *testing.T) { m := make(map[string]string, maxLabelsPerMap+1) for i := 0; i <= maxLabelsPerMap; i++ { m[strings.Repeat("k", i+1)] = "v" } err := validateLabels(m, "labels") require.Error(t, err) assert.Contains(t, err.Error(), "too many entries") } func TestValidateLabels_ExactlyAtEntryLimit(t *testing.T) { m := make(map[string]string, maxLabelsPerMap) for i := 0; i < maxLabelsPerMap; i++ { m[strings.Repeat("k", i+1)] = "v" } err := validateLabels(m, "labels") assert.NoError(t, err) } func TestValidateLabels_FieldNameInError(t *testing.T) { // The error message should reference the field name passed in. m := map[string]string{"": "value"} err := validateLabels(m, "label_filters") require.Error(t, err) assert.Contains(t, err.Error(), "label_filters") } // --------------------------------------------------------------------------- // PushEvent label validation tests // --------------------------------------------------------------------------- func TestPushEvent_WithValidLabels(t *testing.T) { repo := new(mockRepo) inserter := new(mockJobInserter) service := NewWebhookService(inserter, repo, nil) ctx := testContext() consumer := "default" eventName := "user.signup" payload := map[string]any{"user_id": "123"} labels := map[string]string{"region": "us", "tier": "premium"} repo.On("GetEventByName", mock.Anything, mock.Anything, eventName).Return(&store.EventRegistration{ Name: eventName, Active: true, }, nil) repo.On("StoreEventAndEnqueue", mock.Anything, mock.Anything, mock.MatchedBy(func(e *store.EventRecord) bool { // Verify labels are passed through to the stored event record. return e.Labels["region"] == "us" && e.Labels["tier"] == "premium" })).Return(nil) inserter.On("BatchInsertTx", mock.Anything, mock.Anything, mock.Anything).Return(nil) eventID, _, _, _, err := service.PushEvent(ctx, consumer, eventName, payload, 0, nil, labels, nil, nil) assert.NoError(t, err) assert.NotEmpty(t, eventID) repo.AssertExpectations(t) inserter.AssertExpectations(t) } func TestPushEvent_WithNilLabels(t *testing.T) { repo := new(mockRepo) inserter := new(mockJobInserter) service := NewWebhookService(inserter, repo, nil) ctx := testContext() repo.On("GetEventByName", mock.Anything, mock.Anything, "test.event").Return(&store.EventRegistration{ Name: "test.event", Active: true, }, nil) repo.On("StoreEventAndEnqueue", mock.Anything, mock.Anything, mock.Anything).Return(nil) inserter.On("BatchInsertTx", mock.Anything, mock.Anything, mock.Anything).Return(nil) eventID, _, _, _, err := service.PushEvent(ctx, "default", "test.event", nil, 0, nil, nil, nil, nil) assert.NoError(t, err) assert.NotEmpty(t, eventID) repo.AssertExpectations(t) } func TestPushEvent_WithInvalidLabels(t *testing.T) { repo := new(mockRepo) inserter := new(mockJobInserter) service := NewWebhookService(inserter, repo, nil) ctx := testContext() tests := []struct { name string labels map[string]string errMsg string }{ { name: "empty key", labels: map[string]string{"": "value"}, errMsg: "key must not be empty", }, { name: "invalid key chars", labels: map[string]string{"bad key": "value"}, errMsg: "invalid characters", }, { name: "value too long", labels: map[string]string{"key": strings.Repeat("x", maxLabelValueLen+1)}, errMsg: "exceeds maximum length", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, _, _, _, err := service.PushEvent(ctx, "default", "some.event", nil, 0, nil, tt.labels, nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), tt.errMsg) // Ensure no repo calls were made — validation should short-circuit. repo.AssertNotCalled(t, "GetEventByName", mock.Anything, mock.Anything, mock.Anything) }) } } // --------------------------------------------------------------------------- // CreateSubscription label-filter tests // --------------------------------------------------------------------------- func TestCreateSubscription_WithLabelFilters(t *testing.T) { repo := new(mockRepo) inserter := new(mockJobInserter) service := NewWebhookService(inserter, repo, nil) ctx := testContext() webhookID := uuid.New().String() consumer := "default" eventName := "order.created" labelFilters := map[string]string{"region": "us", "tier": "premium"} repo.On("LockWebhook", mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(&store.WebhookRegistration{}, nil) repo.On("CreateSubscription", mock.Anything, mock.Anything, mock.MatchedBy(func(sub *store.EventSubscription) bool { return sub.EventName == eventName && sub.Consumer == consumer && sub.LabelFilters["region"] == "us" && sub.LabelFilters["tier"] == "premium" })).Return(nil) id, createdAt, err := service.CreateSubscription(ctx, webhookID, eventName, consumer, nil, "POST", 30, false, "", labelFilters, SubscriptionTemplateSettings{}) assert.NoError(t, err) assert.NotEmpty(t, id) assert.False(t, createdAt.IsZero()) repo.AssertExpectations(t) } func TestCreateSubscription_WithEmptyLabelFilters(t *testing.T) { repo := new(mockRepo) inserter := new(mockJobInserter) service := NewWebhookService(inserter, repo, nil) ctx := testContext() webhookID := uuid.New().String() repo.On("LockWebhook", mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(&store.WebhookRegistration{}, nil) repo.On("CreateSubscription", mock.Anything, mock.Anything, mock.MatchedBy(func(sub *store.EventSubscription) bool { return len(sub.LabelFilters) == 0 })).Return(nil) id, createdAt, err := service.CreateSubscription(ctx, webhookID, "test.event", "default", nil, "POST", 30, false, "", nil, SubscriptionTemplateSettings{}) assert.NoError(t, err) assert.NotEmpty(t, id) assert.False(t, createdAt.IsZero()) repo.AssertExpectations(t) } func TestCreateSubscription_WithInvalidLabelFilters(t *testing.T) { repo := new(mockRepo) inserter := new(mockJobInserter) service := NewWebhookService(inserter, repo, nil) ctx := testContext() webhookID := uuid.New().String() tests := []struct { name string labelFilters map[string]string errMsg string }{ { name: "empty key", labelFilters: map[string]string{"": "val"}, errMsg: "key must not be empty", }, { name: "invalid key chars", labelFilters: map[string]string{"key with space": "val"}, errMsg: "invalid characters", }, { name: "key too long", labelFilters: map[string]string{strings.Repeat("a", maxLabelKeyLen+1): "val"}, errMsg: "exceeds maximum length", }, { name: "value too long", labelFilters: map[string]string{"key": strings.Repeat("v", maxLabelValueLen+1)}, errMsg: "exceeds maximum length", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, _, err := service.CreateSubscription(ctx, webhookID, "test.event", "default", nil, "POST", 30, false, "", tt.labelFilters, SubscriptionTemplateSettings{}) require.Error(t, err) assert.Contains(t, err.Error(), tt.errMsg) repo.AssertNotCalled(t, "CreateSubscription", mock.Anything, mock.Anything, mock.Anything) }) } } func TestCreateSubscription_CatchAllWithLabelFilters(t *testing.T) { // A catch-all subscription (* event) can still have label filters. repo := new(mockRepo) inserter := new(mockJobInserter) service := NewWebhookService(inserter, repo, nil) ctx := testContext() webhookID := uuid.New().String() labelFilters := map[string]string{"env": "prod"} repo.On("LockWebhook", mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(&store.WebhookRegistration{}, nil) repo.On("CreateSubscription", mock.Anything, mock.Anything, mock.MatchedBy(func(sub *store.EventSubscription) bool { return sub.EventName == store.CatchAllEventName && sub.LabelFilters["env"] == "prod" })).Return(nil) id, _, err := service.CreateSubscription(ctx, webhookID, store.CatchAllEventName, "default", nil, "POST", 30, false, "", labelFilters, SubscriptionTemplateSettings{}) assert.NoError(t, err) assert.NotEmpty(t, id) repo.AssertExpectations(t) } // --------------------------------------------------------------------------- // GetSubscriptionsWithWebhooksByEvent mock tests (service → repo contract) // --------------------------------------------------------------------------- // mockRepoWithEventQuery extends mockRepo with the event-subscription query methods. type mockRepoWithEventQuery struct { mockRepo } func (m *mockRepoWithEventQuery) GetSubscriptionsWithWebhooksByEvent(ctx context.Context, tenantID uuid.UUID, consumer, event string, labels map[string]string) ([]*store.SubscriptionWithWebhook, error) { args := m.Called(ctx, tenantID, consumer, event, labels) res := args.Get(0) if res == nil { return nil, args.Error(1) } return res.([]*store.SubscriptionWithWebhook), args.Error(1) } // --------------------------------------------------------------------------- // GetSubscriptionsWithWebhooksByEvent mock tests (active webhook check) // --------------------------------------------------------------------------- func TestGetSubscriptionsWithWebhooksByEvent_ActiveWebhooksOnly(t *testing.T) { repo := new(mockRepoWithEventQuery) ctx := testContext() activeWebhook := &store.WebhookRegistration{ ID: uuid.New(), Consumer: "default", URL: "https://example.com/hook", Active: true, } sub := &store.EventSubscription{ ID: uuid.New(), WebhookID: activeWebhook.ID, EventName: "signup", Consumer: "default", } // The SQL query JOINs with wr.active = true, so only active webhooks appear. expected := []*store.SubscriptionWithWebhook{ {Subscription: sub, Webhook: activeWebhook}, } repo.On("GetSubscriptionsWithWebhooksByEvent", mock.Anything, mock.Anything, "default", "signup", map[string]string(nil)). Return(expected, nil) result, err := repo.GetSubscriptionsWithWebhooksByEvent(ctx, uuid.New(), "default", "signup", nil) assert.NoError(t, err) assert.Len(t, result, 1) assert.True(t, result[0].Webhook.Active) repo.AssertExpectations(t) } func TestGetSubscriptionsWithWebhooksByEvent_InactiveExcluded(t *testing.T) { repo := new(mockRepoWithEventQuery) ctx := testContext() // An inactive webhook's subscriptions should not be returned by the query. repo.On("GetSubscriptionsWithWebhooksByEvent", mock.Anything, mock.Anything, "default", "signup", map[string]string(nil)). Return([]*store.SubscriptionWithWebhook{}, nil) result, err := repo.GetSubscriptionsWithWebhooksByEvent(ctx, uuid.New(), "default", "signup", nil) assert.NoError(t, err) assert.Empty(t, result) repo.AssertExpectations(t) } func TestGetSubscriptionsWithWebhooksByEvent_MixedCatchAllAndSpecific(t *testing.T) { repo := new(mockRepoWithEventQuery) ctx := testContext() webhook1 := &store.WebhookRegistration{ ID: uuid.New(), Consumer: "default", URL: "https://a.com/hook", Active: true, } webhook2 := &store.WebhookRegistration{ ID: uuid.New(), Consumer: "default", URL: "https://b.com/hook", Active: true, } specificSub := &store.EventSubscription{ ID: uuid.New(), WebhookID: webhook1.ID, EventName: "signup", Consumer: "default", } catchAllSub := &store.EventSubscription{ ID: uuid.New(), WebhookID: webhook2.ID, EventName: store.CatchAllEventName, Consumer: "default", } expected := []*store.SubscriptionWithWebhook{ {Subscription: specificSub, Webhook: webhook1}, {Subscription: catchAllSub, Webhook: webhook2}, } repo.On("GetSubscriptionsWithWebhooksByEvent", mock.Anything, mock.Anything, "default", "signup", map[string]string(nil)). Return(expected, nil) result, err := repo.GetSubscriptionsWithWebhooksByEvent(ctx, uuid.New(), "default", "signup", nil) assert.NoError(t, err) assert.Len(t, result, 2) names := []string{result[0].Subscription.EventName, result[1].Subscription.EventName} assert.Contains(t, names, "signup") assert.Contains(t, names, store.CatchAllEventName) repo.AssertExpectations(t) } func TestGetSubscriptionsWithWebhooksByEvent_LabelsPassedThrough(t *testing.T) { repo := new(mockRepoWithEventQuery) ctx := testContext() eventLabels := map[string]string{"region": "us", "env": "prod"} // Verify the labels are forwarded to the repository method exactly. repo.On("GetSubscriptionsWithWebhooksByEvent", mock.Anything, mock.Anything, "default", "order.created", eventLabels). Return([]*store.SubscriptionWithWebhook{}, nil) _, err := repo.GetSubscriptionsWithWebhooksByEvent(ctx, uuid.New(), "default", "order.created", eventLabels) assert.NoError(t, err) repo.AssertExpectations(t) } // --------------------------------------------------------------------------- // Event-name length (VARCHAR(255): characters, not bytes) // --------------------------------------------------------------------------- func TestPushEvent_EventNameLengthCountsCharacters(t *testing.T) { repo := new(mockRepo) inserter := new(mockJobInserter) service := NewWebhookService(inserter, repo, nil) ctx := testContext() // 255 two-byte characters = 510 bytes: fits the column, must be accepted. fits := strings.Repeat("é", maxEventNameLength) repo.On("GetEventByName", mock.Anything, mock.Anything, fits).Return(&store.EventRegistration{Name: fits, Active: true}, nil) repo.On("StoreEventAndEnqueue", mock.Anything, mock.Anything, mock.Anything).Return(nil) inserter.On("BatchInsertTx", mock.Anything, mock.Anything, mock.Anything).Return(nil) _, _, _, _, err := service.PushEvent(ctx, "default", fits, nil, 0, nil, nil, nil, nil) require.NoError(t, err) for _, tooLong := range []string{strings.Repeat("x", maxEventNameLength+1), strings.Repeat("é", maxEventNameLength+1)} { _, _, _, _, err := service.PushEvent(ctx, "default", tooLong, nil, 0, nil, nil, nil, nil) require.Error(t, err) var svcErr *svcerrors.ServiceError require.ErrorAs(t, err, &svcErr) assert.Equal(t, svcerrors.InvalidArgument, svcErr.Status) repo.AssertNotCalled(t, "GetEventByName", mock.Anything, mock.Anything, tooLong) } } func TestRegisterEvent_RejectsNamesOver255Characters(t *testing.T) { repo := new(mockRepo) service := NewWebhookService(nil, repo, nil) _, _, err := service.RegisterEvent(testContext(), strings.Repeat("é", maxEventNameLength+1), "", nil, nil, true) require.Error(t, err) var svcErr *svcerrors.ServiceError require.ErrorAs(t, err, &svcErr) assert.Equal(t, svcerrors.InvalidArgument, svcErr.Status) repo.AssertNotCalled(t, "GetEventByName", mock.Anything, mock.Anything, mock.Anything) } // consumer and idempotency_key are VARCHAR(255) too: a longer value used to // fail the INSERT with a 500. func TestPushEvent_LongConsumerOrIdempotencyKeyIsAClientError(t *testing.T) { service := NewWebhookService(nil, new(mockRepo), nil) long := strings.Repeat("é", maxEventNameLength+1) for name, push := range map[string]func() error{ "consumer": func() error { _, _, _, _, err := service.PushEvent(testContext(), long, "order.created", nil, 0, nil, nil, nil, nil) return err }, "idempotency_key": func() error { _, _, _, _, err := service.PushEvent(testContext(), "default", "order.created", nil, 0, nil, nil, &long, nil) return err }, } { err := push() var svcErr *svcerrors.ServiceError require.ErrorAs(t, err, &svcErr, name) assert.Equal(t, svcerrors.InvalidArgument, svcErr.Status, name) assert.Equal(t, name+" must be at most 255 characters, got 256", svcErr.ClientMessage()) } }