From 3a86455d07b184c879ecfd9b829d5de08138c8b7 Mon Sep 17 00:00:00 2001 From: Nadia Mayor Date: Mon, 30 Mar 2026 11:00:42 -0500 Subject: [PATCH 1/3] Added new fetcher and logic to map a RuleChangesDTO --- dtos/configmapper.go | 61 ++++++++ dtos/configmapper_test.go | 177 ++++++++++++++++++++++ dtos/configs.go | 29 ++++ service/api/configs.go | 73 +++++++++ service/api/configs_test.go | 289 ++++++++++++++++++++++++++++++++++++ 5 files changed, 629 insertions(+) create mode 100644 dtos/configmapper.go create mode 100644 dtos/configmapper_test.go create mode 100644 dtos/configs.go create mode 100644 service/api/configs.go create mode 100644 service/api/configs_test.go diff --git a/dtos/configmapper.go b/dtos/configmapper.go new file mode 100644 index 00000000..9ca70c6f --- /dev/null +++ b/dtos/configmapper.go @@ -0,0 +1,61 @@ +package dtos + +// ConvertConfigToSplit converts a ConfigDTO to a SplitDTO +func ConvertConfigToSplit(config ConfigDTO) SplitDTO { + // Apply defaults + trafficTypeName := config.TrafficTypeName + if trafficTypeName == "" { + trafficTypeName = "user" + } + + status := config.Status + if status == "" { + status = "ACTIVE" + } + + defaultTreatment := config.DefaultTreatment + if defaultTreatment == "" { + defaultTreatment = "default" + } + + // Handle conditions - create default if empty + conditions := config.Conditions + if len(conditions) == 0 { + conditions = []ConditionDTO{ + { + ConditionType: "ROLLOUT", + Label: "default rule", + MatcherGroup: MatcherGroupDTO{ + Combiner: "AND", + Matchers: []MatcherDTO{ + { + MatcherType: "ALL_KEYS", + Negate: false, + }, + }, + }, + Partitions: []PartitionDTO{ + { + Treatment: defaultTreatment, + Size: 100, + }, + }, + }, + } + } + + return SplitDTO{ + Name: config.Name, + Killed: config.Killed, + ChangeNumber: config.ChangeNumber, + Configurations: config.Configurations, + DefaultTreatment: defaultTreatment, + TrafficTypeName: trafficTypeName, + Status: status, + Algo: 2, + Seed: config.Seed, + TrafficAllocation: config.TrafficAllocation, + TrafficAllocationSeed: config.TrafficAllocationSeed, + Conditions: conditions, + } +} diff --git a/dtos/configmapper_test.go b/dtos/configmapper_test.go new file mode 100644 index 00000000..8e214c0a --- /dev/null +++ b/dtos/configmapper_test.go @@ -0,0 +1,177 @@ +package dtos + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConvertConfigToSplit_WithAllFields(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + Status: "ACTIVE", + Killed: false, + TrafficTypeName: "account", + DefaultTreatment: "on", + ChangeNumber: 123456, + TrafficAllocation: 100, + TrafficAllocationSeed: 789, + Seed: 456, + Configurations: map[string]string{ + "on": "{\"color\": \"red\"}", + "off": "{\"color\": \"blue\"}", + }, + Conditions: []ConditionDTO{ + { + ConditionType: "WHITELIST", + Label: "custom rule", + MatcherGroup: MatcherGroupDTO{ + Combiner: "AND", + Matchers: []MatcherDTO{ + { + MatcherType: "WHITELIST", + Negate: false, + }, + }, + }, + Partitions: []PartitionDTO{ + { + Treatment: "on", + Size: 100, + }, + }, + }, + }, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, "test_config", split.Name) + assert.Equal(t, "ACTIVE", split.Status) + assert.Equal(t, false, split.Killed) + assert.Equal(t, "account", split.TrafficTypeName) + assert.Equal(t, "on", split.DefaultTreatment) + assert.Equal(t, int64(123456), split.ChangeNumber) + assert.Equal(t, 100, split.TrafficAllocation) + assert.Equal(t, int64(789), split.TrafficAllocationSeed) + assert.Equal(t, int64(456), split.Seed) + assert.Equal(t, 2, split.Algo) + assert.Equal(t, 2, len(split.Configurations)) + assert.Equal(t, "{\"color\": \"red\"}", split.Configurations["on"]) + assert.Equal(t, 1, len(split.Conditions)) + assert.Equal(t, "WHITELIST", split.Conditions[0].ConditionType) +} + +func TestConvertConfigToSplit_WithDefaults(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + ChangeNumber: 123456, + Seed: 456, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, "test_config", split.Name) + assert.Equal(t, "ACTIVE", split.Status, "Status should default to ACTIVE") + assert.Equal(t, "user", split.TrafficTypeName, "TrafficTypeName should default to user") + assert.Equal(t, "default", split.DefaultTreatment, "DefaultTreatment should default to default") + assert.Equal(t, 2, split.Algo, "Algo should always be 2") +} + +func TestConvertConfigToSplit_WithEmptyConditions(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + DefaultTreatment: "off", + ChangeNumber: 123456, + Seed: 456, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, 1, len(split.Conditions), "Should create default condition") + assert.Equal(t, "ROLLOUT", split.Conditions[0].ConditionType) + assert.Equal(t, "default rule", split.Conditions[0].Label) + assert.Equal(t, "AND", split.Conditions[0].MatcherGroup.Combiner) + assert.Equal(t, 1, len(split.Conditions[0].MatcherGroup.Matchers)) + assert.Equal(t, "ALL_KEYS", split.Conditions[0].MatcherGroup.Matchers[0].MatcherType) + assert.Equal(t, false, split.Conditions[0].MatcherGroup.Matchers[0].Negate) + assert.Equal(t, 1, len(split.Conditions[0].Partitions)) + assert.Equal(t, "off", split.Conditions[0].Partitions[0].Treatment) + assert.Equal(t, 100, split.Conditions[0].Partitions[0].Size) +} + +func TestConvertConfigToSplit_WithNilConditions(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + DefaultTreatment: "control", + ChangeNumber: 123456, + Seed: 456, + Conditions: nil, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, 1, len(split.Conditions), "Should create default condition when nil") + assert.Equal(t, "control", split.Conditions[0].Partitions[0].Treatment) +} + +func TestConvertConfigToSplit_WithEmptyDefaultTreatment(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + DefaultTreatment: "", + ChangeNumber: 123456, + Seed: 456, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, "default", split.DefaultTreatment, "Should use 'default' when empty") + assert.Equal(t, "default", split.Conditions[0].Partitions[0].Treatment, "Default condition should use default treatment") +} + +func TestConvertConfigToSplit_KilledFlag(t *testing.T) { + config := ConfigDTO{ + Name: "killed_config", + Killed: true, + ChangeNumber: 123456, + Seed: 456, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, true, split.Killed) +} + +func TestConvertConfigToSplit_WithConfigurations(t *testing.T) { + config := ConfigDTO{ + Name: "config_with_configs", + ChangeNumber: 123456, + Seed: 456, + Configurations: map[string]string{ + "on": "{\"size\": 10}", + "off": "{\"size\": 20}", + "default": "{\"size\": 15}", + }, + } + + split := ConvertConfigToSplit(config) + + assert.NotNil(t, split.Configurations) + assert.Equal(t, 3, len(split.Configurations)) + assert.Equal(t, "{\"size\": 10}", split.Configurations["on"]) + assert.Equal(t, "{\"size\": 20}", split.Configurations["off"]) + assert.Equal(t, "{\"size\": 15}", split.Configurations["default"]) +} + +func TestConvertConfigToSplit_WithNilConfigurations(t *testing.T) { + config := ConfigDTO{ + Name: "config_no_configs", + ChangeNumber: 123456, + Seed: 456, + Configurations: nil, + } + + split := ConvertConfigToSplit(config) + + assert.Nil(t, split.Configurations) +} diff --git a/dtos/configs.go b/dtos/configs.go new file mode 100644 index 00000000..06717651 --- /dev/null +++ b/dtos/configs.go @@ -0,0 +1,29 @@ +package dtos + +// ConfigDTO represents a configuration definition fetched from the /configs endpoint +type ConfigDTO struct { + Name string `json:"name"` + Status string `json:"status"` + Killed bool `json:"killed"` + TrafficTypeName string `json:"trafficTypeName"` + DefaultTreatment string `json:"defaultTreatment"` + ChangeNumber int64 `json:"changeNumber"` + TrafficAllocation int `json:"trafficAllocation"` + TrafficAllocationSeed int64 `json:"trafficAllocationSeed"` + Seed int64 `json:"seed"` + Configurations map[string]string `json:"configurations"` + Conditions []ConditionDTO `json:"conditions"` +} + +// ConfigsDataDTO represents the configs data wrapper in the response +type ConfigsDataDTO struct { + Since int64 `json:"s"` + Till int64 `json:"t"` + Configs []ConfigDTO `json:"d"` +} + +// ConfigsResponseDTO represents the response from the /configs endpoint +type ConfigsResponseDTO struct { + Configs ConfigsDataDTO `json:"configs"` + RBS []RuleBasedSegmentDTO `json:"rbs"` +} diff --git a/service/api/configs.go b/service/api/configs.go new file mode 100644 index 00000000..b8f61500 --- /dev/null +++ b/service/api/configs.go @@ -0,0 +1,73 @@ +package api + +import ( + "encoding/json" + "fmt" + + "github.com/splitio/go-split-commons/v9/conf" + "github.com/splitio/go-split-commons/v9/dtos" + "github.com/splitio/go-split-commons/v9/service" + "github.com/splitio/go-toolkit/v5/logging" +) + +// HTTPConfigsFetcher struct is responsible for fetching configs from the backend via HTTP protocol +type HTTPConfigsFetcher struct { + httpFetcherBase +} + +// NewHTTPConfigsFetcher instantiates and returns an HTTPConfigsFetcher +func NewHTTPConfigsFetcher(apikey string, cfg conf.AdvancedConfig, logger logging.LoggerInterface, metadata dtos.Metadata) *HTTPConfigsFetcher { + return &HTTPConfigsFetcher{ + httpFetcherBase: httpFetcherBase{ + client: NewHTTPClient(apikey, cfg, cfg.SdkURL, logger, metadata), + logger: logger, + }, + } +} + +// Fetch makes an HTTP call to the /configs endpoint and returns the converted RuleChangesDTO +func (f *HTTPConfigsFetcher) Fetch(since int64) (*dtos.RuleChangesDTO, error) { + // Build query parameters + params := &service.FlagRequestParams{} + params.WithChangeNumber(since) + + // Fetch raw data from /configs endpoint + data, err := f.fetchRaw("/configs", params) + if err != nil { + f.logger.Error("Error fetching configs: ", err) + return nil, err + } + + // Unmarshal into ConfigsResponseDTO + var configsResponse dtos.ConfigsResponseDTO + err = json.Unmarshal(data, &configsResponse) + if err != nil { + f.logger.Error("Error parsing configs JSON: ", err) + return nil, err + } + + // Convert ConfigDTOs to SplitDTOs + splits := make([]dtos.SplitDTO, 0, len(configsResponse.Configs.Configs)) + for _, config := range configsResponse.Configs.Configs { + splitDTO := dtos.ConvertConfigToSplit(config) + splits = append(splits, splitDTO) + } + + f.logger.Debug(fmt.Sprintf("Fetched %d configs, converted to %d splits", len(configsResponse.Configs.Configs), len(splits))) + + // Build and return RuleChangesDTO + ruleChanges := &dtos.RuleChangesDTO{ + FeatureFlags: dtos.FeatureFlagsDTO{ + Since: configsResponse.Configs.Since, + Till: configsResponse.Configs.Till, + Splits: splits, + }, + RuleBasedSegments: dtos.RuleBasedSegmentsDTO{ + Since: configsResponse.Configs.Since, + Till: configsResponse.Configs.Till, + RuleBasedSegments: configsResponse.RBS, + }, + } + + return ruleChanges, nil +} diff --git a/service/api/configs_test.go b/service/api/configs_test.go new file mode 100644 index 00000000..bab1ed77 --- /dev/null +++ b/service/api/configs_test.go @@ -0,0 +1,289 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/splitio/go-split-commons/v9/conf" + "github.com/splitio/go-split-commons/v9/dtos" + "github.com/splitio/go-toolkit/v5/logging" + "github.com/stretchr/testify/assert" +) + +func TestHTTPConfigsFetcher_Fetch_Success(t *testing.T) { + logger := logging.NewLogger(nil) + + // Create mock response + mockResponse := dtos.ConfigsResponseDTO{ + Configs: dtos.ConfigsDataDTO{ + Since: 123, + Till: 456, + Configs: []dtos.ConfigDTO{ + { + Name: "config1", + Status: "ACTIVE", + Killed: false, + TrafficTypeName: "user", + DefaultTreatment: "on", + ChangeNumber: 100, + TrafficAllocation: 100, + TrafficAllocationSeed: 999, + Seed: 777, + Configurations: map[string]string{ + "on": "{\"color\": \"blue\"}", + }, + Conditions: []dtos.ConditionDTO{ + { + ConditionType: "ROLLOUT", + Label: "custom", + MatcherGroup: dtos.MatcherGroupDTO{ + Combiner: "AND", + Matchers: []dtos.MatcherDTO{ + { + MatcherType: "ALL_KEYS", + Negate: false, + }, + }, + }, + Partitions: []dtos.PartitionDTO{ + { + Treatment: "on", + Size: 100, + }, + }, + }, + }, + }, + { + Name: "config2", + DefaultTreatment: "off", + ChangeNumber: 200, + Seed: 888, + }, + }, + }, + RBS: []dtos.RuleBasedSegmentDTO{ + { + Name: "segment1", + ChangeNumber: 300, + }, + }, + } + + responseJSON, _ := json.Marshal(mockResponse) + + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/configs", r.URL.Path) + assert.Equal(t, "123", r.URL.Query().Get("since")) + w.WriteHeader(http.StatusOK) + w.Write(responseJSON) + })) + defer server.Close() + + // Create fetcher + cfg := conf.AdvancedConfig{ + SdkURL: server.URL, + HTTPTimeout: 10, + } + metadata := dtos.Metadata{} + fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + + // Execute fetch + result, err := fetcher.Fetch(123) + + // Assertions + assert.NoError(t, err) + assert.NotNil(t, result) + + // Verify FeatureFlags + assert.Equal(t, int64(123), result.FeatureFlags.Since) + assert.Equal(t, int64(456), result.FeatureFlags.Till) + assert.Equal(t, 2, len(result.FeatureFlags.Splits)) + + // Verify first split (with all fields) + split1 := result.FeatureFlags.Splits[0] + assert.Equal(t, "config1", split1.Name) + assert.Equal(t, "ACTIVE", split1.Status) + assert.Equal(t, false, split1.Killed) + assert.Equal(t, "user", split1.TrafficTypeName) + assert.Equal(t, "on", split1.DefaultTreatment) + assert.Equal(t, int64(100), split1.ChangeNumber) + assert.Equal(t, 100, split1.TrafficAllocation) + assert.Equal(t, int64(999), split1.TrafficAllocationSeed) + assert.Equal(t, int64(777), split1.Seed) + assert.Equal(t, 2, split1.Algo) + assert.NotNil(t, split1.Configurations) + assert.Equal(t, 1, len(split1.Conditions)) + + // Verify second split (with defaults) + split2 := result.FeatureFlags.Splits[1] + assert.Equal(t, "config2", split2.Name) + assert.Equal(t, "ACTIVE", split2.Status) // Default + assert.Equal(t, "user", split2.TrafficTypeName) // Default + assert.Equal(t, "off", split2.DefaultTreatment) + assert.Equal(t, int64(200), split2.ChangeNumber) + assert.Equal(t, int64(888), split2.Seed) + assert.Equal(t, 2, split2.Algo) + assert.Equal(t, 1, len(split2.Conditions)) // Default condition created + + // Verify RuleBasedSegments + assert.Equal(t, int64(123), result.RuleBasedSegments.Since) + assert.Equal(t, int64(456), result.RuleBasedSegments.Till) + assert.Equal(t, 1, len(result.RuleBasedSegments.RuleBasedSegments)) + assert.Equal(t, "segment1", result.RuleBasedSegments.RuleBasedSegments[0].Name) + assert.Equal(t, int64(300), result.RuleBasedSegments.RuleBasedSegments[0].ChangeNumber) +} + +func TestHTTPConfigsFetcher_Fetch_EmptyConfigs(t *testing.T) { + logger := logging.NewLogger(nil) + + // Create mock response with no configs + mockResponse := dtos.ConfigsResponseDTO{ + Configs: dtos.ConfigsDataDTO{ + Since: 100, + Till: 200, + Configs: []dtos.ConfigDTO{}, + }, + RBS: []dtos.RuleBasedSegmentDTO{}, + } + + responseJSON, _ := json.Marshal(mockResponse) + + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(responseJSON) + })) + defer server.Close() + + // Create fetcher + cfg := conf.AdvancedConfig{ + SdkURL: server.URL, + HTTPTimeout: 10, + } + metadata := dtos.Metadata{} + fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + + // Execute fetch + result, err := fetcher.Fetch(100) + + // Assertions + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, int64(100), result.FeatureFlags.Since) + assert.Equal(t, int64(200), result.FeatureFlags.Till) + assert.Equal(t, 0, len(result.FeatureFlags.Splits)) + assert.Equal(t, 0, len(result.RuleBasedSegments.RuleBasedSegments)) +} + +func TestHTTPConfigsFetcher_Fetch_HTTPError(t *testing.T) { + logger := logging.NewLogger(nil) + + // Create test server that returns error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal Server Error")) + })) + defer server.Close() + + // Create fetcher + cfg := conf.AdvancedConfig{ + SdkURL: server.URL, + HTTPTimeout: 10, + } + metadata := dtos.Metadata{} + fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + + // Execute fetch + result, err := fetcher.Fetch(123) + + // Assertions + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestHTTPConfigsFetcher_Fetch_InvalidJSON(t *testing.T) { + logger := logging.NewLogger(nil) + + // Create test server that returns invalid JSON + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("invalid json")) + })) + defer server.Close() + + // Create fetcher + cfg := conf.AdvancedConfig{ + SdkURL: server.URL, + HTTPTimeout: 10, + } + metadata := dtos.Metadata{} + fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + + // Execute fetch + result, err := fetcher.Fetch(123) + + // Assertions + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestHTTPConfigsFetcher_Fetch_WithDefaultConditions(t *testing.T) { + logger := logging.NewLogger(nil) + + // Create mock response with config that has no conditions + mockResponse := dtos.ConfigsResponseDTO{ + Configs: dtos.ConfigsDataDTO{ + Since: 1, + Till: 2, + Configs: []dtos.ConfigDTO{ + { + Name: "config_no_conditions", + DefaultTreatment: "control", + ChangeNumber: 100, + Seed: 555, + }, + }, + }, + RBS: []dtos.RuleBasedSegmentDTO{}, + } + + responseJSON, _ := json.Marshal(mockResponse) + + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(responseJSON) + })) + defer server.Close() + + // Create fetcher + cfg := conf.AdvancedConfig{ + SdkURL: server.URL, + HTTPTimeout: 10, + } + metadata := dtos.Metadata{} + fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + + // Execute fetch + result, err := fetcher.Fetch(1) + + // Assertions + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, 1, len(result.FeatureFlags.Splits)) + + split := result.FeatureFlags.Splits[0] + assert.Equal(t, "config_no_conditions", split.Name) + assert.Equal(t, "control", split.DefaultTreatment) + + // Verify default condition was created + assert.Equal(t, 1, len(split.Conditions)) + assert.Equal(t, "ROLLOUT", split.Conditions[0].ConditionType) + assert.Equal(t, "default rule", split.Conditions[0].Label) + assert.Equal(t, "control", split.Conditions[0].Partitions[0].Treatment) + assert.Equal(t, 100, split.Conditions[0].Partitions[0].Size) +} From f94247e072a77ffca44f9fffb82a981d1be6570a Mon Sep 17 00:00:00 2001 From: Nadia Mayor Date: Mon, 30 Mar 2026 11:02:40 -0500 Subject: [PATCH 2/3] Updated tests --- service/api/configs_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/service/api/configs_test.go b/service/api/configs_test.go index bab1ed77..63fb5772 100644 --- a/service/api/configs_test.go +++ b/service/api/configs_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestHTTPConfigsFetcher_Fetch_Success(t *testing.T) { +func TestHTTPConfigsFetcherFetchSuccess(t *testing.T) { logger := logging.NewLogger(nil) // Create mock response @@ -121,7 +121,7 @@ func TestHTTPConfigsFetcher_Fetch_Success(t *testing.T) { // Verify second split (with defaults) split2 := result.FeatureFlags.Splits[1] assert.Equal(t, "config2", split2.Name) - assert.Equal(t, "ACTIVE", split2.Status) // Default + assert.Equal(t, "ACTIVE", split2.Status) // Default assert.Equal(t, "user", split2.TrafficTypeName) // Default assert.Equal(t, "off", split2.DefaultTreatment) assert.Equal(t, int64(200), split2.ChangeNumber) @@ -137,7 +137,7 @@ func TestHTTPConfigsFetcher_Fetch_Success(t *testing.T) { assert.Equal(t, int64(300), result.RuleBasedSegments.RuleBasedSegments[0].ChangeNumber) } -func TestHTTPConfigsFetcher_Fetch_EmptyConfigs(t *testing.T) { +func TestHTTPConfigsFetcherFetchEmptyConfigs(t *testing.T) { logger := logging.NewLogger(nil) // Create mock response with no configs @@ -179,7 +179,7 @@ func TestHTTPConfigsFetcher_Fetch_EmptyConfigs(t *testing.T) { assert.Equal(t, 0, len(result.RuleBasedSegments.RuleBasedSegments)) } -func TestHTTPConfigsFetcher_Fetch_HTTPError(t *testing.T) { +func TestHTTPConfigsFetcherFetchHTTPError(t *testing.T) { logger := logging.NewLogger(nil) // Create test server that returns error @@ -205,7 +205,7 @@ func TestHTTPConfigsFetcher_Fetch_HTTPError(t *testing.T) { assert.Nil(t, result) } -func TestHTTPConfigsFetcher_Fetch_InvalidJSON(t *testing.T) { +func TestHTTPConfigsFetcherFetchInvalidJSON(t *testing.T) { logger := logging.NewLogger(nil) // Create test server that returns invalid JSON @@ -231,7 +231,7 @@ func TestHTTPConfigsFetcher_Fetch_InvalidJSON(t *testing.T) { assert.Nil(t, result) } -func TestHTTPConfigsFetcher_Fetch_WithDefaultConditions(t *testing.T) { +func TestHTTPConfigsFetcherFetchWithDefaultConditions(t *testing.T) { logger := logging.NewLogger(nil) // Create mock response with config that has no conditions From ca30095ba88d6c0ad683c5c58f5ff13c0fb8932a Mon Sep 17 00:00:00 2001 From: Nadia Mayor Date: Mon, 30 Mar 2026 15:10:15 -0500 Subject: [PATCH 3/3] Updated fetcher to use split interface --- dtos/configmapper_test.go | 177 --------------- dtos/configs.go | 60 +++++ dtos/configs_test.go | 440 ++++++++++++++++++++++++++++++++++++ service/api/configs.go | 51 ++--- service/api/configs_test.go | 91 ++++++-- 5 files changed, 586 insertions(+), 233 deletions(-) delete mode 100644 dtos/configmapper_test.go create mode 100644 dtos/configs_test.go diff --git a/dtos/configmapper_test.go b/dtos/configmapper_test.go deleted file mode 100644 index 8e214c0a..00000000 --- a/dtos/configmapper_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package dtos - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestConvertConfigToSplit_WithAllFields(t *testing.T) { - config := ConfigDTO{ - Name: "test_config", - Status: "ACTIVE", - Killed: false, - TrafficTypeName: "account", - DefaultTreatment: "on", - ChangeNumber: 123456, - TrafficAllocation: 100, - TrafficAllocationSeed: 789, - Seed: 456, - Configurations: map[string]string{ - "on": "{\"color\": \"red\"}", - "off": "{\"color\": \"blue\"}", - }, - Conditions: []ConditionDTO{ - { - ConditionType: "WHITELIST", - Label: "custom rule", - MatcherGroup: MatcherGroupDTO{ - Combiner: "AND", - Matchers: []MatcherDTO{ - { - MatcherType: "WHITELIST", - Negate: false, - }, - }, - }, - Partitions: []PartitionDTO{ - { - Treatment: "on", - Size: 100, - }, - }, - }, - }, - } - - split := ConvertConfigToSplit(config) - - assert.Equal(t, "test_config", split.Name) - assert.Equal(t, "ACTIVE", split.Status) - assert.Equal(t, false, split.Killed) - assert.Equal(t, "account", split.TrafficTypeName) - assert.Equal(t, "on", split.DefaultTreatment) - assert.Equal(t, int64(123456), split.ChangeNumber) - assert.Equal(t, 100, split.TrafficAllocation) - assert.Equal(t, int64(789), split.TrafficAllocationSeed) - assert.Equal(t, int64(456), split.Seed) - assert.Equal(t, 2, split.Algo) - assert.Equal(t, 2, len(split.Configurations)) - assert.Equal(t, "{\"color\": \"red\"}", split.Configurations["on"]) - assert.Equal(t, 1, len(split.Conditions)) - assert.Equal(t, "WHITELIST", split.Conditions[0].ConditionType) -} - -func TestConvertConfigToSplit_WithDefaults(t *testing.T) { - config := ConfigDTO{ - Name: "test_config", - ChangeNumber: 123456, - Seed: 456, - } - - split := ConvertConfigToSplit(config) - - assert.Equal(t, "test_config", split.Name) - assert.Equal(t, "ACTIVE", split.Status, "Status should default to ACTIVE") - assert.Equal(t, "user", split.TrafficTypeName, "TrafficTypeName should default to user") - assert.Equal(t, "default", split.DefaultTreatment, "DefaultTreatment should default to default") - assert.Equal(t, 2, split.Algo, "Algo should always be 2") -} - -func TestConvertConfigToSplit_WithEmptyConditions(t *testing.T) { - config := ConfigDTO{ - Name: "test_config", - DefaultTreatment: "off", - ChangeNumber: 123456, - Seed: 456, - } - - split := ConvertConfigToSplit(config) - - assert.Equal(t, 1, len(split.Conditions), "Should create default condition") - assert.Equal(t, "ROLLOUT", split.Conditions[0].ConditionType) - assert.Equal(t, "default rule", split.Conditions[0].Label) - assert.Equal(t, "AND", split.Conditions[0].MatcherGroup.Combiner) - assert.Equal(t, 1, len(split.Conditions[0].MatcherGroup.Matchers)) - assert.Equal(t, "ALL_KEYS", split.Conditions[0].MatcherGroup.Matchers[0].MatcherType) - assert.Equal(t, false, split.Conditions[0].MatcherGroup.Matchers[0].Negate) - assert.Equal(t, 1, len(split.Conditions[0].Partitions)) - assert.Equal(t, "off", split.Conditions[0].Partitions[0].Treatment) - assert.Equal(t, 100, split.Conditions[0].Partitions[0].Size) -} - -func TestConvertConfigToSplit_WithNilConditions(t *testing.T) { - config := ConfigDTO{ - Name: "test_config", - DefaultTreatment: "control", - ChangeNumber: 123456, - Seed: 456, - Conditions: nil, - } - - split := ConvertConfigToSplit(config) - - assert.Equal(t, 1, len(split.Conditions), "Should create default condition when nil") - assert.Equal(t, "control", split.Conditions[0].Partitions[0].Treatment) -} - -func TestConvertConfigToSplit_WithEmptyDefaultTreatment(t *testing.T) { - config := ConfigDTO{ - Name: "test_config", - DefaultTreatment: "", - ChangeNumber: 123456, - Seed: 456, - } - - split := ConvertConfigToSplit(config) - - assert.Equal(t, "default", split.DefaultTreatment, "Should use 'default' when empty") - assert.Equal(t, "default", split.Conditions[0].Partitions[0].Treatment, "Default condition should use default treatment") -} - -func TestConvertConfigToSplit_KilledFlag(t *testing.T) { - config := ConfigDTO{ - Name: "killed_config", - Killed: true, - ChangeNumber: 123456, - Seed: 456, - } - - split := ConvertConfigToSplit(config) - - assert.Equal(t, true, split.Killed) -} - -func TestConvertConfigToSplit_WithConfigurations(t *testing.T) { - config := ConfigDTO{ - Name: "config_with_configs", - ChangeNumber: 123456, - Seed: 456, - Configurations: map[string]string{ - "on": "{\"size\": 10}", - "off": "{\"size\": 20}", - "default": "{\"size\": 15}", - }, - } - - split := ConvertConfigToSplit(config) - - assert.NotNil(t, split.Configurations) - assert.Equal(t, 3, len(split.Configurations)) - assert.Equal(t, "{\"size\": 10}", split.Configurations["on"]) - assert.Equal(t, "{\"size\": 20}", split.Configurations["off"]) - assert.Equal(t, "{\"size\": 15}", split.Configurations["default"]) -} - -func TestConvertConfigToSplit_WithNilConfigurations(t *testing.T) { - config := ConfigDTO{ - Name: "config_no_configs", - ChangeNumber: 123456, - Seed: 456, - Configurations: nil, - } - - split := ConvertConfigToSplit(config) - - assert.Nil(t, split.Configurations) -} diff --git a/dtos/configs.go b/dtos/configs.go index 06717651..b2aa7f13 100644 --- a/dtos/configs.go +++ b/dtos/configs.go @@ -1,5 +1,7 @@ package dtos +import "encoding/json" + // ConfigDTO represents a configuration definition fetched from the /configs endpoint type ConfigDTO struct { Name string `json:"name"` @@ -27,3 +29,61 @@ type ConfigsResponseDTO struct { Configs ConfigsDataDTO `json:"configs"` RBS []RuleBasedSegmentDTO `json:"rbs"` } + +// FFResponseConfigs implements FFResponse interface for configs endpoint responses +type FFResponseConfigs struct { + configsResponse ConfigsResponseDTO +} + +// NewFFResponseConfigs creates a new FFResponseConfigs instance from JSON data +func NewFFResponseConfigs(data []byte) (FFResponse, error) { + var configsResponse ConfigsResponseDTO + err := json.Unmarshal(data, &configsResponse) + if err != nil { + return nil, err + } + return &FFResponseConfigs{ + configsResponse: configsResponse, + }, nil +} + +// NeedsAnotherFetch checks if another fetch is needed based on the since and till values +func (f *FFResponseConfigs) NeedsAnotherFetch() bool { + return f.configsResponse.Configs.Since == f.configsResponse.Configs.Till +} + +// RuleBasedSegments returns the list of rule-based segments from the response +func (f *FFResponseConfigs) RuleBasedSegments() []RuleBasedSegmentDTO { + return f.configsResponse.RBS +} + +// FeatureFlags returns the list of feature flags (splits) converted from configs +func (f *FFResponseConfigs) FeatureFlags() []SplitDTO { + splits := make([]SplitDTO, 0, len(f.configsResponse.Configs.Configs)) + for _, config := range f.configsResponse.Configs.Configs { + splits = append(splits, ConvertConfigToSplit(config)) + } + return splits +} + +// FFTill returns the till value for feature flags +func (f *FFResponseConfigs) FFTill() int64 { + return f.configsResponse.Configs.Till +} + +// RBTill returns the till value for rule-based segments +func (f *FFResponseConfigs) RBTill() int64 { + return f.configsResponse.Configs.Till +} + +// FFSince returns the since value for feature flags +func (f *FFResponseConfigs) FFSince() int64 { + return f.configsResponse.Configs.Since +} + +// RBSince returns the since value for rule-based segments +func (f *FFResponseConfigs) RBSince() int64 { + return f.configsResponse.Configs.Since +} + +var _ FFResponse = (*FFResponseConfigs)(nil) diff --git a/dtos/configs_test.go b/dtos/configs_test.go new file mode 100644 index 00000000..7c76e6dd --- /dev/null +++ b/dtos/configs_test.go @@ -0,0 +1,440 @@ +package dtos + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +// ConvertConfigToSplit tests + +func TestConvertConfigToSplitWithAllFields(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + Status: "ACTIVE", + Killed: false, + TrafficTypeName: "account", + DefaultTreatment: "on", + ChangeNumber: 123456, + TrafficAllocation: 100, + TrafficAllocationSeed: 789, + Seed: 456, + Configurations: map[string]string{ + "on": "{\"color\": \"red\"}", + "off": "{\"color\": \"blue\"}", + }, + Conditions: []ConditionDTO{ + { + ConditionType: "WHITELIST", + Label: "custom rule", + MatcherGroup: MatcherGroupDTO{ + Combiner: "AND", + Matchers: []MatcherDTO{ + { + MatcherType: "WHITELIST", + Negate: false, + }, + }, + }, + Partitions: []PartitionDTO{ + { + Treatment: "on", + Size: 100, + }, + }, + }, + }, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, "test_config", split.Name) + assert.Equal(t, "ACTIVE", split.Status) + assert.Equal(t, false, split.Killed) + assert.Equal(t, "account", split.TrafficTypeName) + assert.Equal(t, "on", split.DefaultTreatment) + assert.Equal(t, int64(123456), split.ChangeNumber) + assert.Equal(t, 100, split.TrafficAllocation) + assert.Equal(t, int64(789), split.TrafficAllocationSeed) + assert.Equal(t, int64(456), split.Seed) + assert.Equal(t, 2, split.Algo) + assert.Equal(t, 2, len(split.Configurations)) + assert.Equal(t, "{\"color\": \"red\"}", split.Configurations["on"]) + assert.Equal(t, 1, len(split.Conditions)) + assert.Equal(t, "WHITELIST", split.Conditions[0].ConditionType) +} + +func TestConvertConfigToSplit_WithDefaults(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + ChangeNumber: 123456, + Seed: 456, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, "test_config", split.Name) + assert.Equal(t, "ACTIVE", split.Status, "Status should default to ACTIVE") + assert.Equal(t, "user", split.TrafficTypeName, "TrafficTypeName should default to user") + assert.Equal(t, "default", split.DefaultTreatment, "DefaultTreatment should default to default") + assert.Equal(t, 2, split.Algo, "Algo should always be 2") +} + +func TestConvertConfigToSplitWithEmptyConditions(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + DefaultTreatment: "off", + ChangeNumber: 123456, + Seed: 456, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, 1, len(split.Conditions), "Should create default condition") + assert.Equal(t, "ROLLOUT", split.Conditions[0].ConditionType) + assert.Equal(t, "default rule", split.Conditions[0].Label) + assert.Equal(t, "AND", split.Conditions[0].MatcherGroup.Combiner) + assert.Equal(t, 1, len(split.Conditions[0].MatcherGroup.Matchers)) + assert.Equal(t, "ALL_KEYS", split.Conditions[0].MatcherGroup.Matchers[0].MatcherType) + assert.Equal(t, false, split.Conditions[0].MatcherGroup.Matchers[0].Negate) + assert.Equal(t, 1, len(split.Conditions[0].Partitions)) + assert.Equal(t, "off", split.Conditions[0].Partitions[0].Treatment) + assert.Equal(t, 100, split.Conditions[0].Partitions[0].Size) +} + +func TestConvertConfigToSplitWithNilConditions(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + DefaultTreatment: "control", + ChangeNumber: 123456, + Seed: 456, + Conditions: nil, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, 1, len(split.Conditions), "Should create default condition when nil") + assert.Equal(t, "control", split.Conditions[0].Partitions[0].Treatment) +} + +func TestConvertConfigToSplitWithEmptyDefaultTreatment(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + DefaultTreatment: "", + ChangeNumber: 123456, + Seed: 456, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, "default", split.DefaultTreatment, "Should use 'default' when empty") + assert.Equal(t, "default", split.Conditions[0].Partitions[0].Treatment, "Default condition should use default treatment") +} + +func TestConvertConfigToSplit_KilledFlag(t *testing.T) { + config := ConfigDTO{ + Name: "killed_config", + Killed: true, + ChangeNumber: 123456, + Seed: 456, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, true, split.Killed) +} + +func TestConvertConfigToSplitWithConfigurations(t *testing.T) { + config := ConfigDTO{ + Name: "config_with_configs", + ChangeNumber: 123456, + Seed: 456, + Configurations: map[string]string{ + "on": "{\"size\": 10}", + "off": "{\"size\": 20}", + "default": "{\"size\": 15}", + }, + } + + split := ConvertConfigToSplit(config) + + assert.NotNil(t, split.Configurations) + assert.Equal(t, 3, len(split.Configurations)) + assert.Equal(t, "{\"size\": 10}", split.Configurations["on"]) + assert.Equal(t, "{\"size\": 20}", split.Configurations["off"]) + assert.Equal(t, "{\"size\": 15}", split.Configurations["default"]) +} + +func TestConvertConfigToSplitWithNilConfigurations(t *testing.T) { + config := ConfigDTO{ + Name: "config_no_configs", + ChangeNumber: 123456, + Seed: 456, + Configurations: nil, + } + + split := ConvertConfigToSplit(config) + + assert.Nil(t, split.Configurations) +} + +// FFResponseConfigs tests + +func TestFFResponseConfigsNewFFResponseConfigs(t *testing.T) { + jsonData := `{ + "configs": { + "s": 100, + "t": 200, + "d": [ + { + "name": "test_config", + "status": "ACTIVE", + "killed": false, + "trafficTypeName": "user", + "defaultTreatment": "on", + "changeNumber": 150, + "trafficAllocation": 100, + "trafficAllocationSeed": 999, + "seed": 777, + "configurations": { + "on": "{\"color\": \"blue\"}" + }, + "conditions": [] + } + ] + }, + "rbs": [ + { + "name": "segment1", + "changeNumber": 300 + } + ] + }` + + ffResponse, err := NewFFResponseConfigs([]byte(jsonData)) + + assert.NoError(t, err) + assert.NotNil(t, ffResponse) + assert.Equal(t, int64(100), ffResponse.FFSince()) + assert.Equal(t, int64(200), ffResponse.FFTill()) + assert.Equal(t, int64(100), ffResponse.RBSince()) + assert.Equal(t, int64(200), ffResponse.RBTill()) + assert.Equal(t, 1, len(ffResponse.FeatureFlags())) + assert.Equal(t, 1, len(ffResponse.RuleBasedSegments())) +} + +func TestFFResponseConfigsNeedsAnotherFetch(t *testing.T) { + // Test when since != till (needs fetch) + jsonData1 := `{ + "configs": { + "s": 100, + "t": 200, + "d": [] + }, + "rbs": [] + }` + + ffResponse1, _ := NewFFResponseConfigs([]byte(jsonData1)) + assert.False(t, ffResponse1.NeedsAnotherFetch()) + + // Test when since == till (no more data) + jsonData2 := `{ + "configs": { + "s": 100, + "t": 100, + "d": [] + }, + "rbs": [] + }` + + ffResponse2, _ := NewFFResponseConfigs([]byte(jsonData2)) + assert.True(t, ffResponse2.NeedsAnotherFetch()) +} + +func TestFFResponseConfigsFeatureFlags(t *testing.T) { + jsonData := `{ + "configs": { + "s": 100, + "t": 200, + "d": [ + { + "name": "config1", + "defaultTreatment": "on", + "changeNumber": 150, + "seed": 777 + }, + { + "name": "config2", + "defaultTreatment": "off", + "changeNumber": 160, + "seed": 888 + } + ] + }, + "rbs": [] + }` + + ffResponse, err := NewFFResponseConfigs([]byte(jsonData)) + assert.NoError(t, err) + + splits := ffResponse.FeatureFlags() + assert.Equal(t, 2, len(splits)) + assert.Equal(t, "config1", splits[0].Name) + assert.Equal(t, "on", splits[0].DefaultTreatment) + assert.Equal(t, "config2", splits[1].Name) + assert.Equal(t, "off", splits[1].DefaultTreatment) + + // Verify defaults are applied + assert.Equal(t, "ACTIVE", splits[0].Status) + assert.Equal(t, "user", splits[0].TrafficTypeName) + assert.Equal(t, 2, splits[0].Algo) + assert.Equal(t, 1, len(splits[0].Conditions)) +} + +func TestFFResponseConfigsRuleBasedSegments(t *testing.T) { + jsonData := `{ + "configs": { + "s": 100, + "t": 200, + "d": [] + }, + "rbs": [ + { + "name": "segment1", + "changeNumber": 300 + }, + { + "name": "segment2", + "changeNumber": 400 + } + ] + }` + + ffResponse, err := NewFFResponseConfigs([]byte(jsonData)) + assert.NoError(t, err) + + rbs := ffResponse.RuleBasedSegments() + assert.Equal(t, 2, len(rbs)) + assert.Equal(t, "segment1", rbs[0].Name) + assert.Equal(t, int64(300), rbs[0].ChangeNumber) + assert.Equal(t, "segment2", rbs[1].Name) + assert.Equal(t, int64(400), rbs[1].ChangeNumber) +} + +func TestFFResponseConfigsInvalidJSON(t *testing.T) { + jsonData := `invalid json` + + ffResponse, err := NewFFResponseConfigs([]byte(jsonData)) + assert.Error(t, err) + assert.Nil(t, ffResponse) +} + +func TestFFResponseConfigsEmptyResponse(t *testing.T) { + jsonData := `{ + "configs": { + "s": 0, + "t": 0, + "d": [] + }, + "rbs": [] + }` + + ffResponse, err := NewFFResponseConfigs([]byte(jsonData)) + assert.NoError(t, err) + assert.NotNil(t, ffResponse) + assert.Equal(t, int64(0), ffResponse.FFSince()) + assert.Equal(t, int64(0), ffResponse.FFTill()) + assert.Equal(t, 0, len(ffResponse.FeatureFlags())) + assert.Equal(t, 0, len(ffResponse.RuleBasedSegments())) + assert.True(t, ffResponse.NeedsAnotherFetch()) +} + +func TestFFResponseConfigs_ImplementsFFResponse(t *testing.T) { + jsonData := `{ + "configs": { + "s": 100, + "t": 200, + "d": [] + }, + "rbs": [] + }` + + ffResponse, _ := NewFFResponseConfigs([]byte(jsonData)) + + // Verify it implements FFResponse interface + var _ FFResponse = ffResponse + assert.NotNil(t, ffResponse) +} + +func TestFFResponseConfigsWithAllConfigFields(t *testing.T) { + config := ConfigDTO{ + Name: "full_config", + Status: "ACTIVE", + Killed: false, + TrafficTypeName: "account", + DefaultTreatment: "premium", + ChangeNumber: 500, + TrafficAllocation: 100, + TrafficAllocationSeed: 12345, + Seed: 67890, + Configurations: map[string]string{ + "premium": "{\"features\": [\"a\", \"b\"]}", + "free": "{\"features\": [\"a\"]}", + }, + Conditions: []ConditionDTO{ + { + ConditionType: "WHITELIST", + Label: "whitelisted users", + MatcherGroup: MatcherGroupDTO{ + Combiner: "AND", + Matchers: []MatcherDTO{ + { + MatcherType: "WHITELIST", + Negate: false, + }, + }, + }, + Partitions: []PartitionDTO{ + { + Treatment: "premium", + Size: 100, + }, + }, + }, + }, + } + + responseDTO := ConfigsResponseDTO{ + Configs: ConfigsDataDTO{ + Since: 100, + Till: 200, + Configs: []ConfigDTO{config}, + }, + RBS: []RuleBasedSegmentDTO{}, + } + + jsonData, _ := json.Marshal(responseDTO) + ffResponse, err := NewFFResponseConfigs(jsonData) + + assert.NoError(t, err) + assert.NotNil(t, ffResponse) + + splits := ffResponse.FeatureFlags() + assert.Equal(t, 1, len(splits)) + + split := splits[0] + assert.Equal(t, "full_config", split.Name) + assert.Equal(t, "ACTIVE", split.Status) + assert.Equal(t, false, split.Killed) + assert.Equal(t, "account", split.TrafficTypeName) + assert.Equal(t, "premium", split.DefaultTreatment) + assert.Equal(t, int64(500), split.ChangeNumber) + assert.Equal(t, 100, split.TrafficAllocation) + assert.Equal(t, int64(12345), split.TrafficAllocationSeed) + assert.Equal(t, int64(67890), split.Seed) + assert.Equal(t, 2, split.Algo) + assert.Equal(t, 2, len(split.Configurations)) + assert.Equal(t, 1, len(split.Conditions)) + assert.Equal(t, "WHITELIST", split.Conditions[0].ConditionType) +} diff --git a/service/api/configs.go b/service/api/configs.go index b8f61500..c2fa0191 100644 --- a/service/api/configs.go +++ b/service/api/configs.go @@ -1,7 +1,6 @@ package api import ( - "encoding/json" "fmt" "github.com/splitio/go-split-commons/v9/conf" @@ -16,7 +15,7 @@ type HTTPConfigsFetcher struct { } // NewHTTPConfigsFetcher instantiates and returns an HTTPConfigsFetcher -func NewHTTPConfigsFetcher(apikey string, cfg conf.AdvancedConfig, logger logging.LoggerInterface, metadata dtos.Metadata) *HTTPConfigsFetcher { +func NewHTTPConfigsFetcher(apikey string, cfg conf.AdvancedConfig, logger logging.LoggerInterface, metadata dtos.Metadata) service.SplitFetcher { return &HTTPConfigsFetcher{ httpFetcherBase: httpFetcherBase{ client: NewHTTPClient(apikey, cfg, cfg.SdkURL, logger, metadata), @@ -25,49 +24,31 @@ func NewHTTPConfigsFetcher(apikey string, cfg conf.AdvancedConfig, logger loggin } } -// Fetch makes an HTTP call to the /configs endpoint and returns the converted RuleChangesDTO -func (f *HTTPConfigsFetcher) Fetch(since int64) (*dtos.RuleChangesDTO, error) { - // Build query parameters - params := &service.FlagRequestParams{} - params.WithChangeNumber(since) - - // Fetch raw data from /configs endpoint - data, err := f.fetchRaw("/configs", params) +// Fetch makes an HTTP call to the /configs endpoint and returns the FFResponse +func (f *HTTPConfigsFetcher) Fetch(fetchOptions *service.FlagRequestParams) (dtos.FFResponse, error) { + // Fetch raw data from /configs endpoint using the provided fetchOptions + data, err := f.fetchRaw("/configs", fetchOptions) if err != nil { f.logger.Error("Error fetching configs: ", err) return nil, err } - // Unmarshal into ConfigsResponseDTO - var configsResponse dtos.ConfigsResponseDTO - err = json.Unmarshal(data, &configsResponse) + // Parse and wrap the response in FFResponseConfigs + ffResponse, err := dtos.NewFFResponseConfigs(data) if err != nil { f.logger.Error("Error parsing configs JSON: ", err) return nil, err } - // Convert ConfigDTOs to SplitDTOs - splits := make([]dtos.SplitDTO, 0, len(configsResponse.Configs.Configs)) - for _, config := range configsResponse.Configs.Configs { - splitDTO := dtos.ConvertConfigToSplit(config) - splits = append(splits, splitDTO) - } - - f.logger.Debug(fmt.Sprintf("Fetched %d configs, converted to %d splits", len(configsResponse.Configs.Configs), len(splits))) + f.logger.Debug(fmt.Sprintf("Fetched %d configs from /configs endpoint", len(ffResponse.FeatureFlags()))) - // Build and return RuleChangesDTO - ruleChanges := &dtos.RuleChangesDTO{ - FeatureFlags: dtos.FeatureFlagsDTO{ - Since: configsResponse.Configs.Since, - Till: configsResponse.Configs.Till, - Splits: splits, - }, - RuleBasedSegments: dtos.RuleBasedSegmentsDTO{ - Since: configsResponse.Configs.Since, - Till: configsResponse.Configs.Till, - RuleBasedSegments: configsResponse.RBS, - }, - } + return ffResponse, nil +} - return ruleChanges, nil +// IsProxy returns false as HTTPConfigsFetcher is not a proxy +func (f *HTTPConfigsFetcher) IsProxy() bool { + return false } + +// Verify that HTTPConfigsFetcher implements SplitFetcher interface +var _ service.SplitFetcher = (*HTTPConfigsFetcher)(nil) diff --git a/service/api/configs_test.go b/service/api/configs_test.go index 63fb5772..d4d0aa62 100644 --- a/service/api/configs_test.go +++ b/service/api/configs_test.go @@ -8,6 +8,7 @@ import ( "github.com/splitio/go-split-commons/v9/conf" "github.com/splitio/go-split-commons/v9/dtos" + "github.com/splitio/go-split-commons/v9/service" "github.com/splitio/go-toolkit/v5/logging" "github.com/stretchr/testify/assert" ) @@ -91,20 +92,29 @@ func TestHTTPConfigsFetcherFetchSuccess(t *testing.T) { metadata := dtos.Metadata{} fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + // Create fetch options + fetchOptions := service.MakeFlagRequestParams().WithChangeNumber(123) + // Execute fetch - result, err := fetcher.Fetch(123) + result, err := fetcher.Fetch(fetchOptions) // Assertions assert.NoError(t, err) assert.NotNil(t, result) + // Verify FFResponse methods + assert.Equal(t, int64(123), result.FFSince()) + assert.Equal(t, int64(456), result.FFTill()) + assert.Equal(t, int64(123), result.RBSince()) + assert.Equal(t, int64(456), result.RBTill()) + assert.False(t, result.NeedsAnotherFetch()) + // Verify FeatureFlags - assert.Equal(t, int64(123), result.FeatureFlags.Since) - assert.Equal(t, int64(456), result.FeatureFlags.Till) - assert.Equal(t, 2, len(result.FeatureFlags.Splits)) + splits := result.FeatureFlags() + assert.Equal(t, 2, len(splits)) // Verify first split (with all fields) - split1 := result.FeatureFlags.Splits[0] + split1 := splits[0] assert.Equal(t, "config1", split1.Name) assert.Equal(t, "ACTIVE", split1.Status) assert.Equal(t, false, split1.Killed) @@ -119,7 +129,7 @@ func TestHTTPConfigsFetcherFetchSuccess(t *testing.T) { assert.Equal(t, 1, len(split1.Conditions)) // Verify second split (with defaults) - split2 := result.FeatureFlags.Splits[1] + split2 := splits[1] assert.Equal(t, "config2", split2.Name) assert.Equal(t, "ACTIVE", split2.Status) // Default assert.Equal(t, "user", split2.TrafficTypeName) // Default @@ -130,11 +140,10 @@ func TestHTTPConfigsFetcherFetchSuccess(t *testing.T) { assert.Equal(t, 1, len(split2.Conditions)) // Default condition created // Verify RuleBasedSegments - assert.Equal(t, int64(123), result.RuleBasedSegments.Since) - assert.Equal(t, int64(456), result.RuleBasedSegments.Till) - assert.Equal(t, 1, len(result.RuleBasedSegments.RuleBasedSegments)) - assert.Equal(t, "segment1", result.RuleBasedSegments.RuleBasedSegments[0].Name) - assert.Equal(t, int64(300), result.RuleBasedSegments.RuleBasedSegments[0].ChangeNumber) + rbs := result.RuleBasedSegments() + assert.Equal(t, 1, len(rbs)) + assert.Equal(t, "segment1", rbs[0].Name) + assert.Equal(t, int64(300), rbs[0].ChangeNumber) } func TestHTTPConfigsFetcherFetchEmptyConfigs(t *testing.T) { @@ -167,16 +176,19 @@ func TestHTTPConfigsFetcherFetchEmptyConfigs(t *testing.T) { metadata := dtos.Metadata{} fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + // Create fetch options + fetchOptions := service.MakeFlagRequestParams().WithChangeNumber(100) + // Execute fetch - result, err := fetcher.Fetch(100) + result, err := fetcher.Fetch(fetchOptions) // Assertions assert.NoError(t, err) assert.NotNil(t, result) - assert.Equal(t, int64(100), result.FeatureFlags.Since) - assert.Equal(t, int64(200), result.FeatureFlags.Till) - assert.Equal(t, 0, len(result.FeatureFlags.Splits)) - assert.Equal(t, 0, len(result.RuleBasedSegments.RuleBasedSegments)) + assert.Equal(t, int64(100), result.FFSince()) + assert.Equal(t, int64(200), result.FFTill()) + assert.Equal(t, 0, len(result.FeatureFlags())) + assert.Equal(t, 0, len(result.RuleBasedSegments())) } func TestHTTPConfigsFetcherFetchHTTPError(t *testing.T) { @@ -197,8 +209,11 @@ func TestHTTPConfigsFetcherFetchHTTPError(t *testing.T) { metadata := dtos.Metadata{} fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + // Create fetch options + fetchOptions := service.MakeFlagRequestParams().WithChangeNumber(123) + // Execute fetch - result, err := fetcher.Fetch(123) + result, err := fetcher.Fetch(fetchOptions) // Assertions assert.Error(t, err) @@ -223,8 +238,11 @@ func TestHTTPConfigsFetcherFetchInvalidJSON(t *testing.T) { metadata := dtos.Metadata{} fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + // Create fetch options + fetchOptions := service.MakeFlagRequestParams().WithChangeNumber(123) + // Execute fetch - result, err := fetcher.Fetch(123) + result, err := fetcher.Fetch(fetchOptions) // Assertions assert.Error(t, err) @@ -268,15 +286,20 @@ func TestHTTPConfigsFetcherFetchWithDefaultConditions(t *testing.T) { metadata := dtos.Metadata{} fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + // Create fetch options + fetchOptions := service.MakeFlagRequestParams().WithChangeNumber(1) + // Execute fetch - result, err := fetcher.Fetch(1) + result, err := fetcher.Fetch(fetchOptions) // Assertions assert.NoError(t, err) assert.NotNil(t, result) - assert.Equal(t, 1, len(result.FeatureFlags.Splits)) - split := result.FeatureFlags.Splits[0] + splits := result.FeatureFlags() + assert.Equal(t, 1, len(splits)) + + split := splits[0] assert.Equal(t, "config_no_conditions", split.Name) assert.Equal(t, "control", split.DefaultTreatment) @@ -287,3 +310,29 @@ func TestHTTPConfigsFetcherFetchWithDefaultConditions(t *testing.T) { assert.Equal(t, "control", split.Conditions[0].Partitions[0].Treatment) assert.Equal(t, 100, split.Conditions[0].Partitions[0].Size) } + +func TestHTTPConfigsFetcherIsProxy(t *testing.T) { + logger := logging.NewLogger(nil) + cfg := conf.AdvancedConfig{ + SdkURL: "http://localhost", + HTTPTimeout: 10, + } + metadata := dtos.Metadata{} + fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + + assert.False(t, fetcher.IsProxy()) +} + +func TestHTTPConfigsFetcherImplementsSplitFetcher(t *testing.T) { + logger := logging.NewLogger(nil) + cfg := conf.AdvancedConfig{ + SdkURL: "http://localhost", + HTTPTimeout: 10, + } + metadata := dtos.Metadata{} + fetcher := NewHTTPConfigsFetcher("test-api-key", cfg, logger, metadata) + + // Verify it implements the interface + var _ service.SplitFetcher = fetcher + assert.NotNil(t, fetcher) +}