Ctrl K
ComunidadGuías…

internal/webhooks/webhook_service_test.go

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

Archivo original ↗Preguntar a KiBot
package webhooks

import (
	"bytes"
	"context"
	"database/sql"
	"errors"
	"strings"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/lib/pq"
	"github.com/riverqueue/river"
	"github.com/riverqueue/river/rivertype"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"

	cryptosvc "github.com/sarathsp06/sparrow/internal/crypto"
	svcerrors "github.com/sarathsp06/sparrow/internal/errors"
	"github.com/sarathsp06/sparrow/internal/storage"
	"github.com/sarathsp06/sparrow/internal/webhooks/queue"
	"github.com/sarathsp06/sparrow/internal/webhooks/store"
)

type mockJobInserter struct {
	mock.Mock
}

func (m *mockJobInserter) Insert(ctx context.Context, args river.JobArgs) (*rivertype.JobInsertResult, error) {
	callArgs := m.Called(ctx, args)
	res := callArgs.Get(0)
	if res == nil {
		return nil, callArgs.Error(1)
	}
	return res.(*rivertype.JobInsertResult), callArgs.Error(1)
}

func (m *mockJobInserter) BatchInsert(ctx context.Context, args []river.JobArgs) ([]*rivertype.JobInsertResult, error) {
	callArgs := m.Called(ctx, args)
	res := callArgs.Get(0)
	if res == nil {
		return nil, callArgs.Error(1)
	}
	return res.([]*rivertype.JobInsertResult), callArgs.Error(1)
}

func (m *mockJobInserter) BatchInsertTx(ctx context.Context, tx *sql.Tx, args []river.JobArgs) error {
	return m.Called(ctx, tx, args).Error(0)
}

// CancelWaitingDeliveryJobsTx claims nothing; the store mock decides what a
// retry resets.
func (m *mockJobInserter) CancelWaitingDeliveryJobsTx(context.Context, *sql.Tx, []uuid.UUID) ([]uuid.UUID, error) {
	return nil, nil
}

type mockRepo struct {
	store.RepositoryInterface
	mock.Mock
	// gotClaim records whether the last ResetDeliveriesForRetry call was
	// given a claimer (needed to retry a retrying delivery).
	gotClaim bool
}

func (m *mockRepo) ListWebhooksPaginated(ctx context.Context, tenantID uuid.UUID, consumer, event string, activeOnly bool, health store.WebhookHealth, limit, offset int) ([]*store.WebhookRegistration, int, error) {
	args := m.Called(ctx, tenantID, consumer, event, activeOnly, health, limit, offset)
	return args.Get(0).([]*store.WebhookRegistration), args.Int(1), args.Error(2)
}

func (m *mockRepo) ListEventsPaginated(ctx context.Context, tenantID uuid.UUID, filter store.EventTypeFilter, limit, offset int) ([]*store.EventRegistration, int, error) {
	args := m.Called(ctx, tenantID, filter, limit, offset)
	return args.Get(0).([]*store.EventRegistration), args.Int(1), args.Error(2)
}

func (m *mockRepo) ListSubscriptions(ctx context.Context, tenantID uuid.UUID, webhookID uuid.UUID) ([]*store.EventSubscription, error) {
	args := m.Called(ctx, tenantID, webhookID)
	return args.Get(0).([]*store.EventSubscription), args.Error(1)
}

func (m *mockRepo) GetWebhookByID(ctx context.Context, tenantID uuid.UUID, webhookID uuid.UUID, consumer string) (*store.WebhookRegistration, error) {
	args := m.Called(ctx, tenantID, webhookID, consumer)
	res := args.Get(0)
	if res == nil {
		return nil, args.Error(1)
	}
	return res.(*store.WebhookRegistration), args.Error(1)
}

func (m *mockRepo) LockWebhook(ctx context.Context, tenantID uuid.UUID, webhookID uuid.UUID, consumer string, exclusive bool) (*store.WebhookRegistration, error) {
	args := m.Called(ctx, tenantID, webhookID, consumer, exclusive)
	res := args.Get(0)
	if res == nil {
		return nil, args.Error(1)
	}
	return res.(*store.WebhookRegistration), args.Error(1)
}

func (m *mockRepo) UpdateWebhook(ctx context.Context, tenantID uuid.UUID, webhook *store.WebhookRegistration) error {
	args := m.Called(ctx, tenantID, webhook)
	return args.Error(0)
}

func (m *mockRepo) RunInTransaction(fn func(store.RepositoryInterface) error) error {
	return fn(m)
}

func (m *mockRepo) UpsertRateLimitState(ctx context.Context, _ uuid.UUID, webhookID uuid.UUID) error {
	args := m.Called(ctx, webhookID)
	return args.Error(0)
}

func (m *mockRepo) DeleteRateLimitState(ctx context.Context, _ uuid.UUID, webhookID uuid.UUID) error {
	args := m.Called(ctx, webhookID)
	return args.Error(0)
}

func (m *mockRepo) GetDeliveryByID(ctx context.Context, tenantID uuid.UUID, deliveryID uuid.UUID, consumer string) (*store.WebhookDelivery, error) {
	args := m.Called(ctx, tenantID, deliveryID, consumer)
	res := args.Get(0)
	if res == nil {
		return nil, args.Error(1)
	}
	return res.(*store.WebhookDelivery), args.Error(1)
}

func (m *mockRepo) GetRetriableDeliveries(ctx context.Context, tenantID uuid.UUID, webhookID uuid.UUID, consumer string, force bool) ([]*store.WebhookDelivery, error) {
	args := m.Called(ctx, tenantID, webhookID, consumer, force)
	return args.Get(0).([]*store.WebhookDelivery), args.Error(1)
}

// ResetDeliveriesForRetry resets the IDs the test returns and runs enqueue
// for them, as the store does inside its transaction.
func (m *mockRepo) ResetDeliveriesForRetry(ctx context.Context, _ uuid.UUID, deliveryIDs []uuid.UUID, claim store.RetryClaimer, enqueue store.RetryEnqueuer) ([]uuid.UUID, error) {
	m.gotClaim = claim != nil
	args := m.Called(ctx, deliveryIDs)
	reset, _ := args.Get(0).([]uuid.UUID)
	if err := args.Error(1); err != nil || len(reset) == 0 {
		return nil, err
	}
	if err := enqueue(ctx, nil, reset); err != nil {
		return nil, err
	}
	return reset, nil
}

func (m *mockRepo) ListDeliveriesFiltered(ctx context.Context, tenantID uuid.UUID, filter store.DeliveryFilter) ([]*store.WebhookDelivery, bool, error) {
	args := m.Called(ctx, tenantID, filter)
	return args.Get(0).([]*store.WebhookDelivery), args.Bool(1), args.Error(2)
}

func (m *mockRepo) CountDeliveries(ctx context.Context, tenantID uuid.UUID, filter store.DeliveryFilter) (int, error) {
	args := m.Called(ctx, tenantID, filter)
	return args.Int(0), args.Error(1)
}

func (m *mockRepo) ListEventReportsFiltered(ctx context.Context, tenantID uuid.UUID, filter store.EventReportFilter) ([]*store.EventReportWithStats, bool, error) {
	args := m.Called(ctx, tenantID, filter)
	return args.Get(0).([]*store.EventReportWithStats), args.Bool(1), args.Error(2)
}

func (m *mockRepo) CreateSubscription(ctx context.Context, tenantID uuid.UUID, sub *store.EventSubscription) error {
	args := m.Called(ctx, tenantID, sub)
	sub.ID = uuid.New()
	sub.CreatedAt = time.Now()
	return args.Error(0)
}

func (m *mockRepo) GetEventByName(ctx context.Context, tenantID uuid.UUID, eventName string) (*store.EventRegistration, error) {
	args := m.Called(ctx, tenantID, eventName)
	res := args.Get(0)
	if res == nil {
		return nil, args.Error(1)
	}
	return res.(*store.EventRegistration), args.Error(1)
}

func (m *mockRepo) RegisterEvent(ctx context.Context, tenantID uuid.UUID, event *store.EventRegistration) error {
	args := m.Called(ctx, tenantID, event)
	event.TenantID = tenantID
	event.CreatedAt = time.Now()
	event.UpdatedAt = time.Now()
	return args.Error(0)
}

// StoreEventAndEnqueue stores the event as the test says and runs enqueue,
// as the store does inside its transaction.
func (m *mockRepo) StoreEventAndEnqueue(ctx context.Context, tenantID uuid.UUID, event *store.EventRecord, enqueue store.EventEnqueuer) error {
	if err := m.Called(ctx, tenantID, event).Error(0); err != nil {
		return err
	}
	return enqueue(ctx, nil)
}

func (m *mockRepo) StoreEvent(ctx context.Context, tenantID uuid.UUID, event *store.EventRecord) error {
	args := m.Called(ctx, tenantID, event)
	return args.Error(0)
}

func (m *mockRepo) GetEventByIdempotencyKey(ctx context.Context, tenantID uuid.UUID, consumer, idempotencyKey string) (*store.EventRecord, error) {
	args := m.Called(ctx, tenantID, consumer, idempotencyKey)
	res := args.Get(0)
	if res == nil {
		return nil, args.Error(1)
	}
	return res.(*store.EventRecord), args.Error(1)
}

// testContext returns a context for testing.
func testContext() context.Context {
	return context.Background()
}

func TestWebhookService_ListWebhooks_Pagination(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil)

	ctx := testContext()
	consumer := "default"
	limit := int32(10)
	offset := int32(0)

	expectedWebhooks := []*store.WebhookRegistration{
		{ID: uuid.New(), Consumer: consumer, URL: "http://example.com"},
	}

	repo.On("ListWebhooksPaginated", mock.Anything, mock.Anything, consumer, "", false, store.WebhookHealth(""), int(limit), int(offset)).
		Return(expectedWebhooks, 1, nil)

	webhooks, totalCount, err := service.ListWebhooks(ctx, consumer, "", "", false, "", limit, offset)

	assert.NoError(t, err)
	assert.Equal(t, int32(1), totalCount)
	assert.Equal(t, len(expectedWebhooks), len(webhooks))
	repo.AssertExpectations(t)
}

func TestWebhookService_RetryDelivery(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil)

	ctx := testContext()
	consumer := "default"
	deliveryID := uuid.New()
	webhookID := uuid.New()

	delivery := &store.WebhookDelivery{
		ID:        deliveryID,
		WebhookID: webhookID,
		EventID:   uuid.New(),
		Status:    store.StatusFailed,
		ExpiresAt: time.Now().Add(time.Hour),
	}

	webhook := &store.WebhookRegistration{
		ID:       webhookID,
		Consumer: consumer,
	}

	repo.On("GetDeliveryByID", mock.Anything, mock.Anything, deliveryID, consumer).Return(delivery, nil)
	repo.On("ResetDeliveriesForRetry", mock.Anything, []uuid.UUID{deliveryID}).Return([]uuid.UUID{deliveryID}, nil)
	repo.On("GetWebhookByID", mock.Anything, mock.Anything, webhookID, consumer).Return(webhook, nil).Once()
	inserter.On("BatchInsertTx", mock.Anything, mock.Anything, mock.MatchedBy(func(jobs []river.JobArgs) bool { return len(jobs) == 1 })).
		Return(nil)

	ids, count, err := service.RetryDelivery(ctx, consumer, deliveryID.String(), "", false)

	assert.NoError(t, err)
	assert.Equal(t, int32(1), count)
	assert.Contains(t, ids, deliveryID.String())
	repo.AssertExpectations(t)
	inserter.AssertExpectations(t)
}

// A delivery the store does not reset (pending, or its job already picked up
// by a worker) is left to that job, even by a forced retry, and the caller
// gets FailedPrecondition instead of a second job.
func TestWebhookService_RetryDeliveryInFlight(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil)
	ctx := testContext()
	deliveryID, webhookID := uuid.New(), uuid.New()

	repo.On("GetDeliveryByID", mock.Anything, mock.Anything, deliveryID, "default").
		Return(&store.WebhookDelivery{ID: deliveryID, WebhookID: webhookID, EventID: uuid.New(), Status: store.StatusRetrying}, nil)
	repo.On("GetWebhookByID", mock.Anything, mock.Anything, webhookID, "default").Return(&store.WebhookRegistration{ID: webhookID, Consumer: "default"}, nil)
	repo.On("ResetDeliveriesForRetry", mock.Anything, []uuid.UUID{deliveryID}).Return([]uuid.UUID(nil), nil)

	_, _, err := service.RetryDelivery(ctx, "default", deliveryID.String(), "", true)
	var svcErr *svcerrors.ServiceError
	if !errors.As(err, &svcErr) || svcErr.Status != svcerrors.FailedPrecondition {
		t.Fatalf("err = %v, want FailedPrecondition", err)
	}
	inserter.AssertNotCalled(t, "BatchInsertTx", mock.Anything, mock.Anything, mock.Anything)
	assert.Contains(t, svcErr.Msg, "being sent right now", "the error says why and what to do")
}

// A retrying delivery whose waiting job the store's claim cancels is reset
// and queued like a failed one: the service always hands the store its
// claimer.
func TestWebhookService_RetryDeliveryRetryingPassesClaimer(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil)
	ctx := testContext()
	deliveryID, webhookID := uuid.New(), uuid.New()

	repo.On("GetDeliveryByID", mock.Anything, mock.Anything, deliveryID, "default").
		Return(&store.WebhookDelivery{ID: deliveryID, WebhookID: webhookID, EventID: uuid.New(), Status: store.StatusRetrying, MaxAttempts: 4}, nil)
	repo.On("GetWebhookByID", mock.Anything, mock.Anything, webhookID, "default").Return(&store.WebhookRegistration{ID: webhookID, Consumer: "default"}, nil)
	repo.On("ResetDeliveriesForRetry", mock.Anything, []uuid.UUID{deliveryID}).Return([]uuid.UUID{deliveryID}, nil)
	inserter.On("BatchInsertTx", mock.Anything, mock.Anything, mock.MatchedBy(func(jobs []river.JobArgs) bool { return len(jobs) == 1 })).Return(nil)

	ids, count, err := service.RetryDelivery(ctx, "default", deliveryID.String(), "", false)

	require.NoError(t, err)
	assert.Equal(t, int32(1), count)
	assert.Equal(t, []string{deliveryID.String()}, ids)
	assert.True(t, repo.gotClaim, "the service must pass a claimer, or a retrying delivery is never retried")
	inserter.AssertExpectations(t)
}

func TestWebhookService_ListDeliveries_Pagination(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil)

	ctx := testContext()
	consumer := "default"

	expectedDeliveries := []*store.WebhookDelivery{
		{ID: uuid.New(), WebhookID: uuid.New(), EventID: uuid.New()},
	}

	// The service normalises the limit, so match the filter as built by the service.
	repo.On("ListDeliveriesFiltered", mock.Anything, mock.Anything, mock.MatchedBy(func(f store.DeliveryFilter) bool {
		return f.Consumer == consumer && f.Limit == 20
	})).Return(expectedDeliveries, true, nil)

	filter := store.DeliveryFilter{
		Consumer: consumer,
		Limit:    20,
	}
	page, err := service.ListDeliveries(ctx, filter)

	assert.NoError(t, err)
	assert.True(t, page.HasMore)
	assert.Equal(t, len(expectedDeliveries), len(page.Items))
	repo.AssertExpectations(t)
}

func TestWebhookService_CreateSubscription(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil)

	ctx := testContext()
	webhookID := uuid.New().String()
	consumer := "default"
	eventName := "user.created"

	repo.On("LockWebhook", mock.Anything, mock.Anything, mock.Anything, mock.Anything, false).Return(&store.WebhookRegistration{}, nil)
	repo.On("CreateSubscription", mock.Anything, mock.Anything, mock.Anything).Return(nil)

	id, createdAt, err := service.CreateSubscription(ctx, webhookID, eventName, consumer, nil, "POST", 30, false, "", nil, SubscriptionTemplateSettings{})

	assert.NoError(t, err)
	assert.NotEmpty(t, id)
	assert.False(t, createdAt.IsZero())
	repo.AssertExpectations(t)
}

func TestWebhookService_GetEvent(t *testing.T) {
	repo := new(mockRepo)
	service := NewWebhookService(nil, repo, nil)

	ctx := testContext()
	eventName := "test.event"
	event := &store.EventRegistration{
		Name: eventName,
	}

	repo.On("GetEventByName", mock.Anything, mock.Anything, eventName).Return(event, nil)

	res, err := service.GetEvent(ctx, eventName)
	assert.NoError(t, err)
	assert.Equal(t, event, res)
	repo.AssertExpectations(t)
}

func TestWebhookService_TestSubscriptionTemplate(t *testing.T) {
	repo := new(mockRepo)
	service := NewWebhookService(nil, repo, nil)

	ctx := testContext()
	eventName := "test.event"
	event := &store.EventRegistration{
		Name: eventName,
		SamplePayload: map[string]any{
			"id": "123",
		},
	}

	repo.On("GetEventByName", mock.Anything, mock.Anything, eventName).Return(event, nil)

	template := `{"new_id": "{{ .payload.id }}"}`
	res, err := service.TestSubscriptionTemplate(ctx, eventName, template, "default", true, nil)
	assert.NoError(t, err)
	assert.Equal(t, `{"new_id": "123"}`, res, "nil payload renders the sample")

	res, err = service.TestSubscriptionTemplate(ctx, eventName, template, "default", true, map[string]any{"id": "ord_7"})
	assert.NoError(t, err)
	assert.Equal(t, `{"new_id": "ord_7"}`, res, "a given payload replaces the sample")

	_, err = service.TestSubscriptionTemplate(ctx, eventName, template, "default", true, map[string]any{"other": 1})
	assert.Error(t, err, "strict render of a payload without the key fails")

	res, err = service.TestSubscriptionTemplate(ctx, eventName, `{"n": {{len .payload}}}`, "default", true, map[string]any{})
	assert.NoError(t, err)
	assert.Equal(t, `{"n": 0}`, res, "an empty payload is used as given, not replaced by the sample")
	repo.AssertExpectations(t)
}

func TestWebhookService_PushEvent_AutoRegister(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil, WithAutoRegisterEvents(true))

	ctx := testContext()
	consumer := "default"
	eventName := "user.signup"
	payload := map[string]any{"user_id": "123"}

	// Event does not exist — should be auto-registered
	repo.On("GetEventByName", mock.Anything, mock.Anything, eventName).Return(nil, nil)
	repo.On("RegisterEvent", mock.Anything, mock.Anything, mock.MatchedBy(func(e *store.EventRegistration) bool {
		return e.Name == eventName && e.Active == true
	})).Return(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, consumer, eventName, payload, 0, nil, nil, nil, nil)

	assert.NoError(t, err)
	assert.NotEmpty(t, eventID)
	repo.AssertExpectations(t)
	inserter.AssertExpectations(t)
}

func TestWebhookService_PushEvent_ExistingEvent(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil)

	ctx := testContext()
	consumer := "default"
	eventName := "user.created"
	payload := map[string]any{"user_id": "456"}

	// Event already registered and active
	repo.On("GetEventByName", mock.Anything, mock.Anything, eventName).Return(&store.EventRegistration{
		Name:   eventName,
		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, consumer, eventName, payload, 0, nil, nil, nil, nil)

	assert.NoError(t, err)
	assert.NotEmpty(t, eventID)
	// RegisterEvent should NOT be called since event already exists
	repo.AssertNotCalled(t, "RegisterEvent", mock.Anything, mock.Anything, mock.Anything)
	repo.AssertExpectations(t)
	inserter.AssertExpectations(t)
}

func TestWebhookService_PushEvent_Scheduled(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil)
	ctx := testContext()

	repo.On("GetEventByName", mock.Anything, mock.Anything, "user.created").Return(&store.EventRegistration{
		Name:   "user.created",
		Active: true,
	}, nil)
	var rec *store.EventRecord
	repo.On("StoreEventAndEnqueue", mock.Anything, mock.Anything, mock.MatchedBy(func(e *store.EventRecord) bool {
		rec = e
		return true
	})).Return(nil)
	var jobArgs []river.JobArgs
	inserter.On("BatchInsertTx", mock.Anything, mock.Anything, mock.MatchedBy(func(a []river.JobArgs) bool {
		jobArgs = a
		return true
	})).Return(nil)

	// Future scheduled_at lands on the record, the job args and the
	// River insert opts; TTL counts from the scheduled time.
	when := time.Now().Add(2 * time.Hour).Truncate(time.Second)
	_, _, _, _, err := service.PushEvent(ctx, "default", "user.created", nil, 600, nil, nil, nil, &when)
	require.NoError(t, err)
	require.NotNil(t, rec.ScheduledAt)
	assert.True(t, rec.ScheduledAt.Equal(when))
	assert.True(t, rec.ExpiresAt.Equal(when.Add(600*time.Second)), "expiry anchors at the scheduled time, not the push")
	ea := jobArgs[0].(queue.EventArgs)
	assert.True(t, ea.ScheduledAt.Equal(when))
	assert.True(t, ea.InsertOpts().ScheduledAt.Equal(when))

	// A past scheduled_at means immediate: nothing scheduled anywhere.
	past := time.Now().Add(-time.Minute)
	_, _, _, _, err = service.PushEvent(ctx, "default", "user.created", nil, 0, nil, nil, nil, &past)
	require.NoError(t, err)
	assert.Nil(t, rec.ScheduledAt)
	assert.True(t, jobArgs[0].(queue.EventArgs).ScheduledAt.IsZero())

	// Beyond the one-year cap is rejected before anything is stored.
	far := time.Now().Add(maxScheduleAhead + time.Hour)
	_, _, _, _, err = service.PushEvent(ctx, "default", "user.created", nil, 0, nil, nil, nil, &far)
	require.Error(t, err)
	var svcErr *svcerrors.ServiceError
	require.ErrorAs(t, err, &svcErr)
	assert.Equal(t, svcerrors.InvalidArgument, svcErr.Status)
}

func TestWebhookService_PushEvent_InactiveEvent(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil)

	ctx := testContext()

	// Event exists but inactive
	repo.On("GetEventByName", mock.Anything, mock.Anything, "user.deleted").Return(&store.EventRegistration{
		Name:   "user.deleted",
		Active: false,
	}, nil)

	_, _, _, _, err := service.PushEvent(ctx, "default", "user.deleted", nil, 0, nil, nil, nil, nil)

	assert.Error(t, err)
	assert.Contains(t, err.Error(), "inactive")
	repo.AssertExpectations(t)
}

func TestWebhookService_CreateSubscription_CatchAll(t *testing.T) {
	repo := new(mockRepo)
	inserter := new(mockJobInserter)
	service := NewWebhookService(inserter, repo, nil)

	ctx := testContext()
	webhookID := uuid.New().String()
	consumer := "default"

	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
	})).Return(nil)

	id, createdAt, err := service.CreateSubscription(ctx, webhookID, store.CatchAllEventName, consumer, nil, "POST", 30, false, "", nil, SubscriptionTemplateSettings{})

	assert.NoError(t, err)
	assert.NotEmpty(t, id)
	assert.False(t, createdAt.IsZero())
	repo.AssertExpectations(t)
}

func TestWebhookService_UpdateWebhookConfig_MergesSecretHeaderChanges(t *testing.T) {
	repo := new(mockRepo)
	key := bytes.Repeat([]byte{1}, 32)
	cryptoSvc, err := cryptosvc.NewService(key)
	require.NoError(t, err)
	service := NewWebhookService(nil, repo, cryptoSvc)

	ctx := testContext()
	consumer := "default"
	webhookID := uuid.New()
	existingHeaders := map[string]string{
		"Authorization": "Bearer old",
		"X-Trace":       "keep-me",
		"X-Remove":      "drop-me",
	}
	encryptedHeaders, err := service.EncryptSecretHeaders(existingHeaders)
	require.NoError(t, err)

	repo.On("GetWebhookByID", mock.Anything, mock.Anything, webhookID, consumer).Return(&store.WebhookRegistration{
		ID:            webhookID,
		Consumer:      consumer,
		URL:           "https://example.com/webhook",
		Active:        true,
		SecretHeaders: encryptedHeaders,
	}, nil)
	repo.On("UpdateWebhook", mock.Anything, mock.Anything, mock.MatchedBy(func(webhook *store.WebhookRegistration) bool {
		decrypted, err := service.DecryptSecretHeaders(webhook.SecretHeaders)
		require.NoError(t, err)
		return assert.Equal(t, map[string]string{
			"Authorization": "Bearer new",
			"X-Trace":       "keep-me",
		}, decrypted)
	})).Return(nil)
	repo.On("DeleteRateLimitState", mock.Anything, webhookID).Return(nil)

	err = service.UpdateWebhookConfig(ctx, webhookID.String(), consumer, nil, "", nil, true, "", nil, map[string]string{
		"Authorization": "Bearer new",
		"X-Remove":      "",
	}, "", false, []string{"secret_headers"})

	require.NoError(t, err)
	repo.AssertExpectations(t)
}

func TestWebhookService_UpdateWebhookConfig_RejectsOutOfBoundsValues(t *testing.T) {
	repo := new(mockRepo)
	service := NewWebhookService(nil, repo, nil)

	ctx := testContext()
	consumer := "default"
	webhookID := uuid.New()

	repo.On("GetWebhookByID", mock.Anything, mock.Anything, webhookID, consumer).Return(&store.WebhookRegistration{
		ID:                    webhookID,
		Consumer:              consumer,
		URL:                   "https://example.com/webhook",
		Active:                true,
		MaxRetries:            3,
		RetryBackoffSeconds:   60,
		RequestTimeoutSeconds: 30,
		ExpectedStatusCodes:   pq.Int64Array{200},
		ContentType:           "application/json",
	}, nil)

	// request_timeout_seconds above the 300s create-path bound must be
	// rejected on update too; UpdateWebhook must never be called.
	err := service.UpdateWebhookConfig(ctx, webhookID.String(), consumer, nil, "", nil, true, "", &WebhookHTTPConfig{
		RequestTimeoutSeconds: 86400,
	}, nil, "", false, []string{"http_config"})

	require.Error(t, err)
	var svcErr *svcerrors.ServiceError
	require.ErrorAs(t, err, &svcErr)
	assert.Equal(t, svcerrors.InvalidArgument, svcErr.Status)
	repo.AssertNotCalled(t, "UpdateWebhook", mock.Anything, mock.Anything, mock.Anything)
}

func TestWebhookService_RequiresTransform(t *testing.T) {
	ctx := testContext()
	const consumer = "default"
	webhookID := uuid.New()
	requiring := &store.WebhookRegistration{ID: webhookID, Consumer: consumer, RequiresTransform: true}

	t.Run("create webhook with events needs a template", func(t *testing.T) {
		service := NewWebhookService(nil, new(mockRepo), nil)
		_, _, err := service.CreateWebhook(ctx, WebhookRegistrationRequest{
			Consumer: consumer, URL: "https://example.com/hook", Events: []string{"order.created"}, RequiresTransform: true,
		})
		var svcErr *svcerrors.ServiceError
		require.ErrorAs(t, err, &svcErr)
		assert.Equal(t, svcerrors.InvalidArgument, svcErr.Status)
	})

	t.Run("subscription without a transform is refused", func(t *testing.T) {
		repo := new(mockRepo)
		service := NewWebhookService(nil, repo, nil)
		repo.On("LockWebhook", mock.Anything, mock.Anything, webhookID, consumer, false).Return(requiring, nil)

		_, _, err := service.CreateSubscription(ctx, webhookID.String(), "order.created", consumer, nil, "POST", 30, false, "", nil, SubscriptionTemplateSettings{})
		var svcErr *svcerrors.ServiceError
		require.ErrorAs(t, err, &svcErr)
		assert.Equal(t, svcerrors.InvalidArgument, svcErr.Status)
		_, _, err = service.CreateSubscription(ctx, webhookID.String(), "order.created", consumer, nil, "POST", 30, true, "   ", nil, SubscriptionTemplateSettings{})
		require.ErrorAs(t, err, &svcErr)
		repo.AssertNotCalled(t, "CreateSubscription", mock.Anything, mock.Anything, mock.Anything)
	})

	t.Run("turning it on needs every subscription transformed", func(t *testing.T) {
		repo := new(mockRepo)
		service := NewWebhookService(nil, repo, nil)
		repo.On("GetWebhookByID", mock.Anything, mock.Anything, webhookID, consumer).Return(&store.WebhookRegistration{ID: webhookID, Consumer: consumer}, nil)
		repo.On("LockWebhook", mock.Anything, mock.Anything, webhookID, consumer, true).Return(&store.WebhookRegistration{ID: webhookID, Consumer: consumer}, nil)
		repo.On("ListSubscriptions", mock.Anything, mock.Anything, webhookID).Return([]*store.EventSubscription{
			{EventName: "order.created", TransformEnabled: true, TransformTemplate: "{}"},
			{EventName: "order.refunded"},
		}, nil)

		err := service.UpdateWebhookConfig(ctx, webhookID.String(), consumer, nil, "", nil, false, "", nil, nil, "", true, []string{"requires_transform"})
		var svcErr *svcerrors.ServiceError
		require.ErrorAs(t, err, &svcErr)
		assert.Equal(t, svcerrors.FailedPrecondition, svcErr.Status)
		assert.Contains(t, svcErr.Error(), "order.refunded")
		repo.AssertNotCalled(t, "UpdateWebhook", mock.Anything, mock.Anything, mock.Anything)
	})

	t.Run("events cannot be replaced in bulk", func(t *testing.T) {
		repo := new(mockRepo)
		service := NewWebhookService(nil, repo, nil)
		repo.On("GetWebhookByID", mock.Anything, mock.Anything, webhookID, consumer).Return(requiring, nil)

		err := service.UpdateWebhookConfig(ctx, webhookID.String(), consumer, []string{"order.created"}, "", nil, false, "", nil, nil, "", false, []string{"events"})
		var svcErr *svcerrors.ServiceError
		require.ErrorAs(t, err, &svcErr)
		assert.Equal(t, svcerrors.FailedPrecondition, svcErr.Status)
	})
}

func TestWebhookService_RotateWebhookSecret(t *testing.T) {
	repo := new(mockRepo)
	cryptoSvc, err := cryptosvc.NewService(bytes.Repeat([]byte{1}, 32))
	require.NoError(t, err)
	service := NewWebhookService(nil, repo, cryptoSvc)

	ctx := testContext()
	consumer := "default"
	webhookID := uuid.New()
	oldSecret, err := service.EncryptWebhookSecret("whsec_old")
	require.NoError(t, err)

	repo.On("GetWebhookByID", mock.Anything, mock.Anything, webhookID, consumer).Return(&store.WebhookRegistration{
		ID:            webhookID,
		Consumer:      consumer,
		URL:           "https://example.com/webhook",
		Active:        true,
		WebhookSecret: oldSecret,
	}, nil)
	var stored []byte
	repo.On("UpdateWebhook", mock.Anything, mock.Anything, mock.MatchedBy(func(webhook *store.WebhookRegistration) bool {
		stored = webhook.WebhookSecret
		return webhook.ID == webhookID && webhook.URL == "https://example.com/webhook"
	})).Return(nil)

	secret, err := service.RotateWebhookSecret(ctx, webhookID.String(), consumer)

	require.NoError(t, err)
	assert.True(t, strings.HasPrefix(secret, "whsec_"), "secret %q is not in Standard Webhooks format", secret)
	assert.NotEqual(t, "whsec_old", secret)
	decrypted, err := service.DecryptWebhookSecret(stored)
	require.NoError(t, err)
	assert.Equal(t, secret, decrypted, "the returned secret must be the one stored")
	repo.AssertExpectations(t)
}

func (m *mockRepo) GetWebhookHealthState(ctx context.Context, _ uuid.UUID, webhookID uuid.UUID) (*store.WebhookHealthMetrics, error) {
	args := m.Called(ctx, webhookID)
	s, _ := args.Get(0).(*store.WebhookHealthMetrics)
	return s, args.Error(1)
}

func (m *mockRepo) GetWebhookHealthSummary(ctx context.Context, _ uuid.UUID, webhookID uuid.UUID, hours int) (*store.WebhookHealthSummary, error) {
	args := m.Called(ctx, webhookID, hours)
	s, _ := args.Get(0).(*store.WebhookHealthSummary)
	return s, args.Error(1)
}

// Before the health evaluator's first pass there is no health state row, but
// the attempts are already in the attempt log: their counts are reported
// (the API promises zeros only before the first attempt).
func TestWebhookService_GetWebhookHealthBeforeFirstEvaluation(t *testing.T) {
	repo := new(mockRepo)
	service := NewWebhookService(new(mockJobInserter), repo, nil)
	ctx := testContext()
	webhookID := uuid.New()

	repo.On("GetWebhookByID", mock.Anything, mock.Anything, webhookID, "default").
		Return(&store.WebhookRegistration{ID: webhookID, Consumer: "default", Health: store.HealthUnknown}, nil)
	repo.On("GetWebhookHealthState", mock.Anything, webhookID).Return(nil, storage.ErrNotFound)
	repo.On("GetWebhookHealthSummary", mock.Anything, webhookID, store.DefaultHealthRules.WindowHours).
		Return(&store.WebhookHealthSummary{TotalDeliveries: 3, SuccessfulDeliveries: 1, FailedDeliveries: 2, SuccessRate: 1.0 / 3, ServerErrors: 2}, nil)

	h, err := service.GetWebhookHealth(ctx, webhookID.String(), "default")

	require.NoError(t, err)
	assert.Equal(t, 3, h.TotalDeliveries)
	assert.Equal(t, 1, h.SuccessfulDeliveries)
	assert.Equal(t, 2, h.FailedDeliveries)
	assert.Equal(t, 2, h.ServerErrors)
	assert.Zero(t, h.ConsecutiveFailures, "state fields stay empty until the evaluator runs")
	assert.Nil(t, h.LastFailureAt)
}