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/configs.go b/dtos/configs.go new file mode 100644 index 00000000..b2aa7f13 --- /dev/null +++ b/dtos/configs.go @@ -0,0 +1,89 @@ +package dtos + +import "encoding/json" + +// 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"` +} + +// 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 new file mode 100644 index 00000000..c2fa0191 --- /dev/null +++ b/service/api/configs.go @@ -0,0 +1,54 @@ +package api + +import ( + "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) service.SplitFetcher { + 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 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 + } + + // 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 + } + + f.logger.Debug(fmt.Sprintf("Fetched %d configs from /configs endpoint", len(ffResponse.FeatureFlags()))) + + return ffResponse, 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 new file mode 100644 index 00000000..d4d0aa62 --- /dev/null +++ b/service/api/configs_test.go @@ -0,0 +1,338 @@ +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-split-commons/v9/service" + "github.com/splitio/go-toolkit/v5/logging" + "github.com/stretchr/testify/assert" +) + +func TestHTTPConfigsFetcherFetchSuccess(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) + + // Create fetch options + fetchOptions := service.MakeFlagRequestParams().WithChangeNumber(123) + + // Execute fetch + 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 + splits := result.FeatureFlags() + assert.Equal(t, 2, len(splits)) + + // Verify first split (with all fields) + split1 := 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 := 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 + 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) { + 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) + + // Create fetch options + fetchOptions := service.MakeFlagRequestParams().WithChangeNumber(100) + + // Execute fetch + result, err := fetcher.Fetch(fetchOptions) + + // Assertions + assert.NoError(t, err) + assert.NotNil(t, result) + 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) { + 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) + + // Create fetch options + fetchOptions := service.MakeFlagRequestParams().WithChangeNumber(123) + + // Execute fetch + result, err := fetcher.Fetch(fetchOptions) + + // Assertions + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestHTTPConfigsFetcherFetchInvalidJSON(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) + + // Create fetch options + fetchOptions := service.MakeFlagRequestParams().WithChangeNumber(123) + + // Execute fetch + result, err := fetcher.Fetch(fetchOptions) + + // Assertions + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestHTTPConfigsFetcherFetchWithDefaultConditions(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) + + // Create fetch options + fetchOptions := service.MakeFlagRequestParams().WithChangeNumber(1) + + // Execute fetch + result, err := fetcher.Fetch(fetchOptions) + + // Assertions + assert.NoError(t, err) + assert.NotNil(t, result) + + 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) + + // 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) +} + +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) +}