Ctrl K
ComunidadGuías…

internal/webhooks/event_filtering_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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())
	}
}