diff --git a/pkg/client/clientset.go b/pkg/client/clientset.go index 700a857c0..e7aea73f4 100644 --- a/pkg/client/clientset.go +++ b/pkg/client/clientset.go @@ -170,6 +170,8 @@ type SegmentClient interface { List(ctx context.Context) (segments.Response, error) GetAll(ctx context.Context) ([]segments.Response, error) Delete(ctx context.Context, id string) (segments.Response, error) + Upsert(ctx context.Context, id string, data []byte) (segments.Response, error) + Get(ctx context.Context, id string) (segments.Response, error) } var DefaultMonacoUserAgent = "Dynatrace Monitoring as Code/" + version.MonitoringAsCode + " " + (runtime.GOOS + " " + runtime.GOARCH) diff --git a/pkg/client/dummy_clientset.go b/pkg/client/dummy_clientset.go index 3b78c5b10..388b2b177 100644 --- a/pkg/client/dummy_clientset.go +++ b/pkg/client/dummy_clientset.go @@ -17,17 +17,18 @@ package client import ( - context "context" + "context" "fmt" "net/http" coreapi "github.com/dynatrace/dynatrace-configuration-as-code-core/api" automationApi "github.com/dynatrace/dynatrace-configuration-as-code-core/api/clients/automation" "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/automation" - buckets "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/buckets" - documents "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/documents" - openpipeline "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/openpipeline" - dtclient "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/client/dtclient" + "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/buckets" + "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/documents" + "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/openpipeline" + "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/segments" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/client/dtclient" ) var DummyClientSet = ClientSet{ @@ -37,6 +38,7 @@ var DummyClientSet = ClientSet{ BucketClient: &DummyBucketClient{}, DocumentClient: &DummyDocumentClient{}, OpenPipelineClient: &DummyOpenPipelineClient{}, + SegmentClient: &DummySegmentClient{}, } var _ AutomationClient = (*DummyAutomationClient)(nil) @@ -155,3 +157,25 @@ func (c *DummyOpenPipelineClient) GetAll(ctx context.Context) ([]coreapi.Respons func (c *DummyOpenPipelineClient) Update(_ context.Context, _ string, _ []byte) (openpipeline.Response, error) { return openpipeline.Response{}, nil } + +type DummySegmentClient struct{} + +func (c *DummySegmentClient) List(_ context.Context) (segments.Response, error) { + return segments.Response{}, nil +} + +func (c *DummySegmentClient) GetAll(_ context.Context) ([]segments.Response, error) { + return []segments.Response{}, nil +} + +func (c *DummySegmentClient) Delete(_ context.Context, _ string) (segments.Response, error) { + return segments.Response{}, nil +} + +func (c *DummySegmentClient) Upsert(_ context.Context, _ string, _ []byte) (segments.Response, error) { + return segments.Response{}, nil +} + +func (c *DummySegmentClient) Get(_ context.Context, _ string) (segments.Response, error) { + return segments.Response{}, nil +} diff --git a/pkg/client/test_clientset.go b/pkg/client/test_clientset.go new file mode 100644 index 000000000..790386e28 --- /dev/null +++ b/pkg/client/test_clientset.go @@ -0,0 +1,46 @@ +/* + * @license + * Copyright 2025 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package client + +import ( + "context" + "fmt" + + "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/segments" +) + +type TestSegmentsClient struct{} + +func (TestSegmentsClient) List(ctx context.Context) (segments.Response, error) { + return segments.Response{}, fmt.Errorf("unimplemented") +} + +func (TestSegmentsClient) GetAll(ctx context.Context) ([]segments.Response, error) { + return []segments.Response{}, fmt.Errorf("unimplemented") +} + +func (TestSegmentsClient) Delete(ctx context.Context, id string) (segments.Response, error) { + return segments.Response{}, fmt.Errorf("unimplemented") +} + +func (TestSegmentsClient) Upsert(ctx context.Context, id string, data []byte) (segments.Response, error) { + return segments.Response{}, fmt.Errorf("unimplemented") +} + +func (TestSegmentsClient) Get(ctx context.Context, id string) (segments.Response, error) { + return segments.Response{}, fmt.Errorf("unimplemented") +} diff --git a/pkg/delete/delete_test.go b/pkg/delete/delete_test.go index 42164c55c..db19b156f 100644 --- a/pkg/delete/delete_test.go +++ b/pkg/delete/delete_test.go @@ -37,7 +37,6 @@ import ( "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/automation" "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/buckets" "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/documents" - "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/segments" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/featureflags" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/testutils/matcher" @@ -1149,93 +1148,48 @@ func TestDelete_Documents(t *testing.T) { }) } -type segmentStubClient struct { - called bool - list func() (segments.Response, error) - getAll func() ([]segments.Response, error) - delete func() (segments.Response, error) -} - -func (c *segmentStubClient) List(_ context.Context) (segments.Response, error) { - return c.list() -} - -func (c *segmentStubClient) GetAll(_ context.Context) ([]segments.Response, error) { - return c.getAll() -} - -func (c *segmentStubClient) Delete(_ context.Context, _ string) (segments.Response, error) { - c.called = true - return c.delete() -} - func TestDelete_Segments(t *testing.T) { - t.Run("simple case", func(t *testing.T) { - t.Setenv(featureflags.Segments.EnvName(), "true") - - c := segmentStubClient{ - delete: func() (segments.Response, error) { - return segments.Response{StatusCode: http.StatusOK}, nil + c := client.TestSegmentsClient{} + given := delete.DeleteEntries{ + "segment": { + { + Type: "segment", + OriginObjectId: "originObjectID", }, - } + }, + } + + t.Run("With Enabled Segment FF", func(t *testing.T) { + t.Setenv(featureflags.Segments.EnvName(), "true") - given := delete.DeleteEntries{ - "segment": { - { - Type: "segment", - OriginObjectId: "originObjectID", - }, - }, - } err := delete.Configs(context.TODO(), client.ClientSet{SegmentClient: &c}, given) - assert.NoError(t, err) - assert.True(t, c.called, "delete should have been called") + //DummyClient returns unimplemented error on every execution of any method + assert.Error(t, err, "unimplemented") }) - t.Run("simple case with FF turned off", func(t *testing.T) { + t.Run("With Disabled Segment FF", func(t *testing.T) { t.Setenv(featureflags.Segments.EnvName(), "false") - c := segmentStubClient{} - - given := delete.DeleteEntries{ - "segment": { - { - Type: "segment", - OriginObjectId: "originObjectID", - }, - }, - } err := delete.Configs(context.TODO(), client.ClientSet{SegmentClient: &c}, given) assert.NoError(t, err) - assert.False(t, c.called, "delete should not have been called") }) } func TestDeleteAll_Segments(t *testing.T) { - t.Run("simple case", func(t *testing.T) { - t.Setenv(featureflags.Segments.EnvName(), "true") + c := client.TestSegmentsClient{} - c := segmentStubClient{ - list: func() (segments.Response, error) { - return segments.Response{StatusCode: http.StatusOK, Data: []byte(`[{"uid": "uid_1"},{"uid": "uid_2"},{"uid": "uid_3"}]`)}, nil - }, - delete: func() (segments.Response, error) { - return segments.Response{StatusCode: http.StatusOK}, nil - }, - } + t.Run("With Enabled Segment FF", func(t *testing.T) { + t.Setenv(featureflags.Segments.EnvName(), "true") err := delete.All(context.TODO(), client.ClientSet{SegmentClient: &c}, api.APIs{}) - assert.NoError(t, err) - assert.True(t, c.called, "delete should have been called") + //fakeClient returns unimplemented error on every execution of any method + assert.Error(t, err, "unimplemented") }) - t.Run("FF is turned off", func(t *testing.T) { + t.Run("With Disabled Segment FF", func(t *testing.T) { t.Setenv(featureflags.Segments.EnvName(), "false") - c := segmentStubClient{} - err := delete.All(context.TODO(), client.ClientSet{SegmentClient: &c}, api.APIs{}) assert.NoError(t, err) - assert.False(t, c.called, "delete should not have been called") }) } diff --git a/pkg/deploy/deploy.go b/pkg/deploy/deploy.go index 00f278c74..6693b94a6 100644 --- a/pkg/deploy/deploy.go +++ b/pkg/deploy/deploy.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/deploy/internal/segment" "sync" "time" @@ -309,6 +310,13 @@ func deployConfig(ctx context.Context, c *config.Config, clientset *client.Clien deployErr = fmt.Errorf("unknown config-type (ID: %q)", c.Type.ID()) } + case config.Segment: + if featureflags.Segments.Enabled() { + resolvedEntity, deployErr = segment.Deploy(ctx, clientset.SegmentClient, properties, renderedConfig, c) + } else { + deployErr = fmt.Errorf("unknown config-type (ID: %q)", c.Type.ID()) + } + default: deployErr = fmt.Errorf("unknown config-type (ID: %q)", c.Type.ID()) } diff --git a/pkg/deploy/deploy_test.go b/pkg/deploy/deploy_test.go index 2070bfca3..c93c638a1 100644 --- a/pkg/deploy/deploy_test.go +++ b/pkg/deploy/deploy_test.go @@ -21,6 +21,8 @@ import ( "fmt" "testing" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/featureflags" + "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -1298,3 +1300,87 @@ func TestDeployConfigGraph_CollectsAllErrors(t *testing.T) { }) } + +func TestDeployConfigFF(t *testing.T) { + dummyClientSet := client.ClientSet{SegmentClient: client.TestSegmentsClient{}} + c := dynatrace.EnvironmentClients{ + dynatrace.EnvironmentInfo{Name: "env"}: &dummyClientSet, + } + tests := []struct { + name string + projects []project.Project + featureFlag string + configType config.TypeID + expectedErrString string + }{ + { + name: "segments FF test", + projects: []project.Project{ + { + Configs: project.ConfigsPerTypePerEnvironments{ + "env": project.ConfigsPerType{ + "p1": { + config.Config{ + Type: config.Segment{}, + Environment: "env", + Coordinate: coordinate.Coordinate{ + Project: "p1", + Type: "type", + ConfigId: "config1", + }, + }, + }, + }, + }, + }, + }, + featureFlag: featureflags.Segments.EnvName(), + configType: config.SegmentID, + }, + } + + for _, tt := range tests { + t.Run(tt.name+" | FF Enabled", func(t *testing.T) { + t.Setenv(tt.featureFlag, "true") + err := deploy.Deploy(context.Background(), tt.projects, c, deploy.DeployConfigsOptions{}) + //fakeClient returns unimplemented error on every execution of any method + assert.Errorf(t, err, "unimplemented") + }) + t.Run(tt.name+" | FF Disabled", func(t *testing.T) { + t.Setenv(tt.featureFlag, "false") + err := deploy.Deploy(context.Background(), tt.projects, c, deploy.DeployConfigsOptions{}) + assert.Errorf(t, err, fmt.Sprintf("unknown config-type (ID: %q)", tt.configType)) + }) + } +} + +func TestDeployDryRun(t *testing.T) { + c := dynatrace.EnvironmentClients{ + dynatrace.EnvironmentInfo{Name: "env", Group: "group"}: &client.DummyClientSet, + } + projects := []project.Project{ + { + Configs: project.ConfigsPerTypePerEnvironments{ + "env": project.ConfigsPerType{ + "p1": { + config.Config{ + Type: config.Segment{}, + Environment: "env", + Coordinate: coordinate.Coordinate{ + Project: "p1", + Type: "segment", + ConfigId: "config1", + }, + Template: testutils.GenerateDummyTemplate(t), + }, + }, + }, + }, + }, + } + t.Setenv(featureflags.Segments.EnvName(), "true") + t.Run("dry-run", func(t *testing.T) { + err := deploy.Deploy(context.Background(), projects, c, deploy.DeployConfigsOptions{DryRun: true}) + assert.Empty(t, err) + }) +} diff --git a/pkg/deploy/internal/segment/segment.go b/pkg/deploy/internal/segment/segment.go new file mode 100644 index 000000000..ef5d39483 --- /dev/null +++ b/pkg/deploy/internal/segment/segment.go @@ -0,0 +1,198 @@ +/* + * @license + * Copyright 2024 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package segment + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/dynatrace/dynatrace-configuration-as-code-core/api" + segment "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/segments" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/idutils" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/entities" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/parameter" + deployErrors "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/deploy/errors" + "github.com/go-logr/logr" +) + +type deploySegmentClient interface { + Upsert(ctx context.Context, id string, data []byte) (segment.Response, error) + GetAll(ctx context.Context) ([]segment.Response, error) + Get(ctx context.Context, id string) (segment.Response, error) +} + +type jsonResponse struct { + UID string `json:"uid"` + Owner string `json:"owner"` + ExternalId string `json:"externalId"` +} + +func Deploy(ctx context.Context, client deploySegmentClient, properties parameter.Properties, renderedConfig string, c *config.Config) (entities.ResolvedEntity, error) { + externalId, err := idutils.GenerateExternalIDForDocument(c.Coordinate) + if err != nil { + return entities.ResolvedEntity{}, err + } + requestPayload, err := addExternalId(externalId, renderedConfig) + if err != nil { + return entities.ResolvedEntity{}, fmt.Errorf("failed to add externalId to segments request payload: %w", err) + } + + //Strategy 1 when OriginObjectId is set we try to get the object if it exists we update it else we create it. + if c.OriginObjectId != "" { + //@TODO this here is temporary code to enable deploy for segments + getResponse, err := client.Get(ctx, c.OriginObjectId) + if err != nil { + return entities.ResolvedEntity{}, err + } + + if getResponse.StatusCode != http.StatusNotFound { + + id, err := deployWithOriginObjectId(ctx, client, c, requestPayload) + if err != nil { + return entities.ResolvedEntity{}, fmt.Errorf("failed to deploy segment with externalId: %s : %w", externalId, err) + } + + return createResolveEntity(id, properties, c), nil + } + } + + //Strategy 2 is to try to find a match with external id and either update or create object if no match found. + id, err := deployWithExternalID(ctx, client, externalId, requestPayload, c) + if err != nil { + return entities.ResolvedEntity{}, fmt.Errorf("failed to deploy segment with externalId: %s : %w", externalId, err) + } + + return createResolveEntity(id, properties, c), nil +} + +func addExternalId(externalId string, renderedConfig string) ([]byte, error) { + var request map[string]any + err := json.Unmarshal([]byte(renderedConfig), &request) + if err != nil { + return nil, err + } + request["externalId"] = externalId + return json.Marshal(request) +} + +func deployWithExternalID(ctx context.Context, client deploySegmentClient, externalId string, requestPayload []byte, c *config.Config) (string, error) { + id := "" + responseData, match, err := findMatchOnRemote(ctx, client, externalId) + if err != nil { + return "", err + } + + if match { + id = responseData.UID + } + + responseUpsert, err := deploy(ctx, client, id, requestPayload, c) + if err != nil { + return "", err + } + + id, err = resolveIdFromResponse(responseUpsert, id) + if err != nil { + return "", err + } + + return id, nil +} + +func deployWithOriginObjectId(ctx context.Context, client deploySegmentClient, c *config.Config, requestPayload []byte) (string, error) { + responseUpsert, err := deploy(ctx, client, c.OriginObjectId, requestPayload, c) + if err != nil { + return "", err + } + + return resolveIdFromResponse(responseUpsert, c.OriginObjectId) +} + +func resolveIdFromResponse(responseUpsert segment.Response, id string) (string, error) { + //For a POST we need to parse the response again to read out the ID + if responseUpsert.StatusCode == http.StatusCreated { + responseData, err := getJsonResponseFromSegmentsResponse(responseUpsert) + if err != nil { + return "", err + } + return responseData.UID, nil + } + return id, nil +} + +func findMatchOnRemote(ctx context.Context, client deploySegmentClient, externalId string) (jsonResponse, bool, error) { + segmentsResponses, err := client.GetAll(ctx) + if err != nil { + return jsonResponse{}, false, fmt.Errorf("failed to GET segments: %w", err) + } + + var responseData jsonResponse + for _, segmentResponse := range segmentsResponses { + responseData, err = getJsonResponseFromSegmentsResponse(segmentResponse) + if err != nil { + return jsonResponse{}, false, err + } + if responseData.ExternalId == externalId { + return responseData, true, nil + } + } + + return jsonResponse{}, false, nil +} + +func deploy(ctx context.Context, client deploySegmentClient, id string, requestPayload []byte, c *config.Config) (segment.Response, error) { + //create new context to carry logger + ctx = logr.NewContext(ctx, log.WithCtxFields(ctx).GetLogr()) + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + + responseUpsert, err := client.Upsert(ctx, id, requestPayload) + if err != nil { + var apiErr api.APIError + if errors.As(err, &apiErr) { + return api.Response{}, fmt.Errorf("failed to upsert segment with id %q: %w", id, err) + } + + return api.Response{}, deployErrors.NewConfigDeployErr(c, fmt.Sprintf("failed to upsert segment with id %q", id)).WithError(err) + } + + return responseUpsert, nil +} + +func createResolveEntity(id string, properties parameter.Properties, c *config.Config) entities.ResolvedEntity { + properties[config.IdParameter] = id + return entities.ResolvedEntity{ + Coordinate: c.Coordinate, + Properties: properties, + } +} + +func getJsonResponseFromSegmentsResponse(rawResponse segment.Response) (jsonResponse, error) { + var response jsonResponse + err := json.Unmarshal(rawResponse.Data, &response) + if err != nil { + return jsonResponse{}, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return response, nil +} diff --git a/pkg/deploy/internal/segment/segment_test.go b/pkg/deploy/internal/segment/segment_test.go new file mode 100644 index 000000000..965bc8441 --- /dev/null +++ b/pkg/deploy/internal/segment/segment_test.go @@ -0,0 +1,343 @@ +/* + * @license + * Copyright 2025 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package segment_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/dynatrace/dynatrace-configuration-as-code-core/api" + "github.com/dynatrace/dynatrace-configuration-as-code-core/clients/segments" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/coordinate" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/entities" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/template" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/deploy/internal/segment" + "github.com/stretchr/testify/assert" +) + +type testClient struct { + upsertStub func() (segments.Response, error) + getAllStub func() ([]segments.Response, error) + getStub func() (segments.Response, error) +} + +func (tc *testClient) Upsert(_ context.Context, _ string, _ []byte) (segments.Response, error) { + return tc.upsertStub() +} + +func (tc *testClient) GetAll(_ context.Context) ([]segments.Response, error) { + return tc.getAllStub() +} + +func (tc *testClient) Get(_ context.Context, _ string) (segments.Response, error) { + return tc.getStub() +} + +func TestDeploy(t *testing.T) { + testCoordinate := coordinate.Coordinate{ + Project: "my-project", + Type: "segment", + ConfigId: "my-config-id", + } + tests := []struct { + name string + inputConfig config.Config + upsertStub func() (segments.Response, error) + getStub func() (segments.Response, error) + getAllStub func() ([]segments.Response, error) + expected entities.ResolvedEntity + expectErr bool + expectedErrMsg string + }{ + { + name: "deploy with objectOriginId - success PUT", + inputConfig: config.Config{ + Template: template.NewInMemoryTemplate("path/file.json", "{}"), + Coordinate: testCoordinate, + OriginObjectId: "my-object-id", + Type: config.Segment{}, + Parameters: config.Parameters{}, + Skip: false, + }, + upsertStub: func() (segments.Response, error) { + return segments.Response{ + StatusCode: http.StatusOK, + }, nil + }, + getStub: func() (segments.Response, error) { + return segments.Response{ + StatusCode: http.StatusOK, + }, nil + }, + getAllStub: func() ([]segments.Response, error) { + t.Fatalf("should not be called") + return nil, nil + }, + expected: entities.ResolvedEntity{ + Coordinate: testCoordinate, + Properties: map[string]interface{}{ + "id": "my-object-id", + }, + Skip: false, + }, + expectErr: false, + }, + { + name: "deploy with objectOriginId, no object found on remote - success PUT wia externalId", + inputConfig: config.Config{ + Template: template.NewInMemoryTemplate("path/file.json", "{}"), + Coordinate: testCoordinate, + OriginObjectId: "my-object-id", + Type: config.Segment{}, + Parameters: config.Parameters{}, + Skip: false, + }, + upsertStub: func() (segments.Response, error) { + return segments.Response{ + StatusCode: http.StatusCreated, + Data: marshal(map[string]any{ + "uid": "JMhNaJ0Zbf9", + "name": "test-segment-post-match", + "description": "post - update from monaco - change - 2", + "isPublic": false, + "owner": "79a4c92e-379b-4cd7-96a3-78a601b6a69b", + "externalId": "monaco-e2320031-d6c6-3c83-9706-b3e82b834129", + }, t), + }, nil + }, + getStub: func() (segments.Response, error) { + return segments.Response{ + StatusCode: http.StatusNotFound, + }, nil + }, + getAllStub: func() ([]segments.Response, error) { + response := []segments.Response{ + { + StatusCode: http.StatusOK, + Data: marshal(map[string]any{ + "uid": "JMhNaJ0Zbf9", + "name": "no-match", + "description": "post - update from monaco - change - 2", + "isPublic": false, + "owner": "79a4c92e-379b-4cd7-96a3-78a601b6a69b", + "externalId": "monaco-e2320031-d6c6-3c83-9706-b3e82b834129", + }, t), + }, + { + StatusCode: http.StatusOK, + Data: marshal(map[string]any{ + "uid": "should-not-be-this-id", + "name": "match", + "description": "post - update from monaco - change - 2", + "isPublic": false, + "owner": "79a4c92e-379b-4cd7-96a3-78a601b6a69b", + "externalId": "not-a-match", + }, t), + }, + } + return response, nil + }, + expected: entities.ResolvedEntity{ + Coordinate: testCoordinate, + Properties: map[string]interface{}{ + "id": "JMhNaJ0Zbf9", + }, + Skip: false, + }, + expectErr: false, + }, + { + name: "deploy with objectOriginId - error PUT(error returned by upsert)", + inputConfig: config.Config{ + Template: template.NewInMemoryTemplate("path/file.json", "{}"), + Coordinate: testCoordinate, + OriginObjectId: "my-object-id", + Type: config.Segment{}, + Parameters: config.Parameters{}, + Skip: false, + }, + getStub: func() (segments.Response, error) { + return segments.Response{ + StatusCode: http.StatusOK, + }, nil + }, + upsertStub: func() (segments.Response, error) { + return segments.Response{}, fmt.Errorf("error") + }, + getAllStub: func() ([]segments.Response, error) { + t.Fatalf("should not be called") + return nil, nil + }, + expectErr: true, + expectedErrMsg: "failed to deploy segment with externalId", + }, + { + name: "deploy with objectOriginId - error PUT(invalid response payload)", + inputConfig: config.Config{ + Template: template.NewInMemoryTemplate("path/file.json", "{}"), + Coordinate: testCoordinate, + OriginObjectId: "my-object-id", + Type: config.Segment{}, + Parameters: config.Parameters{}, + Skip: false, + }, + upsertStub: func() (segments.Response, error) { + return segments.Response{ + StatusCode: http.StatusCreated, + Data: []byte("invalid json"), + }, nil + }, + getStub: func() (segments.Response, error) { + return segments.Response{ + StatusCode: http.StatusOK, + }, nil + }, + getAllStub: func() ([]segments.Response, error) { + t.Fatalf("should not be called") + return nil, nil + }, + expectErr: true, + expectedErrMsg: "failed to deploy segment with externalId", + }, + { + name: "deploy with externalId - success PUT", + inputConfig: config.Config{ + Template: template.NewInMemoryTemplate("path/file.json", "{}"), + Coordinate: testCoordinate, + Type: config.Segment{}, + Parameters: config.Parameters{}, + Skip: false, + }, + upsertStub: func() (segments.Response, error) { + return segments.Response{ + StatusCode: http.StatusOK, + }, nil + }, + getAllStub: func() ([]segments.Response, error) { + response := []segments.Response{ + { + StatusCode: http.StatusOK, + Data: marshal(map[string]any{ + "uid": "JMhNaJ0Zbf9", + "name": "no-match", + "description": "post - update from monaco - change - 2", + "isPublic": false, + "owner": "79a4c92e-379b-4cd7-96a3-78a601b6a69b", + "externalId": "monaco-e2320031-d6c6-3c83-9706-b3e82b834129", + }, t), + }, + { + StatusCode: http.StatusOK, + Data: marshal(map[string]any{ + "uid": "should-not-be-this-id", + "name": "match", + "description": "post - update from monaco - change - 2", + "isPublic": false, + "owner": "79a4c92e-379b-4cd7-96a3-78a601b6a69b", + "externalId": "not-a-match", + }, t), + }, + } + return response, nil + }, + expected: entities.ResolvedEntity{ + Coordinate: testCoordinate, + Properties: map[string]interface{}{ + "id": "JMhNaJ0Zbf9", + }, + Skip: false, + }, + expectErr: false, + }, + { + name: "deploy with externalId - error PUT 400", + inputConfig: config.Config{ + Template: template.NewInMemoryTemplate("path/file.json", "{}"), + Coordinate: testCoordinate, + Type: config.Segment{}, + Parameters: config.Parameters{}, + Skip: false, + }, + upsertStub: func() (segments.Response, error) { + return segments.Response{}, api.APIError{ + StatusCode: http.StatusBadRequest, + } + }, + getAllStub: func() ([]segments.Response, error) { + var response []segments.Response + return response, nil + }, + expectErr: true, + expectedErrMsg: "failed to deploy segment with externalId", + }, + { + name: "deploy with externalId - error GET 400", + inputConfig: config.Config{ + Template: template.NewInMemoryTemplate("path/file.json", "{}"), + Coordinate: testCoordinate, + Type: config.Segment{}, + Parameters: config.Parameters{}, + Skip: false, + }, + upsertStub: func() (segments.Response, error) { + t.Fatalf("should not be called") + return segments.Response{}, nil + }, + getAllStub: func() ([]segments.Response, error) { + var response []segments.Response + return response, api.APIError{ + StatusCode: http.StatusBadRequest, + } + }, + expectErr: true, + expectedErrMsg: "failed to deploy segment with externalId", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := testClient{upsertStub: tt.upsertStub, getAllStub: tt.getAllStub, getStub: tt.getStub} + + props, errs := tt.inputConfig.ResolveParameterValues(entities.New()) + assert.Empty(t, errs) + + renderedConfig, err := tt.inputConfig.Render(props) + assert.NoError(t, err) + + resolvedEntity, err := segment.Deploy(context.Background(), &c, props, renderedConfig, &tt.inputConfig) + if tt.expectErr { + assert.ErrorContains(t, err, tt.expectedErrMsg) + } + if !tt.expectErr { + assert.NoError(t, err) + assert.Equal(t, resolvedEntity, tt.expected) + } + }) + } +} + +func marshal(object map[string]any, t *testing.T) []byte { + payload, err := json.Marshal(object) + if err != nil { + t.Fatalf("error marshalling object: %v", err) + } + return payload +}