From a98bcf5ff18f059e7c28f39fd26db8b0821acd9e Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Wed, 14 Aug 2024 09:26:32 +0200 Subject: [PATCH 1/2] fix: eventhandler init --- internal/notification/eventhandler.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/internal/notification/eventhandler.go b/internal/notification/eventhandler.go index b02c06591..d79b5e331 100644 --- a/internal/notification/eventhandler.go +++ b/internal/notification/eventhandler.go @@ -47,14 +47,6 @@ func (c *EventHandlerConfig) Validate() error { return fmt.Errorf("webhook is required") } - if c.Logger == nil { - c.Logger = slog.Default() - } - - if c.ReconcileInterval == 0 { - c.ReconcileInterval = DefaultReconcileInterval - } - return nil } @@ -248,9 +240,19 @@ func NewEventHandler(config EventHandlerConfig) (EventHandler, error) { return nil, err } + if config.ReconcileInterval == 0 { + config.ReconcileInterval = DefaultReconcileInterval + } + + if config.Logger == nil { + config.Logger = slog.Default() + } + return &handler{ - repo: config.Repository, - webhook: config.Webhook, - stopCh: make(chan struct{}), + repo: config.Repository, + webhook: config.Webhook, + reconcileInterval: config.ReconcileInterval, + logger: config.Logger, + stopCh: make(chan struct{}), }, nil } From f7081a80479a140938967c887eed9c00e58c5b76 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Wed, 14 Aug 2024 09:27:37 +0200 Subject: [PATCH 2/2] test: add integration tests for Notification API --- ci/e2e.go | 17 ++ ci/main.go | 3 + ci/versions_pinned.go | 1 + test/notification/channel.go | 168 ++++++++++++++ test/notification/event.go | 298 +++++++++++++++++++++++++ test/notification/helpers.go | 81 +++++++ test/notification/notification_test.go | 139 ++++++++++++ test/notification/rule.go | 246 ++++++++++++++++++++ test/notification/testenv.go | 174 +++++++++++++++ test/notification/webhook.go | 209 +++++++++++++++++ 10 files changed, 1336 insertions(+) create mode 100644 test/notification/channel.go create mode 100644 test/notification/event.go create mode 100644 test/notification/helpers.go create mode 100644 test/notification/notification_test.go create mode 100644 test/notification/rule.go create mode 100644 test/notification/testenv.go create mode 100644 test/notification/webhook.go diff --git a/ci/e2e.go b/ci/e2e.go index e1159dd32..cac033d7a 100644 --- a/ci/e2e.go +++ b/ci/e2e.go @@ -79,3 +79,20 @@ func postgres() *dagger.Service { WithExposedPort(5432). AsService() } + +const ( + SvixJWTSingingSecret = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MjI5NzYyNzMsImV4cCI6MjAzODMzNjI3MywibmJmIjoxNzIyOTc2MjczLCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.PomP6JWRI62W5N4GtNdJm2h635Q5F54eij0J3BU-_Ds" +) + +func svix() *dagger.Service { + return dag.Container(). + From(fmt.Sprintf("svix/svix-server:%s", svixVersion)). + WithEnvVariable("WAIT_FOR", "true"). + WithEnvVariable("SVIX_QUEUE_TYPE", "memory"). + WithEnvVariable("SVIX_CACHE_TYPE", "memory"). + WithEnvVariable("SVIX_DB_DSN", "postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable"). + WithEnvVariable("SVIX_JWT_SECRET", SvixJWTSingingSecret). + WithServiceBinding("postgres", postgres()). + WithExposedPort(8071). + AsService() +} diff --git a/ci/main.go b/ci/main.go index de0825810..f8a620c82 100644 --- a/ci/main.go +++ b/ci/main.go @@ -87,7 +87,10 @@ func (m *Ci) Test() *dagger.Container { WithSource(m.Source). Container(). WithServiceBinding("postgres", postgres()). + WithServiceBinding("svix", svix()). WithEnvVariable("POSTGRES_HOST", "postgres"). + WithEnvVariable("SVIX_HOST", "svix"). + WithEnvVariable("SVIX_JWT_SECRET", SvixJWTSingingSecret). WithExec([]string{"go", "test", "-tags", "musl", "-v", "./..."}) } diff --git a/ci/versions_pinned.go b/ci/versions_pinned.go index d149c0c70..bfb4e11af 100644 --- a/ci/versions_pinned.go +++ b/ci/versions_pinned.go @@ -8,6 +8,7 @@ const ( clickhouseVersion = "24.5.5.78" redisVersion = "7.0.12" postgresVersion = "14.9" + svixVersion = "v1.29" // TODO: add update mechanism for versions below diff --git a/test/notification/channel.go b/test/notification/channel.go new file mode 100644 index 000000000..1622e18b5 --- /dev/null +++ b/test/notification/channel.go @@ -0,0 +1,168 @@ +package notification + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/internal/notification" + notificationwebhook "github.com/openmeterio/openmeter/internal/notification/webhook" + "github.com/openmeterio/openmeter/pkg/models" +) + +func NewCreateChannelInput(name string) notification.CreateChannelInput { + return notification.CreateChannelInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: TestNamespace, + }, + Type: notification.ChannelTypeWebhook, + Name: name, + Disabled: false, + Config: notification.ChannelConfig{ + ChannelConfigMeta: notification.ChannelConfigMeta{ + Type: notification.ChannelTypeWebhook, + }, + WebHook: notification.WebHookChannelConfig{ + CustomHeaders: map[string]interface{}{ + "X-TEST-HEADER": "NotificationChannelTest", + }, + URL: TestWebhookURL, + SigningSecret: TestSigningSecret, + }, + }, + } +} + +type ChannelTestSuite struct { + Env TestEnv +} + +func (s *ChannelTestSuite) TestCreate(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn := NewCreateChannelInput("NotificationCreateChannel") + + channel, err := service.CreateChannel(ctx, createIn) + require.NoError(t, err, "Creating channel must not return error") + require.NotNil(t, channel, "Channel must not be nil") + assert.NotEmpty(t, channel.ID, "Channel ID must not be empty") + assert.Equal(t, createIn.Disabled, channel.Disabled, "Channel must not be disabled") + assert.Equal(t, createIn.Type, channel.Type, "Channel type must be the same") + assert.EqualValues(t, createIn.Config, channel.Config, "Channel config must be the same") +} + +func (s *ChannelTestSuite) TestList(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn1 := NewCreateChannelInput("NotificationListChannel1") + channel1, err := service.CreateChannel(ctx, createIn1) + require.NoError(t, err, "Creating channel must not return error") + require.NotNil(t, channel1, "Channel must not be nil") + + createIn2 := NewCreateChannelInput("NotificationListChannel2") + channel2, err := service.CreateChannel(ctx, createIn2) + require.NoError(t, err, "Creating channel must not return error") + require.NotNil(t, channel2, "Channel must not be nil") + + list, err := service.ListChannels(ctx, notification.ListChannelsInput{ + Namespaces: []string{ + createIn1.Namespace, + createIn2.Namespace, + }, + Channels: []string{ + channel1.ID, + channel2.ID, + }, + OrderBy: "id", + IncludeDisabled: false, + }) + require.NoError(t, err, "Listing channels must not return error") + assert.NotEmpty(t, list.Items, "List of channels must not be empty") + + expectedList := []notification.Channel{ + *channel1, + *channel2, + } + + assert.EqualValues(t, expectedList, list.Items, "Unexpected items returned by listing channels") +} + +func (s *ChannelTestSuite) TestUpdate(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn := NewCreateChannelInput("NotificationUpdateChannel1") + + channel, err := service.CreateChannel(ctx, createIn) + require.NoError(t, err, "Creating channel must not return error") + require.NotNil(t, channel, "Channel must not be nil") + + secret, err := notificationwebhook.NewSigningSecretWithDefaultSize() + require.NoError(t, err, "Generating new signing secret must not return an error") + + updateIn := notification.UpdateChannelInput{ + NamespacedModel: channel.NamespacedModel, + Type: channel.Type, + Name: "NotificationUpdateChannel2", + Disabled: true, + Config: notification.ChannelConfig{ + ChannelConfigMeta: channel.Config.ChannelConfigMeta, + WebHook: notification.WebHookChannelConfig{ + CustomHeaders: map[string]interface{}{ + "X-TEST-HEADER": "NotificationUpdateChannel2", + }, + URL: "http://example.com/update", + SigningSecret: secret, + }, + }, + ID: channel.ID, + } + + channel2, err := service.UpdateChannel(ctx, updateIn) + require.NoError(t, err, "Creating channel must not return error") + require.NotNil(t, channel2, "Channel must not be nil") + + assert.Equal(t, updateIn.Disabled, channel2.Disabled, "Channel must not be disabled") + assert.Equal(t, updateIn.Type, channel2.Type, "Channel type must be the same") + assert.EqualValues(t, updateIn.Config, channel2.Config, "Channel config must be the same") +} + +func (s *ChannelTestSuite) TestDelete(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn := NewCreateChannelInput("NotificationDeleteChannel1") + + channel, err := service.CreateChannel(ctx, createIn) + require.NoError(t, err, "Creating channel must not return error") + require.NotNil(t, channel, "Channel must not be nil") + + err = service.DeleteChannel(ctx, notification.DeleteChannelInput{ + Namespace: channel.Namespace, + ID: channel.ID, + }) + require.NoError(t, err, "Deleting channel must not return error") +} + +func (s *ChannelTestSuite) TestGet(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn := NewCreateChannelInput("NotificationGetChannel1") + + channel, err := service.CreateChannel(ctx, createIn) + require.NoError(t, err, "Creating channel must not return error") + require.NotNil(t, channel, "Channel must not be nil") + + channel2, err := service.GetChannel(ctx, notification.GetChannelInput{ + Namespace: channel.Namespace, + ID: channel.ID, + }) + require.NoError(t, err, "Deleting channel must not return error") + require.NotNil(t, channel2, "Channel must not be nil") + assert.NotEmpty(t, channel2.ID, "Channel ID must not be empty") + assert.Equal(t, channel.Namespace, channel2.Namespace, "Channel namespace must be equal") + assert.Equal(t, channel.ID, channel2.ID, "Channel ID must be equal") + assert.Equal(t, channel.Disabled, channel2.Disabled, "Channel disabled must not be equal") + assert.Equal(t, channel.Type, channel2.Type, "Channel type must be the same") + assert.EqualValues(t, channel.Config, channel2.Config, "Channel config must be the same") +} diff --git a/test/notification/event.go b/test/notification/event.go new file mode 100644 index 000000000..4586544e7 --- /dev/null +++ b/test/notification/event.go @@ -0,0 +1,298 @@ +package notification + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/internal/notification" + "github.com/openmeterio/openmeter/internal/productcatalog" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/errorsx" + "github.com/openmeterio/openmeter/pkg/models" +) + +func NewBalanceThresholdPayload() notification.EventPayload { + return notification.EventPayload{ + EventPayloadMeta: notification.EventPayloadMeta{ + Type: notification.EventTypeBalanceThreshold, + }, + BalanceThreshold: notification.BalanceThresholdPayload{ + Entitlement: api.EntitlementMetered{ + CreatedAt: convert.ToPointer(time.Now().Add(-10 * 24 * time.Hour).UTC()), + CurrentUsagePeriod: api.Period{ + From: time.Now().Add(-24 * time.Hour).UTC(), + To: time.Now().UTC(), + }, + DeletedAt: nil, + FeatureId: "01J4VCZKH5QAF85GE501M8637W", + FeatureKey: "feature-1", + Id: convert.ToPointer("01J4VCTKG06VJ0H78GD0MZBE49"), + IsSoftLimit: nil, + IsUnlimited: nil, + IssueAfterReset: nil, + IssueAfterResetPriority: nil, + LastReset: time.Time{}, + MeasureUsageFrom: time.Time{}, + Metadata: nil, + SubjectKey: "customer-1", + Type: "", + UpdatedAt: convert.ToPointer(time.Now().Add(-2 * time.Hour).UTC()), + UsagePeriod: api.RecurringPeriod{ + Anchor: time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC), + Interval: "MONTH", + }, + }, + Feature: api.Feature{ + ArchivedAt: nil, + CreatedAt: convert.ToPointer(time.Now().Add(-10 * 24 * time.Hour).UTC()), + DeletedAt: nil, + Id: convert.ToPointer("01J4VCZKH5QAF85GE501M8637W"), + Key: "feature-1", + Metadata: nil, + MeterGroupByFilters: nil, + MeterSlug: nil, + Name: "feature-1", + UpdatedAt: convert.ToPointer(time.Now().Add(-24 * time.Hour).UTC()), + }, + Subject: api.Subject{ + CurrentPeriodEnd: &time.Time{}, + CurrentPeriodStart: &time.Time{}, + DisplayName: nil, + Id: convert.ToPointer("01J4VD1XZH5HM705DCPB8XD5QD"), + Key: "customer-1", + Metadata: nil, + StripeCustomerId: nil, + }, + Value: api.EntitlementValue{ + Balance: convert.ToPointer(10000.0), + Config: nil, + HasAccess: convert.ToPointer(true), + Overage: convert.ToPointer(500.0), + Usage: convert.ToPointer(50000.0), + }, + Threshold: api.NotificationRuleBalanceThresholdValue{ + Type: notification.BalanceThresholdTypePercent, + Value: 50, + }, + }, + } +} + +func NewCreateEventInput(t notification.EventType, ruleID string, payload notification.EventPayload) notification.CreateEventInput { + return notification.CreateEventInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: TestNamespace, + }, + Type: t, + Payload: payload, + RuleID: ruleID, + } +} + +type EventTestSuite struct { + Env TestEnv + + channel notification.Channel + rule notification.Rule + subjectKey models.SubjectKey + feature productcatalog.Feature +} + +func (s *EventTestSuite) Setup(ctx context.Context, t *testing.T) { + t.Helper() + + meter, err := s.Env.Meter().GetMeterByIDOrSlug(ctx, TestNamespace, TestMeterSlug) + require.NoError(t, err, "Getting meter must not return error") + + feature, err := s.Env.Feature().GetFeature(ctx, TestNamespace, TestFeatureKey, false) + if _, ok := errorsx.ErrorAs[*productcatalog.FeatureNotFoundError](err); !ok { + require.NoError(t, err, "Getting feature must not return error") + } + if feature != nil { + s.feature = *feature + } else { + s.feature, err = s.Env.Feature().CreateFeature(ctx, productcatalog.CreateFeatureInputs{ + Name: TestFeatureName, + Key: TestFeatureKey, + Namespace: TestNamespace, + MeterSlug: convert.ToPointer(meter.Slug), + MeterGroupByFilters: meter.GroupBy, + }) + } + require.NoError(t, err, "Creating feature must not return error") + + s.subjectKey = TestSubjectKey + + service := s.Env.Notification() + + channelIn := NewCreateChannelInput("NotificationEventTest") + + channel, err := service.CreateChannel(ctx, channelIn) + require.NoError(t, err, "Creating channel must not return error") + require.NotNil(t, channel, "Channel must not be nil") + + s.channel = *channel + + ruleIn := NewCreateRuleInput("NotificationEvent", s.channel.ID) + + rule, err := service.CreateRule(ctx, ruleIn) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, rule, "Rule must not be nil") + + s.rule = *rule +} + +func (s *EventTestSuite) TestCreateEvent(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + input := NewCreateEventInput(notification.EventTypeBalanceThreshold, s.rule.ID, NewBalanceThresholdPayload()) + + event, err := service.CreateEvent(ctx, input) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, event, "Rule must not be nil") + + assert.Equal(t, float64(50), event.Payload.BalanceThreshold.Threshold.Value) +} + +func (s *EventTestSuite) TestGetEvent(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + input := NewCreateEventInput(notification.EventTypeBalanceThreshold, s.rule.ID, NewBalanceThresholdPayload()) + + event, err := service.CreateEvent(ctx, input) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, event, "Rule must not be nil") + + event2, err := service.GetEvent(ctx, notification.GetEventInput{ + NamespacedID: models.NamespacedID{ + Namespace: event.Namespace, + ID: event.ID, + }, + }) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, event2, "Rule must not be nil") +} + +func (s *EventTestSuite) TestListEvents(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn := NewCreateEventInput(notification.EventTypeBalanceThreshold, s.rule.ID, NewBalanceThresholdPayload()) + + event, err := service.CreateEvent(ctx, createIn) + require.NoError(t, err, "Creating notification event must not return error") + require.NotNil(t, event, "Notification event must not be nil") + + listIn := notification.ListEventsInput{ + Namespaces: []string{ + event.Namespace, + }, + Events: []string{event.ID}, + From: event.CreatedAt.Add(-time.Minute), + To: event.CreatedAt.Add(time.Minute), + } + + events, err := service.ListEvents(ctx, listIn) + require.NoError(t, err, "Listing notification events must not return error") + require.NotNil(t, event, "Notification events must not be nil") + + expectedList := []notification.Event{ + *event, + } + + require.Len(t, events.Items, len(expectedList), "List of events must match") + + for idx, e2 := range expectedList { + e1 := events.Items[idx] + + assert.Equal(t, e1.ID, e2.ID, "Event IDs must match") + assert.Equal(t, e1.Namespace, e2.Namespace, "Event namespaces must match") + } +} + +func (s *EventTestSuite) TestListDeliveryStatus(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn := NewCreateEventInput(notification.EventTypeBalanceThreshold, s.rule.ID, NewBalanceThresholdPayload()) + + event, err := service.CreateEvent(ctx, createIn) + require.NoError(t, err, "Creating notification event must not return error") + require.NotNil(t, event, "Notification event must not be nil") + + listIn := notification.ListEventsDeliveryStatusInput{ + Namespaces: []string{ + event.Namespace, + }, + Events: []string{event.ID}, + } + + statuses, err := service.ListEventsDeliveryStatus(ctx, listIn) + require.NoError(t, err, "Listing notification event delivery statuses must not return error") + require.NotNil(t, event, "Notification event delivery statuses must not be nil") + + assert.Equal(t, statuses.TotalCount, len(s.rule.Channels), "Unexpected number of delivery statuses returned by listing events") + + channelsIDs := func() []string { + var channelIDs []string + for _, channel := range s.rule.Channels { + channelIDs = append(channelIDs, channel.ID) + } + + return channelIDs + }() + + for _, status := range statuses.Items { + assert.Equal(t, status.EventID, event.ID, "Unexpected event ID returned by listing events") + assert.Truef(t, slices.Contains(channelsIDs, status.ChannelID), "Unexpected channel ID") + } +} + +func (s *EventTestSuite) TestUpdateDeliveryStatus(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn := NewCreateEventInput(notification.EventTypeBalanceThreshold, s.rule.ID, NewBalanceThresholdPayload()) + + event, err := service.CreateEvent(ctx, createIn) + require.NoError(t, err, "Creating notification event must not return error") + require.NotNil(t, event, "Notification event must not be nil") + + subTests := []struct { + Name string + Input notification.UpdateEventDeliveryStatusInput + }{ + { + Name: "WithID", + Input: notification.UpdateEventDeliveryStatusInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: event.Namespace, + }, + ID: event.DeliveryStatus[0].ID, + State: notification.EventDeliveryStatusStateFailed, + }, + }, + { + Name: "WithEventIDAndChannelID", + Input: notification.UpdateEventDeliveryStatusInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: event.Namespace, + }, + EventID: event.ID, + ChannelID: event.Rule.Channels[0].ID, + State: notification.EventDeliveryStatusStateSuccess, + }, + }, + } + + for _, test := range subTests { + t.Run(test.Name, func(t *testing.T) { + status, err := service.UpdateEventDeliveryStatus(ctx, test.Input) + require.NoError(t, err, "Updating notification event delivery status must not return error") + require.NotNil(t, status, "Notification event must not be nil") + }) + } +} diff --git a/test/notification/helpers.go b/test/notification/helpers.go new file mode 100644 index 000000000..921c787e3 --- /dev/null +++ b/test/notification/helpers.go @@ -0,0 +1,81 @@ +package notification + +import ( + "context" + "crypto/rand" + "fmt" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + clickhousedriver "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/golang-jwt/jwt/v5" + "github.com/oklog/ulid/v2" + + "github.com/openmeterio/openmeter/internal/ent/db" + "github.com/openmeterio/openmeter/internal/meter" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" +) + +func NewSvixAuthToken(signingSecret string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Issuer: "svix-server", + Subject: "org_23rb8YdGqMT0qIzpgGwdXfHirMu", + ExpiresAt: jwt.NewNumericDate(time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)), + NotBefore: jwt.NewNumericDate(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), + IssuedAt: jwt.NewNumericDate(time.Now().UTC()), + }) + + return token.SignedString([]byte(signingSecret)) +} + +func NewClickhouseClient(addr string) (clickhousedriver.Conn, error) { + return clickhouse.Open(&clickhouse.Options{ + Addr: []string{addr}, + Auth: clickhouse.Auth{ + Database: "openmeter", + Username: "default", + Password: "default", + }, + DialTimeout: time.Duration(10) * time.Second, + MaxOpenConns: 5, + MaxIdleConns: 5, + ConnMaxLifetime: time.Duration(10) * time.Minute, + ConnOpenStrategy: clickhouse.ConnOpenInOrder, + BlockBufferSize: 10, + }) +} + +func NewMeterRepository() meter.Repository { + return meter.NewInMemoryRepository([]models.Meter{ + { + Namespace: TestNamespace, + ID: ulid.MustNew(ulid.Timestamp(time.Now().UTC()), rand.Reader).String(), + Slug: TestMeterSlug, + Aggregation: models.MeterAggregationSum, + EventType: "request", + ValueProperty: "$.duration_ms", + GroupBy: map[string]string{ + "method": "$.method", + "path": "$.path", + }, + WindowSize: "MINUTE", + }, + }) +} + +func NewPGClient(url string) (*db.Client, error) { + driver, err := entutils.GetPGDriver(url) + if err != nil { + return nil, fmt.Errorf("failed to init postgres driver: %w", err) + } + + // initialize client & run migrations + dbClient := db.NewClient(db.Driver(driver)) + + if err = dbClient.Schema.Create(context.Background()); err != nil { + return nil, fmt.Errorf("failed to migrate databse schme: %w", err) + } + + return dbClient, nil +} diff --git a/test/notification/notification_test.go b/test/notification/notification_test.go new file mode 100644 index 000000000..2f3fc77b7 --- /dev/null +++ b/test/notification/notification_test.go @@ -0,0 +1,139 @@ +package notification + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNotification(t *testing.T) { + env, err := NewTestEnv() + require.NoError(t, err, "NotificationTestEnv() failed") + require.NotNil(t, env.Notification()) + require.NotNil(t, env.NotificationRepo()) + require.NotNil(t, env.Feature()) + + defer func() { + if err := env.Close(); err != nil { + t.Errorf("failed to close environment: %v", err) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Test suite for testing integration with webhook provider (Svix) + t.Run("Webhook", func(t *testing.T) { + testSuite := WebhookTestSuite{ + Env: env, + } + + testSuite.Setup(ctx, t) + + t.Run("CreateWebhook", func(t *testing.T) { + testSuite.TestCreateWebhook(ctx, t) + }) + + t.Run("UpdateWebhook", func(t *testing.T) { + testSuite.TestUpdateWebhook(ctx, t) + }) + + t.Run("DeleteWebhook", func(t *testing.T) { + testSuite.TestDeleteWebhook(ctx, t) + }) + + t.Run("GetWebhook", func(t *testing.T) { + testSuite.TestGetWebhook(ctx, t) + }) + + t.Run("ListWebhook", func(t *testing.T) { + testSuite.TestListWebhook(ctx, t) + }) + }) + + // Test suite covering notification channels + t.Run("Channel", func(t *testing.T) { + testSuite := ChannelTestSuite{ + Env: env, + } + + t.Run("Create", func(t *testing.T) { + testSuite.TestCreate(ctx, t) + }) + + t.Run("List", func(t *testing.T) { + testSuite.TestList(ctx, t) + }) + + t.Run("Update", func(t *testing.T) { + testSuite.TestUpdate(ctx, t) + }) + + t.Run("Delete", func(t *testing.T) { + testSuite.TestDelete(ctx, t) + }) + + t.Run("Get", func(t *testing.T) { + testSuite.TestGet(ctx, t) + }) + }) + + // Test suite covering notification rules + t.Run("Rule", func(t *testing.T) { + testSuite := RuleTestSuite{ + Env: env, + } + + testSuite.Setup(ctx, t) + + t.Run("Create", func(t *testing.T) { + testSuite.TestCreate(ctx, t) + }) + + t.Run("List", func(t *testing.T) { + testSuite.TestList(ctx, t) + }) + + t.Run("Update", func(t *testing.T) { + testSuite.TestUpdate(ctx, t) + }) + + t.Run("Delete", func(t *testing.T) { + testSuite.TestDelete(ctx, t) + }) + + t.Run("Get", func(t *testing.T) { + testSuite.TestGet(ctx, t) + }) + }) + + // Test suite covering notification events + t.Run("Event", func(t *testing.T) { + testSuite := EventTestSuite{ + Env: env, + } + + testSuite.Setup(ctx, t) + + t.Run("CreateEvent", func(t *testing.T) { + testSuite.TestCreateEvent(ctx, t) + }) + + t.Run("GetEvent", func(t *testing.T) { + testSuite.TestGetEvent(ctx, t) + }) + + t.Run("ListEvents", func(t *testing.T) { + testSuite.TestListEvents(ctx, t) + }) + + t.Run("TestListDeliveryStatus", func(t *testing.T) { + testSuite.TestListDeliveryStatus(ctx, t) + }) + + t.Run("TestUpdateDeliveryStatus", func(t *testing.T) { + testSuite.TestUpdateDeliveryStatus(ctx, t) + }) + }) +} diff --git a/test/notification/rule.go b/test/notification/rule.go new file mode 100644 index 000000000..dc71d9192 --- /dev/null +++ b/test/notification/rule.go @@ -0,0 +1,246 @@ +package notification + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/internal/notification" + "github.com/openmeterio/openmeter/internal/productcatalog" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/errorsx" + "github.com/openmeterio/openmeter/pkg/models" +) + +func NewCreateRuleInput(name string, channels ...string) notification.CreateRuleInput { + return notification.CreateRuleInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: TestNamespace, + }, + Type: notification.RuleTypeBalanceThreshold, + Name: name, + Disabled: false, + Config: notification.RuleConfig{ + RuleConfigMeta: notification.RuleConfigMeta{ + Type: notification.RuleTypeBalanceThreshold, + }, + BalanceThreshold: notification.BalanceThresholdRuleConfig{ + Features: nil, + Thresholds: []notification.BalanceThreshold{ + { + Type: notification.BalanceThresholdTypeNumber, + Value: 1000, + }, + { + Type: notification.BalanceThresholdTypePercent, + Value: 95, + }, + }, + }, + }, + Channels: channels, + } +} + +type RuleTestSuite struct { + Env TestEnv + + channel notification.Channel + feature productcatalog.Feature +} + +func (s *RuleTestSuite) Setup(ctx context.Context, t *testing.T) { + t.Helper() + + service := s.Env.Notification() + + meter, err := s.Env.Meter().GetMeterByIDOrSlug(ctx, TestNamespace, TestMeterSlug) + require.NoError(t, err, "Getting meter must not return error") + + feature, err := s.Env.Feature().GetFeature(ctx, TestNamespace, TestFeatureKey, false) + if _, ok := errorsx.ErrorAs[*productcatalog.FeatureNotFoundError](err); !ok { + require.NoError(t, err, "Getting feature must not return error") + } + if feature != nil { + s.feature = *feature + } else { + s.feature, err = s.Env.Feature().CreateFeature(ctx, productcatalog.CreateFeatureInputs{ + Name: TestFeatureName, + Key: TestFeatureKey, + Namespace: TestNamespace, + MeterSlug: convert.ToPointer(meter.Slug), + MeterGroupByFilters: meter.GroupBy, + }) + } + require.NoError(t, err, "Creating feature must not return error") + + input := NewCreateChannelInput("NotificationRuleTest") + + channel, err := service.CreateChannel(ctx, input) + require.NoError(t, err, "Creating channel must not return error") + require.NotNil(t, channel, "Channel must not be nil") + + s.channel = *channel +} + +func (s *RuleTestSuite) TestCreate(ctx context.Context, t *testing.T) { + t.Helper() + + service := s.Env.Notification() + + tests := []struct { + Name string + CreateIn notification.CreateRuleInput + }{ + { + Name: "WithoutFeature", + CreateIn: NewCreateRuleInput("NotificationCreateRuleWithoutFeature", s.channel.ID), + }, + { + Name: "WithFeature", + CreateIn: func() notification.CreateRuleInput { + createIn := NewCreateRuleInput("NotificationCreateRuleWithFeature", s.channel.ID) + createIn.Config.BalanceThreshold.Features = []string{s.feature.Key} + + return createIn + }(), + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + rule, err := service.CreateRule(ctx, test.CreateIn) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, rule, "Rule must not be nil") + assert.NotEmpty(t, rule.ID, "Rule ID must not be empty") + assert.Equal(t, test.CreateIn.Disabled, rule.Disabled, "Rule must not be disabled") + assert.Equal(t, test.CreateIn.Type, rule.Type, "Rule type must be the same") + assert.EqualValues(t, test.CreateIn.Config, rule.Config, "Rule config must be the same") + }) + } +} + +func (s *RuleTestSuite) TestList(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn1 := NewCreateRuleInput("NotificationListRule1", s.channel.ID) + rule1, err := service.CreateRule(ctx, createIn1) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, rule1, "Rule must not be nil") + + createIn2 := NewCreateRuleInput("NotificationListRule2", s.channel.ID) + rule2, err := service.CreateRule(ctx, createIn2) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, rule2, "Rule must not be nil") + + list, err := service.ListRules(ctx, notification.ListRulesInput{ + Namespaces: []string{ + createIn1.Namespace, + createIn2.Namespace, + }, + Rules: []string{ + rule1.ID, + rule2.ID, + }, + OrderBy: "id", + IncludeDisabled: false, + }) + require.NoError(t, err, "Listing rules must not return error") + assert.NotEmpty(t, list.Items, "List of rules must not be empty") + + expectedList := []notification.Rule{ + *rule1, + *rule2, + } + + require.Len(t, list.Items, len(expectedList), "List of rules must match") + + for idx, r2 := range expectedList { + r1 := list.Items[idx] + + assert.Equal(t, r1.ID, r2.ID, "Rule IDs must match") + assert.Equal(t, r1.Namespace, r2.Namespace, "Rule namespaces must match") + } +} + +func (s *RuleTestSuite) TestUpdate(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn := NewCreateRuleInput("NotificationUpdateRule1", s.channel.ID) + rule, err := service.CreateRule(ctx, createIn) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, rule, "Rule must not be nil") + + updateIn := notification.UpdateRuleInput{ + NamespacedModel: rule.NamespacedModel, + Type: rule.Type, + Name: "NotificationUpdateRule2", + Disabled: true, + Config: notification.RuleConfig{ + RuleConfigMeta: notification.RuleConfigMeta{ + Type: rule.Config.Type, + }, + BalanceThreshold: notification.BalanceThresholdRuleConfig{ + Features: rule.Config.BalanceThreshold.Features, + Thresholds: append(rule.Config.BalanceThreshold.Thresholds, notification.BalanceThreshold{ + Type: notification.BalanceThresholdTypeNumber, + Value: 2000, + }), + }, + }, + ID: rule.ID, + } + + rule2, err := service.UpdateRule(ctx, updateIn) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, rule2, "Rule must not be nil") + + assert.Equal(t, updateIn.Disabled, rule2.Disabled, "Rule must not be disabled") + assert.Equal(t, updateIn.Type, rule2.Type, "Rule type must be the same") + assert.EqualValues(t, updateIn.Config, rule2.Config, "Rule config must be the same") +} + +func (s *RuleTestSuite) TestDelete(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn := NewCreateRuleInput("NotificationDeleteRule1", s.channel.ID) + + rule, err := service.CreateRule(ctx, createIn) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, rule, "Rule must not be nil") + assert.NotEmpty(t, rule.ID, "Rule ID must not be empty") + + err = service.DeleteRule(ctx, notification.DeleteRuleInput{ + Namespace: rule.Namespace, + ID: rule.ID, + }) + require.NoError(t, err, "Deleting rule must not return error") +} + +func (s *RuleTestSuite) TestGet(ctx context.Context, t *testing.T) { + service := s.Env.Notification() + + createIn := NewCreateRuleInput("NotificationGetRule1", s.channel.ID) + + rule, err := service.CreateRule(ctx, createIn) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, rule, "Rule must not be nil") + + getIn := notification.GetRuleInput{ + Namespace: rule.Namespace, + ID: rule.ID, + } + + rule2, err := service.GetRule(ctx, getIn) + require.NoError(t, err, "Creating rule must not return error") + require.NotNil(t, rule2, "Rule must not be nil") + + assert.Equal(t, rule.Namespace, rule2.Namespace, "Rule namespace must be equal") + assert.Equal(t, rule.ID, rule2.ID, "Rule ID must be equal") + assert.Equal(t, rule.Disabled, rule2.Disabled, "Rule must not be disabled") + assert.Equal(t, rule.Type, rule.Type, "Rule type must be the same") + assert.Equal(t, rule.Channels, rule.Channels, "Rule channels must be the same") + assert.EqualValues(t, rule.Config, rule.Config, "Rule config must be the same") +} diff --git a/test/notification/testenv.go b/test/notification/testenv.go new file mode 100644 index 000000000..ad0edd249 --- /dev/null +++ b/test/notification/testenv.go @@ -0,0 +1,174 @@ +package notification + +import ( + "errors" + "fmt" + "log/slog" + "os" + + "github.com/openmeterio/openmeter/internal/meter" + "github.com/openmeterio/openmeter/internal/notification" + notificationrepository "github.com/openmeterio/openmeter/internal/notification/repository" + notificationwebhook "github.com/openmeterio/openmeter/internal/notification/webhook" + "github.com/openmeterio/openmeter/internal/productcatalog" + productcatalogadapter "github.com/openmeterio/openmeter/internal/productcatalog/adapter" + "github.com/openmeterio/openmeter/pkg/defaultx" +) + +const ( + TestNamespace = "default" + TestMeterSlug = "api-call" + TestFeatureName = "API Requests" + TestFeatureKey = "api-call" + TestSubjectKey = "john-doe" + // TestWebhookURL is the target URL where the notifications are sent to. + // Use the following URL to verify notifications events sent over webhook channel: + // https://play.svix.com/view/e_eyihAQHBB5d6T9ck1iYevP825pg + TestWebhookURL = "https://play.svix.com/in/e_eyihAQHBB5d6T9ck1iYevP825pg/" + // TestSigningSecret used for verifying events sent to webhook. + TestSigningSecret = "whsec_Fk5kgr5qTdPdQIDniFv+6K0WN2bUpdGjjGtaNeAx8N8=" + + PostgresURLTemplate = "postgres://postgres:postgres@%s:5432/postgres?sslmode=disable" + SvixServerURLTemplate = "http://%s:8071" +) + +type TestEnv interface { + NotificationRepo() notification.Repository + Notification() notification.Service + NotificationWebhook() notificationwebhook.Handler + + Feature() productcatalog.FeatureConnector + Meter() meter.Repository + + Close() error +} + +var _ TestEnv = (*testEnv)(nil) + +type testEnv struct { + notificationRepo notification.Repository + notification notification.Service + webhook notificationwebhook.Handler + + feature productcatalog.FeatureConnector + meter meter.Repository + + closerFunc func() error +} + +func (n testEnv) Close() error { + return n.closerFunc() +} + +func (n testEnv) NotificationRepo() notification.Repository { + return n.notificationRepo +} + +func (n testEnv) Notification() notification.Service { + return n.notification +} + +func (n testEnv) NotificationWebhook() notificationwebhook.Handler { + return n.webhook +} + +func (n testEnv) Feature() productcatalog.FeatureConnector { + return n.feature +} + +func (n testEnv) Meter() meter.Repository { + return n.meter +} + +const ( + DefaultPostgresHost = "127.0.0.1" + DefaultSvixHost = "127.0.0.1" + DefaultSvixJWTSigningSecret = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MjI5NzYyNzMsImV4cCI6MjAzODMzNjI3MywibmJmIjoxNzIyOTc2MjczLCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.PomP6JWRI62W5N4GtNdJm2h635Q5F54eij0J3BU-_Ds" +) + +func NewTestEnv() (TestEnv, error) { + postgresHost := defaultx.IfZero(os.Getenv("POSTGRES_HOST"), DefaultPostgresHost) + if postgresHost == "" { + return nil, errors.New("POSTGRES_HOST environment variable not set") + } + + svixHost := defaultx.IfZero(os.Getenv("SVIX_HOST"), DefaultSvixHost) + if svixHost == "" { + return nil, errors.New("SVIX_HOST environment variable not set") + } + + svixJWTSigningSecret := defaultx.IfZero(os.Getenv("SVIX_JWT_SECRET"), DefaultSvixJWTSigningSecret) + if svixJWTSigningSecret == "" { + return nil, errors.New("SVIX_JWT_SECRET environment variable not set") + } + + logger := slog.Default().WithGroup("notification") + + pgClient, err := NewPGClient(fmt.Sprintf(PostgresURLTemplate, postgresHost)) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + if err := pgClient.Close(); err != nil { + logger.Error("failed to close postgres client", slog.String("error", err.Error())) + } + } + }() + + meterRepository := NewMeterRepository() + + featureAdapter := productcatalogadapter.NewPostgresFeatureRepo(pgClient, logger.WithGroup("feature.postgres")) + featureConnector := productcatalog.NewFeatureConnector(featureAdapter, meterRepository) + + repo, err := notificationrepository.New(notificationrepository.Config{ + Client: pgClient, + Logger: logger.WithGroup("postgres"), + }) + if err != nil { + return nil, fmt.Errorf("failed to create notification repo: %w", err) + } + + // Setup webhook provider + + apiToken, err := NewSvixAuthToken(svixJWTSigningSecret) + if err != nil { + return nil, fmt.Errorf("failed to generate Svix API token: %w", err) + } + + logger.Info("Svix API Token", slog.String("token", apiToken)) + + webhook, err := notificationwebhook.New(notificationwebhook.Config{ + SvixConfig: notificationwebhook.SvixConfig{ + APIToken: apiToken, + ServerURL: fmt.Sprintf(SvixServerURLTemplate, svixHost), + Debug: false, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create webhook handler: %w", err) + } + + service, err := notification.New(notification.Config{ + Repository: repo, + FeatureConnector: featureConnector, + Webhook: webhook, + Logger: logger.WithGroup("notification"), + }) + if err != nil { + return nil, err + } + + closerFunc := func() error { + return pgClient.Close() + } + + return &testEnv{ + notificationRepo: repo, + notification: service, + webhook: webhook, + feature: featureConnector, + meter: meterRepository, + closerFunc: closerFunc, + }, nil +} diff --git a/test/notification/webhook.go b/test/notification/webhook.go new file mode 100644 index 000000000..271614083 --- /dev/null +++ b/test/notification/webhook.go @@ -0,0 +1,209 @@ +package notification + +import ( + "context" + "crypto/rand" + "testing" + "time" + + "github.com/oklog/ulid/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + notificationwebhook "github.com/openmeterio/openmeter/internal/notification/webhook" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/defaultx" +) + +func NewCreateWebhookInput(id *string, desc string) notificationwebhook.CreateWebhookInput { + if id == nil || *id == "" { + uid := ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader) + id = convert.ToPointer(uid.String()) + } + + return notificationwebhook.CreateWebhookInput{ + Namespace: TestNamespace, + ID: id, + URL: TestWebhookURL, + CustomHeaders: nil, + Disabled: false, + Secret: convert.ToPointer(TestSigningSecret), + RateLimit: nil, + Description: convert.ToPointer(desc), + EventTypes: nil, + Channels: nil, + } +} + +type WebhookTestSuite struct { + Env TestEnv +} + +func (s *WebhookTestSuite) Setup(ctx context.Context, t *testing.T) { + err := s.Env.NotificationWebhook().RegisterEventTypes(ctx, notificationwebhook.RegisterEventTypesInputs{ + EvenTypes: notificationwebhook.NotificationEventTypes, + }) + assert.NoError(t, err, "Registering event types must not fail") +} + +func (s *WebhookTestSuite) TestCreateWebhook(ctx context.Context, t *testing.T) { + wb := s.Env.NotificationWebhook() + + input := NewCreateWebhookInput(nil, "TestCreateWebhook") + + webhook, err := wb.CreateWebhook(ctx, input) + require.NoError(t, err, "Creating webhook must not return error") + require.NotNil(t, wb, "Webhook must not be nil") + + assert.Equal(t, input.Namespace, webhook.Namespace, "Webhook namespace must match") + assert.Equal(t, input.URL, webhook.URL, "Webhook url must match") + assert.Equal(t, input.Disabled, webhook.Disabled, "Webhook disabled must match") + if input.Secret != nil { + assert.Equal(t, defaultx.WithDefault(input.Secret, ""), webhook.Secret, "Webhook secret must match") + } + assert.Equal(t, defaultx.WithDefault(input.Description, ""), webhook.Description, "Webhook description must match") + assert.Equal(t, input.EventTypes, webhook.EventTypes, "Webhook event types must match") + assert.Equal(t, input.Channels, webhook.Channels, "Webhook channels must match") + assert.NotZero(t, webhook.CreatedAt, "Webhook created at timestamp must not be empty") + assert.NotZero(t, webhook.UpdatedAt, "Webhook updated at timestamp must not be empty") +} + +func (s *WebhookTestSuite) TestUpdateWebhook(ctx context.Context, t *testing.T) { + wb := s.Env.NotificationWebhook() + + createIn := NewCreateWebhookInput(nil, "TestUpdateWebhook") + + webhook, err := wb.CreateWebhook(ctx, createIn) + require.NoError(t, err, "Creating webhook must not return error", "createIn", createIn) + require.NotNil(t, webhook, "Webhook must not be nil") + + updateIn := notificationwebhook.UpdateWebhookInput{ + Namespace: TestNamespace, + ID: webhook.ID, + URL: "http://example2.com/", + CustomHeaders: map[string]string{ + "X-Test-Header": "test-value", + }, + Disabled: true, + Secret: convert.ToPointer("whsec_mCP4QSwe52D0IEU/UXLSD6Fif1RykRRMFHL0KJnGeQg="), + RateLimit: convert.ToPointer[int32](50), + Description: convert.ToPointer(webhook.Description), + EventTypes: nil, + Channels: []string{"test-channel"}, + } + + updatedWebhook, err := wb.UpdateWebhook(ctx, updateIn) + require.NoError(t, err, "Updating webhook must not return error", "updateIn", updateIn) + require.NotNil(t, updatedWebhook, "Webhook must not be nil") + + assert.Equal(t, updateIn.Namespace, updatedWebhook.Namespace, "Webhook namespace must match") + assert.Equal(t, updateIn.URL, updatedWebhook.URL, "Webhook url must match") + assert.Equal(t, updateIn.Disabled, updatedWebhook.Disabled, "Webhook disabled must match") + assert.Equal(t, defaultx.WithDefault(updateIn.Secret, ""), updatedWebhook.Secret, "Webhook secret must match") + assert.Equal(t, defaultx.WithDefault(updateIn.Description, ""), updatedWebhook.Description, "Webhook description must match") + assert.Equal(t, updateIn.EventTypes, updatedWebhook.EventTypes, "Webhook event types must match") + assert.Equal(t, updateIn.Channels, updatedWebhook.Channels, "Webhook channels must match") + assert.NotZero(t, updatedWebhook.CreatedAt, "Webhook channels must match") + assert.NotZero(t, updatedWebhook.UpdatedAt, "Webhook channels must match") +} + +func (s *WebhookTestSuite) TestDeleteWebhook(ctx context.Context, t *testing.T) { + wb := s.Env.NotificationWebhook() + + createIn := NewCreateWebhookInput(nil, "TestDeleteWebhook") + + webhook, err := wb.CreateWebhook(ctx, createIn) + require.NoError(t, err, "Creating webhook must not return error") + require.NotNil(t, webhook, "Webhook must not be nil") + + deleteIn := notificationwebhook.DeleteWebhookInput{ + Namespace: webhook.Namespace, + ID: webhook.ID, + } + + err = wb.DeleteWebhook(ctx, deleteIn) + require.NoError(t, err, "Creating webhook must not return error") +} + +func (s *WebhookTestSuite) TestGetWebhook(ctx context.Context, t *testing.T) { + wb := s.Env.NotificationWebhook() + + createIn := NewCreateWebhookInput(nil, "TestGetWebhook") + + webhook, err := wb.CreateWebhook(ctx, createIn) + require.NoError(t, err, "Creating webhook must not return error") + require.NotNil(t, wb, "Webhook must not be nil") + + webhook2, err := wb.GetWebhook(ctx, notificationwebhook.GetWebhookInput{ + Namespace: webhook.Namespace, + ID: webhook.ID, + }) + require.NoError(t, err, "Fetching webhook must not return error") + require.NotNil(t, wb, "Webhook must not be nil") + + assert.Equal(t, webhook.Namespace, webhook2.Namespace, "Webhook namespace must match") + assert.Equal(t, webhook.URL, webhook2.URL, "Webhook url must match") + assert.Equal(t, webhook.Disabled, webhook2.Disabled, "Webhook disabled must match") + assert.Equal(t, webhook.Secret, webhook2.Secret, "Webhook secret must match") + assert.Equal(t, webhook.Description, webhook2.Description, "Webhook description must match") + assert.Equal(t, webhook.EventTypes, webhook2.EventTypes, "Webhook event types must match") + assert.Equal(t, webhook.Channels, webhook2.Channels, "Webhook channels must match") + assert.Equal(t, webhook.CreatedAt, webhook2.CreatedAt, "Webhook created at must match") +} + +func (s *WebhookTestSuite) TestListWebhook(ctx context.Context, t *testing.T) { + wb := s.Env.NotificationWebhook() + + createIn1 := NewCreateWebhookInput(nil, "TestListWebhook1") + + webhook1, err := wb.CreateWebhook(ctx, createIn1) + require.NoError(t, err, "Creating webhook must not return error") + require.NotNil(t, wb, "Webhook must not be nil") + + createIn2 := NewCreateWebhookInput(nil, "TestListWebhook2") + createIn2.EventTypes = []string{ + notificationwebhook.EntitlementsBalanceThresholdType, + } + + webhook2, err := wb.CreateWebhook(ctx, createIn2) + require.NoError(t, err, "Creating webhook must not return error") + require.NotNil(t, wb, "Webhook must not be nil") + + createIn3 := NewCreateWebhookInput(nil, "TestListWebhook3") + createIn3.Channels = []string{ + "test-channel", + } + + webhook3, err := wb.CreateWebhook(ctx, createIn3) + require.NoError(t, err, "Creating webhook must not return error") + require.NotNil(t, wb, "Webhook must not be nil") + + list, err := wb.ListWebhooks(ctx, notificationwebhook.ListWebhooksInput{ + Namespace: TestNamespace, + IDs: []string{webhook1.ID}, + EventTypes: webhook2.EventTypes, + Channels: webhook3.Channels, + }) + require.NoError(t, err, "Creating webhook must not return error") + require.NotNil(t, list, "Webhook list must not be nil") + + expectedWebhooks := map[string]notificationwebhook.Webhook{ + webhook1.ID: *webhook1, + webhook2.ID: *webhook2, + webhook3.ID: *webhook3, + } + + for _, webhook := range list { + expectedWebhook, ok := expectedWebhooks[webhook.ID] + require.True(t, ok, "Expected webhook to exist") + + assert.Equal(t, webhook.Namespace, expectedWebhook.Namespace, "Webhook namespace must match") + assert.Equal(t, webhook.URL, expectedWebhook.URL, "Webhook url must match") + assert.Equal(t, webhook.Disabled, expectedWebhook.Disabled, "Webhook disabled must match") + assert.Equal(t, webhook.Secret, expectedWebhook.Secret, "Webhook secret must match") + assert.Equal(t, webhook.Description, expectedWebhook.Description, "Webhook description must match") + assert.Equal(t, webhook.EventTypes, expectedWebhook.EventTypes, "Webhook event types must match") + assert.Equal(t, webhook.Channels, expectedWebhook.Channels, "Webhook channels must match") + assert.Equal(t, webhook.CreatedAt, expectedWebhook.CreatedAt, "Webhook created at must match") + } +}