Skip to content

Commit ea03147

Browse files
authored
feat: Enable & disable destination (#89)
* refactor: Use shared method to retrieve destination * fix: Entity bug when setting destination disabled_at field * fix: Entity bug resetting destination disabled_at field * feat: Enable & disable API * test: e2e test for destination disable & enable API endpoints * test: Unit test destination enable & disable * feat: Filter out disabled destinations when matching events
1 parent 148f644 commit ea03147

File tree

6 files changed

+315
-32
lines changed

6 files changed

+315
-32
lines changed

cmd/e2e/api_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,146 @@ func (suite *basicSuite) TestDestinationsListAPI() {
734734
suite.RunAPITests(suite.T(), tests)
735735
}
736736

737+
func (suite *basicSuite) TestDestinationEnableDisableAPI() {
738+
tenantID := uuid.New().String()
739+
sampleDestinationID := uuid.New().String()
740+
tests := []APITest{
741+
{
742+
Name: "PUT /:tenantID",
743+
Request: suite.AuthRequest(httpclient.Request{
744+
Method: httpclient.MethodPUT,
745+
Path: "/" + tenantID,
746+
}),
747+
Expected: APITestExpectation{
748+
Match: &httpclient.Response{
749+
StatusCode: http.StatusCreated,
750+
},
751+
},
752+
},
753+
{
754+
Name: "POST /:tenantID/destinations",
755+
Request: suite.AuthRequest(httpclient.Request{
756+
Method: httpclient.MethodPOST,
757+
Path: "/" + tenantID + "/destinations",
758+
Body: map[string]interface{}{
759+
"id": sampleDestinationID,
760+
"type": "webhook",
761+
"topics": "*",
762+
"config": map[string]interface{}{
763+
"url": "http://host.docker.internal:4444",
764+
},
765+
},
766+
}),
767+
Expected: APITestExpectation{
768+
Match: &httpclient.Response{
769+
StatusCode: http.StatusCreated,
770+
},
771+
},
772+
},
773+
{
774+
Name: "GET /:tenantID/destinations/:destinationID",
775+
Request: suite.AuthRequest(httpclient.Request{
776+
Method: httpclient.MethodGET,
777+
Path: "/" + tenantID + "/destinations/" + sampleDestinationID,
778+
}),
779+
Expected: APITestExpectation{
780+
Validate: makeDestinationDisabledValidator(sampleDestinationID, false),
781+
},
782+
},
783+
{
784+
Name: "PUT /:tenantID/destinations/:destinationID/disable",
785+
Request: suite.AuthRequest(httpclient.Request{
786+
Method: httpclient.MethodPUT,
787+
Path: "/" + tenantID + "/destinations/" + sampleDestinationID + "/disable",
788+
}),
789+
Expected: APITestExpectation{
790+
Validate: makeDestinationDisabledValidator(sampleDestinationID, true),
791+
},
792+
},
793+
{
794+
Name: "GET /:tenantID/destinations/:destinationID",
795+
Request: suite.AuthRequest(httpclient.Request{
796+
Method: httpclient.MethodGET,
797+
Path: "/" + tenantID + "/destinations/" + sampleDestinationID,
798+
}),
799+
Expected: APITestExpectation{
800+
Validate: makeDestinationDisabledValidator(sampleDestinationID, true),
801+
},
802+
},
803+
{
804+
Name: "PUT /:tenantID/destinations/:destinationID/enable",
805+
Request: suite.AuthRequest(httpclient.Request{
806+
Method: httpclient.MethodPUT,
807+
Path: "/" + tenantID + "/destinations/" + sampleDestinationID + "/enable",
808+
}),
809+
Expected: APITestExpectation{
810+
Validate: makeDestinationDisabledValidator(sampleDestinationID, false),
811+
},
812+
},
813+
{
814+
Name: "GET /:tenantID/destinations/:destinationID",
815+
Request: suite.AuthRequest(httpclient.Request{
816+
Method: httpclient.MethodGET,
817+
Path: "/" + tenantID + "/destinations/" + sampleDestinationID,
818+
}),
819+
Expected: APITestExpectation{
820+
Validate: makeDestinationDisabledValidator(sampleDestinationID, false),
821+
},
822+
},
823+
{
824+
Name: "PUT /:tenantID/destinations/:destinationID/enable duplicate",
825+
Request: suite.AuthRequest(httpclient.Request{
826+
Method: httpclient.MethodPUT,
827+
Path: "/" + tenantID + "/destinations/" + sampleDestinationID + "/enable",
828+
}),
829+
Expected: APITestExpectation{
830+
Validate: makeDestinationDisabledValidator(sampleDestinationID, false),
831+
},
832+
},
833+
{
834+
Name: "GET /:tenantID/destinations/:destinationID",
835+
Request: suite.AuthRequest(httpclient.Request{
836+
Method: httpclient.MethodGET,
837+
Path: "/" + tenantID + "/destinations/" + sampleDestinationID,
838+
}),
839+
Expected: APITestExpectation{
840+
Validate: makeDestinationDisabledValidator(sampleDestinationID, false),
841+
},
842+
},
843+
{
844+
Name: "PUT /:tenantID/destinations/:destinationID/disable",
845+
Request: suite.AuthRequest(httpclient.Request{
846+
Method: httpclient.MethodPUT,
847+
Path: "/" + tenantID + "/destinations/" + sampleDestinationID + "/disable",
848+
}),
849+
Expected: APITestExpectation{
850+
Validate: makeDestinationDisabledValidator(sampleDestinationID, true),
851+
},
852+
},
853+
{
854+
Name: "PUT /:tenantID/destinations/:destinationID/disable duplicate",
855+
Request: suite.AuthRequest(httpclient.Request{
856+
Method: httpclient.MethodPUT,
857+
Path: "/" + tenantID + "/destinations/" + sampleDestinationID + "/disable",
858+
}),
859+
Expected: APITestExpectation{
860+
Validate: makeDestinationDisabledValidator(sampleDestinationID, true),
861+
},
862+
},
863+
{
864+
Name: "GET /:tenantID/destinations/:destinationID",
865+
Request: suite.AuthRequest(httpclient.Request{
866+
Method: httpclient.MethodGET,
867+
Path: "/" + tenantID + "/destinations/" + sampleDestinationID,
868+
}),
869+
Expected: APITestExpectation{
870+
Validate: makeDestinationDisabledValidator(sampleDestinationID, true),
871+
},
872+
},
873+
}
874+
suite.RunAPITests(suite.T(), tests)
875+
}
876+
737877
func makeDestinationListValidator(length int) map[string]any {
738878
return map[string]any{
739879
"type": "object",
@@ -767,3 +907,32 @@ func makeDestinationListValidator(length int) map[string]any {
767907
},
768908
}
769909
}
910+
911+
func makeDestinationDisabledValidator(id string, disabled bool) map[string]any {
912+
var disabledValidator map[string]any
913+
if disabled {
914+
disabledValidator = map[string]any{
915+
"type": "string",
916+
"minLength": 1,
917+
}
918+
} else {
919+
disabledValidator = map[string]any{
920+
"type": "null",
921+
}
922+
}
923+
return map[string]interface{}{
924+
"properties": map[string]interface{}{
925+
"statusCode": map[string]interface{}{
926+
"const": 200,
927+
},
928+
"body": map[string]interface{}{
929+
"properties": map[string]interface{}{
930+
"id": map[string]interface{}{
931+
"const": id,
932+
},
933+
"disabled_at": disabledValidator,
934+
},
935+
},
936+
},
937+
}
938+
}

internal/models/destination.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,10 @@ func (d *Destination) Publish(ctx context.Context, event *Event) error {
102102
}
103103

104104
type DestinationSummary struct {
105-
ID string `json:"id"`
106-
Type string `json:"type"`
107-
Topics Topics `json:"topics"`
105+
ID string `json:"id"`
106+
Type string `json:"type"`
107+
Topics Topics `json:"topics"`
108+
Disabled bool `json:"disabled"`
108109
}
109110

110111
var _ encoding.BinaryMarshaler = &DestinationSummary{}
@@ -120,9 +121,10 @@ func (ds *DestinationSummary) UnmarshalBinary(data []byte) error {
120121

121122
func (d *Destination) ToSummary() *DestinationSummary {
122123
return &DestinationSummary{
123-
ID: d.ID,
124-
Type: d.Type,
125-
Topics: d.Topics,
124+
ID: d.ID,
125+
Type: d.Type,
126+
Topics: d.Topics,
127+
Disabled: d.DisabledAt != nil,
126128
}
127129
}
128130

internal/models/entity.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,9 @@ func (m *entityStoreImpl) UpsertDestination(ctx context.Context, destination Des
236236
r.HSet(ctx, key, "credentials", encryptedCredentials)
237237
r.HSet(ctx, key, "created_at", destination.CreatedAt)
238238
if destination.DisabledAt != nil {
239-
r.HSet(ctx, key, "disabled_at", destination.DisabledAt)
239+
r.HSet(ctx, key, "disabled_at", *destination.DisabledAt)
240+
} else {
241+
r.HDel(ctx, key, "disabled_at")
240242
}
241243
r.HSet(ctx, redisTenantDestinationSummaryKey(destination.TenantID), destination.ID, destination.ToSummary()).Val()
242244
return nil
@@ -275,7 +277,7 @@ func (s *entityStoreImpl) matchEventWithAllDestination(ctx context.Context, even
275277
matchedDestinationSummaryList := []DestinationSummary{}
276278

277279
for _, destinationSummary := range destinationSummaryList {
278-
if destinationSummary.Topics[0] == "*" || slices.Contains(destinationSummary.Topics, event.Topic) {
280+
if !destinationSummary.Disabled && (destinationSummary.Topics[0] == "*" || slices.Contains(destinationSummary.Topics, event.Topic)) {
279281
matchedDestinationSummaryList = append(matchedDestinationSummaryList, destinationSummary)
280282
}
281283
}

internal/models/entity_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,3 +549,88 @@ func TestMultiDestinationSuite_MatchEvent(t *testing.T) {
549549
}
550550
})
551551
}
552+
553+
func TestDestinationEnableDisable(t *testing.T) {
554+
t.Parallel()
555+
556+
redisClient := testutil.CreateTestRedisClient(t)
557+
entityStore := models.NewEntityStore(redisClient, models.NewAESCipher("secret"), testutil.TestTopics)
558+
559+
input := testutil.DestinationFactory.Any()
560+
require.NoError(t, entityStore.UpsertDestination(context.Background(), input))
561+
562+
assertDestination := func(t *testing.T, expected models.Destination) {
563+
actual, err := entityStore.RetrieveDestination(context.Background(), input.TenantID, input.ID)
564+
require.NoError(t, err)
565+
assert.Equal(t, expected.ID, actual.ID)
566+
assert.True(t, cmp.Equal(expected.DisabledAt, actual.DisabledAt), "expected %v, got %v", expected.DisabledAt, actual.DisabledAt)
567+
}
568+
569+
t.Run("should disable", func(t *testing.T) {
570+
now := time.Now()
571+
input.DisabledAt = &now
572+
require.NoError(t, entityStore.UpsertDestination(context.Background(), input))
573+
assertDestination(t, input)
574+
})
575+
576+
t.Run("should enable", func(t *testing.T) {
577+
input.DisabledAt = nil
578+
require.NoError(t, entityStore.UpsertDestination(context.Background(), input))
579+
assertDestination(t, input)
580+
})
581+
}
582+
583+
func TestMultiSuite_DisableAndMatch(t *testing.T) {
584+
t.Parallel()
585+
586+
suite := multiDestinationSuite{}
587+
suite.SetupTest(t)
588+
589+
t.Run("initial match user.deleted", func(t *testing.T) {
590+
event := testutil.EventFactory.Any(
591+
testutil.EventFactory.WithTenantID(suite.tenant.ID),
592+
testutil.EventFactory.WithTopic("user.deleted"),
593+
)
594+
matchedDestinationSummaryList, err := suite.entityStore.MatchEvent(suite.ctx, event)
595+
require.NoError(t, err)
596+
require.Len(t, matchedDestinationSummaryList, 2)
597+
for _, summary := range matchedDestinationSummaryList {
598+
require.Contains(t, []string{suite.destinations[0].ID, suite.destinations[3].ID}, summary.ID)
599+
}
600+
})
601+
602+
t.Run("should not match disabled destination", func(t *testing.T) {
603+
destination := suite.destinations[0]
604+
now := time.Now()
605+
destination.DisabledAt = &now
606+
require.NoError(t, suite.entityStore.UpsertDestination(suite.ctx, destination))
607+
608+
event := testutil.EventFactory.Any(
609+
testutil.EventFactory.WithTenantID(suite.tenant.ID),
610+
testutil.EventFactory.WithTopic("user.deleted"),
611+
)
612+
matchedDestinationSummaryList, err := suite.entityStore.MatchEvent(suite.ctx, event)
613+
require.NoError(t, err)
614+
require.Len(t, matchedDestinationSummaryList, 1)
615+
for _, summary := range matchedDestinationSummaryList {
616+
require.Contains(t, []string{suite.destinations[3].ID}, summary.ID)
617+
}
618+
})
619+
620+
t.Run("should match after re-enabled destination", func(t *testing.T) {
621+
destination := suite.destinations[0]
622+
destination.DisabledAt = nil
623+
require.NoError(t, suite.entityStore.UpsertDestination(suite.ctx, destination))
624+
625+
event := testutil.EventFactory.Any(
626+
testutil.EventFactory.WithTenantID(suite.tenant.ID),
627+
testutil.EventFactory.WithTopic("user.deleted"),
628+
)
629+
matchedDestinationSummaryList, err := suite.entityStore.MatchEvent(suite.ctx, event)
630+
require.NoError(t, err)
631+
require.Len(t, matchedDestinationSummaryList, 2)
632+
for _, summary := range matchedDestinationSummaryList {
633+
require.Contains(t, []string{suite.destinations[0].ID, suite.destinations[3].ID}, summary.ID)
634+
}
635+
})
636+
}

0 commit comments

Comments
 (0)