diff --git a/dtos/configmapper.go b/dtos/configmapper.go new file mode 100644 index 00000000..48752b17 --- /dev/null +++ b/dtos/configmapper.go @@ -0,0 +1,186 @@ +package dtos + +import "encoding/json" + +// ConvertConfigToSplit converts a ConfigDTO to a SplitDTO +// This mapper bridges the /configs endpoint response to the internal Split representation +// used by the evaluator. +func ConvertConfigToSplit(config ConfigDTO) SplitDTO { + // Apply defaults as per SDK spec + trafficTypeName := config.TrafficTypeName + if trafficTypeName == "" { + trafficTypeName = "user" + } + + // Default to ACTIVE if status is missing + status := config.Status + if status == "" { + status = "ACTIVE" + } + + // Map Targeting.Default to DefaultTreatment + defaultTreatment := config.Targeting.Default + if defaultTreatment == "" { + defaultTreatment = "control" + } + + // Build configurations map from Variants + // Each variant's Definition (acts as directive for evaluator) is marshaled to JSON + configurations := make(map[string]string) + for _, variant := range config.Variants { + if variant.Definition != nil { + // Marshal the Definition (directive) to JSON string + definitionJSON, err := json.Marshal(variant.Definition) + if err == nil { + configurations[variant.Name] = string(definitionJSON) + } + } + } + + // Transform raw conditions from /configs format to evaluator ConditionDTO format + conditions := make([]ConditionDTO, 0, len(config.Targeting.Conditions)+1) + for _, rawCondition := range config.Targeting.Conditions { + conditions = append(conditions, convertRawCondition(rawCondition)) + } + + // ALWAYS append default rule condition at the end + conditions = append(conditions, ConditionDTO{ + ConditionType: "ROLLOUT", + Label: "default rule", + MatcherGroup: MatcherGroupDTO{ + Combiner: "AND", + Matchers: []MatcherDTO{ + { + MatcherType: "ALL_KEYS", + Negate: false, + KeySelector: nil, + }, + }, + }, + Partitions: []PartitionDTO{ + { + Treatment: defaultTreatment, + Size: 100, + }, + }, + }) + + // Build the SplitDTO for the evaluator + return SplitDTO{ + Name: config.Name, + Killed: config.Killed, // Defaults to false if missing in JSON + ChangeNumber: config.ChangeNumber, + Configurations: configurations, + DefaultTreatment: defaultTreatment, + TrafficTypeName: trafficTypeName, + Status: status, + Algo: 2, // Explicitly set to 2 (Murmur3) - not provided by /configs endpoint + Seed: config.Targeting.Seed, + TrafficAllocation: config.Targeting.TrafficAllocation, + TrafficAllocationSeed: config.Targeting.TrafficAllocationSeed, + Conditions: conditions, + Sets: config.Sets, // Feature flag sets + } +} + +// convertRawCondition transforms a raw condition from /configs format to ConditionDTO +func convertRawCondition(raw RawConditionDTO) ConditionDTO { + // Determine condition type based on matchers + // If any matcher is WHITELIST type, the whole condition is WHITELIST, otherwise ROLLOUT + conditionType := "ROLLOUT" + for _, matcher := range raw.Matchers { + if matcher.Type == "WHITELIST" { + conditionType = "WHITELIST" + break + } + } + + // Convert matchers + matchers := make([]MatcherDTO, 0, len(raw.Matchers)) + for _, rawMatcher := range raw.Matchers { + matchers = append(matchers, convertMatcher(rawMatcher)) + } + + // Convert partitions: variant -> treatment + partitions := make([]PartitionDTO, 0, len(raw.Partitions)) + for _, rawPartition := range raw.Partitions { + partitions = append(partitions, PartitionDTO{ + Treatment: rawPartition.Variant, // Map "variant" to "treatment" + Size: rawPartition.Size, + }) + } + + return ConditionDTO{ + ConditionType: conditionType, + Label: raw.Label, + MatcherGroup: MatcherGroupDTO{ + Combiner: "AND", // Always AND for /configs conditions + Matchers: matchers, + }, + Partitions: partitions, + } +} + +// convertMatcher transforms a raw matcher from /configs format to MatcherDTO +func convertMatcher(raw RawMatcherDTO) MatcherDTO { + // Build keySelector if attribute is provided + var keySelector *KeySelectorDTO + if raw.Attribute != "" { + attr := raw.Attribute + keySelector = &KeySelectorDTO{ + TrafficType: "user", + Attribute: &attr, + } + } + + // Transform based on matcher type + switch raw.Type { + case "WHITELIST": + // Extract strings from data + var whitelist []string + if stringsData, ok := raw.Data["strings"].([]interface{}); ok { + whitelist = make([]string, 0, len(stringsData)) + for _, s := range stringsData { + if str, ok := s.(string); ok { + whitelist = append(whitelist, str) + } + } + } + return MatcherDTO{ + MatcherType: "WHITELIST", + Negate: false, + KeySelector: keySelector, + Whitelist: &WhitelistMatcherDataDTO{ + Whitelist: whitelist, + }, + } + + case "IS_EQUAL_TO": + // Map to EQUAL_TO with unary numeric data + var dataType string + var value int64 + if dt, ok := raw.Data["type"].(string); ok { + dataType = dt + } + if num, ok := raw.Data["number"].(float64); ok { + value = int64(num) + } + return MatcherDTO{ + MatcherType: "EQUAL_TO", + Negate: false, + KeySelector: keySelector, + UnaryNumeric: &UnaryNumericMatcherDataDTO{ + DataType: dataType, + Value: value, + }, + } + + default: + // Default to ALL_KEYS for unknown types + return MatcherDTO{ + MatcherType: "ALL_KEYS", + Negate: false, + KeySelector: keySelector, + } + } +} diff --git a/dtos/configs.go b/dtos/configs.go new file mode 100644 index 00000000..0f349c0e --- /dev/null +++ b/dtos/configs.go @@ -0,0 +1,117 @@ +package dtos + +import "encoding/json" + +// VariantDTO represents a variant (treatment) with its definition (directive for evaluator) +type VariantDTO struct { + Name string `json:"name"` + Definition interface{} `json:"definition"` // Acts as directive for the evaluator +} + +// RawMatcherDTO represents a matcher as returned by the /configs endpoint (needs transformation) +type RawMatcherDTO struct { + Type string `json:"type"` // e.g., "WHITELIST", "IS_EQUAL_TO", "GREATER_THAN_OR_EQUAL_TO" + Attribute string `json:"attribute"` // Attribute name (optional for ALL_KEYS) + Data map[string]interface{} `json:"data"` // Flexible data structure +} + +// RawPartitionDTO represents a partition as returned by the /configs endpoint +type RawPartitionDTO struct { + Variant string `json:"variant"` // Uses "variant" instead of "treatment" + Size int `json:"size"` +} + +// RawConditionDTO represents a condition as returned by the /configs endpoint (needs transformation) +type RawConditionDTO struct { + Label string `json:"label"` + Partitions []RawPartitionDTO `json:"partitions"` + Matchers []RawMatcherDTO `json:"matchers"` // Flat array, not MatcherGroup + // Note: No ConditionType field in raw conditions +} + +// TargetingDTO represents the targeting rules for a config +type TargetingDTO struct { + Default string `json:"default"` // The default variant name + Seed int64 `json:"seed"` // Seed for hashing + TrafficAllocation int `json:"trafficAllocation"` // Percentage of traffic allocated + TrafficAllocationSeed int64 `json:"trafficAllocationSeed"` // Seed for traffic allocation + Conditions []RawConditionDTO `json:"conditions"` // Raw targeting conditions (need transformation) +} + +// ConfigDTO represents a configuration definition fetched from the /configs endpoint +type ConfigDTO struct { + Name string `json:"name"` + TrafficTypeName string `json:"trafficTypeName"` + ChangeNumber int64 `json:"changeNumber"` + Status string `json:"status"` // Defaults to "ACTIVE" if missing + Killed bool `json:"killed"` // Defaults to false if missing + Sets []string `json:"sets"` // Feature flag sets + Variants []VariantDTO `json:"variants"` + Targeting TargetingDTO `json:"targeting"` +} + +// ConfigsResponseDTO represents the response from the /configs endpoint +type ConfigsResponseDTO struct { + Updated []ConfigDTO `json:"updated"` // List of updated configs + Since int64 `json:"since"` // Starting change number + Till int64 `json:"till"` // Ending change number + RBS []RuleBasedSegmentDTO `json:"rbs"` // Rule-based segments +} + +// 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.Since == f.configsResponse.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.Updated)) + for _, config := range f.configsResponse.Updated { + splits = append(splits, ConvertConfigToSplit(config)) + } + return splits +} + +// FFTill returns the till value for feature flags +func (f *FFResponseConfigs) FFTill() int64 { + return f.configsResponse.Till +} + +// RBTill returns the till value for rule-based segments +func (f *FFResponseConfigs) RBTill() int64 { + return f.configsResponse.Till +} + +// FFSince returns the since value for feature flags +func (f *FFResponseConfigs) FFSince() int64 { + return f.configsResponse.Since +} + +// RBSince returns the since value for rule-based segments +func (f *FFResponseConfigs) RBSince() int64 { + return f.configsResponse.Since +} + +var _ FFResponse = (*FFResponseConfigs)(nil) diff --git a/dtos/configs_test.go b/dtos/configs_test.go new file mode 100644 index 00000000..7893f4f0 --- /dev/null +++ b/dtos/configs_test.go @@ -0,0 +1,899 @@ +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", + ChangeNumber: 123456, + Sets: []string{"set1", "set2"}, + Variants: []VariantDTO{ + { + Name: "on", + Definition: map[string]interface{}{"color": "red"}, + }, + { + Name: "off", + Definition: map[string]interface{}{"color": "blue"}, + }, + }, + Targeting: TargetingDTO{ + Default: "on", + Seed: 456, + TrafficAllocation: 100, + TrafficAllocationSeed: 789, + Conditions: []RawConditionDTO{ + { + Label: "custom rule", + Partitions: []RawPartitionDTO{ + { + Variant: "on", + Size: 100, + }, + }, + Matchers: []RawMatcherDTO{ + { + Type: "WHITELIST", + Attribute: "account_type", + Data: map[string]interface{}{ + "strings": []interface{}{"premium", "enterprise"}, + }, + }, + }, + }, + }, + }, + } + + 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.JSONEq(t, "{\"color\":\"red\"}", split.Configurations["on"]) + // Should have 2 conditions: 1 from targeting + 1 default rule + assert.Equal(t, 2, len(split.Conditions)) + assert.Equal(t, "WHITELIST", split.Conditions[0].ConditionType) + assert.Equal(t, "custom rule", split.Conditions[0].Label) + // Verify default rule is always appended + assert.Equal(t, "ROLLOUT", split.Conditions[1].ConditionType) + assert.Equal(t, "default rule", split.Conditions[1].Label) + assert.Equal(t, "on", split.Conditions[1].Partitions[0].Treatment) + assert.Equal(t, 2, len(split.Sets)) + assert.Equal(t, "set1", split.Sets[0]) +} + +func TestConvertConfigToSplitWithDefaults(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + ChangeNumber: 123456, + Targeting: TargetingDTO{ + 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, "control", split.DefaultTreatment, "DefaultTreatment should default to control") + assert.Equal(t, 2, split.Algo, "Algo should always be 2") + // Should have 1 default rule condition + assert.Equal(t, 1, len(split.Conditions)) + assert.Equal(t, "ROLLOUT", split.Conditions[0].ConditionType) + assert.Equal(t, "default rule", split.Conditions[0].Label) +} + +func TestConvertConfigToSplitWithEmptyConditions(t *testing.T) { + config := ConfigDTO{ + Name: "test_config", + ChangeNumber: 123456, + Targeting: TargetingDTO{ + Default: "off", + Seed: 456, + }, + } + + split := ConvertConfigToSplit(config) + + // Empty conditions should still result in default rule being appended + 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", + ChangeNumber: 123456, + Targeting: TargetingDTO{ + Default: "control", + 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", + ChangeNumber: 123456, + Targeting: TargetingDTO{ + Default: "", + Seed: 456, + }, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, "control", split.DefaultTreatment, "Should use 'control' when empty") + assert.Equal(t, "control", split.Conditions[0].Partitions[0].Treatment, "Default condition should use default treatment") +} + +func TestConvertConfigToSplitKilledFlag(t *testing.T) { + config := ConfigDTO{ + Name: "killed_config", + Killed: true, + ChangeNumber: 123456, + Targeting: TargetingDTO{ + Seed: 456, + }, + } + + split := ConvertConfigToSplit(config) + + assert.Equal(t, true, split.Killed) +} + +func TestConvertConfigToSplitWithConfigurations(t *testing.T) { + config := ConfigDTO{ + Name: "config_with_configs", + ChangeNumber: 123456, + Variants: []VariantDTO{ + { + Name: "on", + Definition: map[string]interface{}{"size": 10}, + }, + { + Name: "off", + Definition: map[string]interface{}{"size": 20}, + }, + { + Name: "default", + Definition: map[string]interface{}{"size": 15}, + }, + }, + Targeting: TargetingDTO{ + Seed: 456, + }, + } + + split := ConvertConfigToSplit(config) + + assert.NotNil(t, split.Configurations) + assert.Equal(t, 3, len(split.Configurations)) + assert.JSONEq(t, "{\"size\":10}", split.Configurations["on"]) + assert.JSONEq(t, "{\"size\":20}", split.Configurations["off"]) + assert.JSONEq(t, "{\"size\":15}", split.Configurations["default"]) +} + +func TestConvertConfigToSplitWithNilConfigurations(t *testing.T) { + config := ConfigDTO{ + Name: "config_no_configs", + ChangeNumber: 123456, + Variants: nil, + Targeting: TargetingDTO{ + Seed: 456, + }, + } + + split := ConvertConfigToSplit(config) + + assert.NotNil(t, split.Configurations) + assert.Equal(t, 0, len(split.Configurations)) +} + +// FFResponseConfigs tests + +func TestFFResponseConfigsNewFFResponseConfigs(t *testing.T) { + jsonData := `{ + "updated": [ + { + "name": "test_config", + "status": "ACTIVE", + "killed": false, + "trafficTypeName": "user", + "changeNumber": 150, + "sets": ["set1"], + "variants": [ + { + "name": "on", + "definition": {"color": "blue"} + } + ], + "targeting": { + "default": "on", + "seed": 777, + "trafficAllocation": 100, + "trafficAllocationSeed": 999, + "conditions": [] + } + } + ], + "since": 100, + "till": 200, + "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 := `{ + "updated": [], + "since": 100, + "till": 200, + "rbs": [] + }` + + ffResponse1, _ := NewFFResponseConfigs([]byte(jsonData1)) + assert.False(t, ffResponse1.NeedsAnotherFetch()) + + // Test when since == till (no more data) + jsonData2 := `{ + "updated": [], + "since": 100, + "till": 100, + "rbs": [] + }` + + ffResponse2, _ := NewFFResponseConfigs([]byte(jsonData2)) + assert.True(t, ffResponse2.NeedsAnotherFetch()) +} + +func TestFFResponseConfigsFeatureFlags(t *testing.T) { + jsonData := `{ + "updated": [ + { + "name": "config1", + "changeNumber": 150, + "targeting": { + "default": "on", + "seed": 777 + } + }, + { + "name": "config2", + "changeNumber": 160, + "targeting": { + "default": "off", + "seed": 888 + } + } + ], + "since": 100, + "till": 200, + "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 := `{ + "updated": [], + "since": 100, + "till": 200, + "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 := `{ + "updated": [], + "since": 0, + "till": 0, + "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 := `{ + "updated": [], + "since": 100, + "till": 200, + "rbs": [] + }` + + ffResponse, _ := NewFFResponseConfigs([]byte(jsonData)) + + // Verify it implements FFResponse interface + var _ FFResponse = ffResponse + assert.NotNil(t, ffResponse) +} + +func TestConvertConfigToSplitWithVariantDefinitions(t *testing.T) { + // Test that Variant Definitions (directives) are properly marshaled to JSON strings + config := ConfigDTO{ + Name: "test_variants", + ChangeNumber: 123456, + Variants: []VariantDTO{ + { + Name: "treatment_a", + Definition: map[string]interface{}{ + "action": "redirect", + "url": "https://example.com", + "statusCode": 302, + "enabled": true, + }, + }, + { + Name: "treatment_b", + Definition: map[string]interface{}{ + "action": "render", + "template": map[string]interface{}{ + "name": "banner.html", + "theme": "dark", + }, + }, + }, + }, + Targeting: TargetingDTO{ + Default: "treatment_a", + Seed: 999, + }, + } + + split := ConvertConfigToSplit(config) + + // Verify configurations are created from variant definitions + assert.Equal(t, 2, len(split.Configurations)) + assert.Contains(t, split.Configurations, "treatment_a") + assert.Contains(t, split.Configurations, "treatment_b") + + // Verify the JSON is properly formatted (Definition acts as directive for evaluator) + var configA map[string]interface{} + err := json.Unmarshal([]byte(split.Configurations["treatment_a"]), &configA) + assert.NoError(t, err) + assert.Equal(t, "redirect", configA["action"]) + assert.Equal(t, "https://example.com", configA["url"]) + assert.Equal(t, float64(302), configA["statusCode"]) + assert.Equal(t, true, configA["enabled"]) + + var configB map[string]interface{} + err = json.Unmarshal([]byte(split.Configurations["treatment_b"]), &configB) + assert.NoError(t, err) + assert.Equal(t, "render", configB["action"]) + template := configB["template"].(map[string]interface{}) + assert.Equal(t, "banner.html", template["name"]) + assert.Equal(t, "dark", template["theme"]) +} + +func TestFFResponseConfigsWithAllConfigFields(t *testing.T) { + config := ConfigDTO{ + Name: "full_config", + Status: "ACTIVE", + Killed: false, + TrafficTypeName: "account", + ChangeNumber: 500, + Sets: []string{"set1", "set2"}, + Variants: []VariantDTO{ + { + Name: "premium", + Definition: map[string]interface{}{"features": []interface{}{"a", "b"}}, + }, + { + Name: "free", + Definition: map[string]interface{}{"features": []interface{}{"a"}}, + }, + }, + Targeting: TargetingDTO{ + Default: "premium", + Seed: 67890, + TrafficAllocation: 100, + TrafficAllocationSeed: 12345, + Conditions: []RawConditionDTO{ + { + Label: "whitelisted users", + Partitions: []RawPartitionDTO{ + { + Variant: "premium", + Size: 100, + }, + }, + Matchers: []RawMatcherDTO{ + { + Type: "WHITELIST", + Attribute: "user_type", + Data: map[string]interface{}{ + "strings": []interface{}{"premium", "vip"}, + }, + }, + }, + }, + }, + }, + } + + responseDTO := ConfigsResponseDTO{ + Updated: []ConfigDTO{config}, + Since: 100, + Till: 200, + 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)) + // Should have 2 conditions: 1 from targeting + 1 default rule + assert.Equal(t, 2, len(split.Conditions)) + assert.Equal(t, "WHITELIST", split.Conditions[0].ConditionType) + assert.Equal(t, "ROLLOUT", split.Conditions[1].ConditionType) + assert.Equal(t, "default rule", split.Conditions[1].Label) + assert.Equal(t, 2, len(split.Sets)) +} + +func TestConvertConfigWithWhitelistMatcher(t *testing.T) { + config := ConfigDTO{ + Name: "test_whitelist", + ChangeNumber: 123456, + Variants: []VariantDTO{ + {Name: "on", Definition: map[string]interface{}{"color": "blue"}}, + }, + Targeting: TargetingDTO{ + Default: "on", + Seed: 12345, + Conditions: []RawConditionDTO{ + { + Label: "premium_users", + Partitions: []RawPartitionDTO{ + {Variant: "on", Size: 100}, + }, + Matchers: []RawMatcherDTO{ + { + Type: "WHITELIST", + Attribute: "account_type", + Data: map[string]interface{}{ + "strings": []interface{}{"premium", "enterprise"}, + }, + }, + }, + }, + }, + }, + } + + split := ConvertConfigToSplit(config) + + // Verify condition type is WHITELIST + assert.Equal(t, 2, len(split.Conditions)) + assert.Equal(t, "WHITELIST", split.Conditions[0].ConditionType) + assert.Equal(t, "premium_users", split.Conditions[0].Label) + + // Verify matcher conversion + matcher := split.Conditions[0].MatcherGroup.Matchers[0] + assert.Equal(t, "WHITELIST", matcher.MatcherType) + assert.False(t, matcher.Negate) + assert.NotNil(t, matcher.KeySelector) + assert.Equal(t, "user", matcher.KeySelector.TrafficType) + assert.Equal(t, "account_type", *matcher.KeySelector.Attribute) + assert.NotNil(t, matcher.Whitelist) + assert.Equal(t, 2, len(matcher.Whitelist.Whitelist)) + assert.Contains(t, matcher.Whitelist.Whitelist, "premium") + assert.Contains(t, matcher.Whitelist.Whitelist, "enterprise") + + // Verify partition variant->treatment mapping + assert.Equal(t, "on", split.Conditions[0].Partitions[0].Treatment) +} + +func TestConvertConfigWithUnknownMatcherType(t *testing.T) { + // Per spec, only WHITELIST and IS_EQUAL_TO are supported + // Any other matcher type (like GREATER_THAN_OR_EQUAL_TO) should become ALL_KEYS + config := ConfigDTO{ + Name: "test_unknown_matcher", + ChangeNumber: 123456, + Variants: []VariantDTO{ + {Name: "treatment", Definition: map[string]interface{}{"discount": 15}}, + }, + Targeting: TargetingDTO{ + Default: "control", + Seed: 98765, + Conditions: []RawConditionDTO{ + { + Label: "high_value_segment", + Partitions: []RawPartitionDTO{ + {Variant: "treatment", Size: 100}, + }, + Matchers: []RawMatcherDTO{ + { + Type: "GREATER_THAN_OR_EQUAL_TO", + Attribute: "total_purchases", + Data: map[string]interface{}{ + "type": "NUMERIC", + "number": 5.0, + }, + }, + }, + }, + }, + }, + } + + split := ConvertConfigToSplit(config) + + // Verify condition type is ROLLOUT (not WHITELIST) + assert.Equal(t, 2, len(split.Conditions)) + assert.Equal(t, "ROLLOUT", split.Conditions[0].ConditionType) + assert.Equal(t, "high_value_segment", split.Conditions[0].Label) + + // Verify matcher conversion - GREATER_THAN_OR_EQUAL_TO becomes ALL_KEYS (per spec) + matcher := split.Conditions[0].MatcherGroup.Matchers[0] + assert.Equal(t, "ALL_KEYS", matcher.MatcherType) + assert.False(t, matcher.Negate) + assert.NotNil(t, matcher.KeySelector) + assert.Equal(t, "user", matcher.KeySelector.TrafficType) + assert.Equal(t, "total_purchases", *matcher.KeySelector.Attribute) + // ALL_KEYS matchers don't have unary numeric data + assert.Nil(t, matcher.UnaryNumeric) +} + +func TestConvertConfigWithIsEqualToMatcher(t *testing.T) { + config := ConfigDTO{ + Name: "test_equal", + ChangeNumber: 123456, + Variants: []VariantDTO{ + {Name: "on", Definition: map[string]interface{}{}}, + }, + Targeting: TargetingDTO{ + Default: "off", + Seed: 11111, + Conditions: []RawConditionDTO{ + { + Label: "specific_version", + Partitions: []RawPartitionDTO{ + {Variant: "on", Size: 100}, + }, + Matchers: []RawMatcherDTO{ + { + Type: "IS_EQUAL_TO", + Attribute: "version", + Data: map[string]interface{}{ + "type": "NUMERIC", + "number": 2.0, + }, + }, + }, + }, + }, + }, + } + + split := ConvertConfigToSplit(config) + + // IS_EQUAL_TO should map to EQUAL_TO + assert.Equal(t, 2, len(split.Conditions)) + assert.Equal(t, "ROLLOUT", split.Conditions[0].ConditionType) + + matcher := split.Conditions[0].MatcherGroup.Matchers[0] + assert.Equal(t, "EQUAL_TO", matcher.MatcherType) + assert.NotNil(t, matcher.UnaryNumeric) + assert.Equal(t, "NUMERIC", matcher.UnaryNumeric.DataType) + assert.Equal(t, int64(2), matcher.UnaryNumeric.Value) +} + +func TestConvertConfigWithMultipleConditions(t *testing.T) { + config := ConfigDTO{ + Name: "test_multiple", + ChangeNumber: 123456, + Variants: []VariantDTO{ + {Name: "on", Definition: map[string]interface{}{}}, + {Name: "off", Definition: map[string]interface{}{}}, + }, + Targeting: TargetingDTO{ + Default: "off", + Seed: 99999, + Conditions: []RawConditionDTO{ + { + Label: "whitelist_condition", + Partitions: []RawPartitionDTO{ + {Variant: "on", Size: 100}, + }, + Matchers: []RawMatcherDTO{ + { + Type: "WHITELIST", + Attribute: "user_id", + Data: map[string]interface{}{ + "strings": []interface{}{"user1", "user2"}, + }, + }, + }, + }, + { + Label: "rollout_condition", + Partitions: []RawPartitionDTO{ + {Variant: "on", Size: 50}, + {Variant: "off", Size: 50}, + }, + Matchers: []RawMatcherDTO{ + { + Type: "GREATER_THAN_OR_EQUAL_TO", + Attribute: "age", + Data: map[string]interface{}{ + "type": "NUMERIC", + "number": 18.0, + }, + }, + }, + }, + }, + }, + } + + split := ConvertConfigToSplit(config) + + // Should have 3 conditions: 2 from targeting + 1 default rule + assert.Equal(t, 3, len(split.Conditions)) + + // First condition should be WHITELIST + assert.Equal(t, "WHITELIST", split.Conditions[0].ConditionType) + assert.Equal(t, "whitelist_condition", split.Conditions[0].Label) + + // Second condition should be ROLLOUT (GREATER_THAN_OR_EQUAL_TO is not WHITELIST) + assert.Equal(t, "ROLLOUT", split.Conditions[1].ConditionType) + assert.Equal(t, "rollout_condition", split.Conditions[1].Label) + assert.Equal(t, 2, len(split.Conditions[1].Partitions)) + // Per spec, GREATER_THAN_OR_EQUAL_TO becomes ALL_KEYS + assert.Equal(t, "ALL_KEYS", split.Conditions[1].MatcherGroup.Matchers[0].MatcherType) + + // Third condition should be default rule + assert.Equal(t, "ROLLOUT", split.Conditions[2].ConditionType) + assert.Equal(t, "default rule", split.Conditions[2].Label) + assert.Equal(t, "off", split.Conditions[2].Partitions[0].Treatment) +} + +func TestConvertConfigWithRealJSONExample(t *testing.T) { + // Test with the real JSON structure provided by the user + jsonData := []byte(`{ + "updated": [ + { + "name": "feature_new_checkout", + "variants": [ + { + "name": "on", + "definition": { + "color": "blue", + "buttonText": "Complete Purchase" + } + }, + { + "name": "off", + "definition": { + "color": "green", + "buttonText": "Buy Now" + } + } + ], + "targeting": { + "default": "off", + "seed": 12345, + "trafficAllocation": 100, + "trafficAllocationSeed": 67890, + "conditions": [ + { + "label": "premium_users", + "partitions": [ + { + "variant": "on", + "size": 50 + }, + { + "variant": "off", + "size": 50 + } + ], + "matchers": [ + { + "type": "WHITELIST", + "attribute": "account_type", + "data": { + "strings": ["premium", "enterprise"] + } + } + ] + } + ] + }, + "trafficTypeName": "user", + "changeNumber": 1712345678, + "version": 1, + "status": "ACTIVE", + "killed": false, + "sets": ["checkout_features", "ui_experiments"] + } + ], + "since": 1712345600, + "till": 1712349200 + }`) + + 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, "feature_new_checkout", split.Name) + assert.Equal(t, "ACTIVE", split.Status) + assert.False(t, split.Killed) + assert.Equal(t, "user", split.TrafficTypeName) + assert.Equal(t, "off", split.DefaultTreatment) + assert.Equal(t, int64(1712345678), split.ChangeNumber) + assert.Equal(t, 100, split.TrafficAllocation) + assert.Equal(t, int64(67890), split.TrafficAllocationSeed) + assert.Equal(t, int64(12345), split.Seed) + assert.Equal(t, 2, split.Algo) + + // Verify configurations + assert.Equal(t, 2, len(split.Configurations)) + assert.Contains(t, split.Configurations, "on") + assert.Contains(t, split.Configurations, "off") + + // Verify conditions: 1 from targeting + 1 default rule + assert.Equal(t, 2, len(split.Conditions)) + + // First condition + cond1 := split.Conditions[0] + assert.Equal(t, "WHITELIST", cond1.ConditionType) + assert.Equal(t, "premium_users", cond1.Label) + assert.Equal(t, "AND", cond1.MatcherGroup.Combiner) + assert.Equal(t, 1, len(cond1.MatcherGroup.Matchers)) + + // Verify matcher + matcher := cond1.MatcherGroup.Matchers[0] + assert.Equal(t, "WHITELIST", matcher.MatcherType) + assert.False(t, matcher.Negate) + assert.NotNil(t, matcher.KeySelector) + assert.Equal(t, "user", matcher.KeySelector.TrafficType) + assert.Equal(t, "account_type", *matcher.KeySelector.Attribute) + assert.NotNil(t, matcher.Whitelist) + assert.Equal(t, 2, len(matcher.Whitelist.Whitelist)) + assert.Contains(t, matcher.Whitelist.Whitelist, "premium") + assert.Contains(t, matcher.Whitelist.Whitelist, "enterprise") + + // Verify partitions (variant->treatment mapping) + assert.Equal(t, 2, len(cond1.Partitions)) + assert.Equal(t, "on", cond1.Partitions[0].Treatment) + assert.Equal(t, 50, cond1.Partitions[0].Size) + assert.Equal(t, "off", cond1.Partitions[1].Treatment) + assert.Equal(t, 50, cond1.Partitions[1].Size) + + // Second condition (default rule) + cond2 := split.Conditions[1] + assert.Equal(t, "ROLLOUT", cond2.ConditionType) + assert.Equal(t, "default rule", cond2.Label) + assert.Equal(t, 1, len(cond2.MatcherGroup.Matchers)) + assert.Equal(t, "ALL_KEYS", cond2.MatcherGroup.Matchers[0].MatcherType) + assert.Equal(t, 1, len(cond2.Partitions)) + assert.Equal(t, "off", cond2.Partitions[0].Treatment) + assert.Equal(t, 100, cond2.Partitions[0].Size) + + // Verify sets + assert.Equal(t, 2, len(split.Sets)) + assert.Contains(t, split.Sets, "checkout_features") + assert.Contains(t, split.Sets, "ui_experiments") +} 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..c0e1c2a1 --- /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{ + Updated: []dtos.ConfigDTO{ + { + Name: "config1", + Status: "ACTIVE", + Killed: false, + TrafficTypeName: "user", + ChangeNumber: 100, + Sets: []string{}, + Variants: []dtos.VariantDTO{ + { + Name: "on", + Definition: map[string]interface{}{"color": "blue"}, + }, + }, + Targeting: dtos.TargetingDTO{ + Default: "on", + Seed: 777, + TrafficAllocation: 100, + TrafficAllocationSeed: 999, + Conditions: []dtos.RawConditionDTO{ + { + Label: "custom", + Partitions: []dtos.RawPartitionDTO{ + { + Variant: "on", + Size: 100, + }, + }, + Matchers: []dtos.RawMatcherDTO{ + { + Type: "ALL_KEYS", + }, + }, + }, + }, + }, + }, + { + Name: "config2", + ChangeNumber: 200, + Targeting: dtos.TargetingDTO{ + Default: "off", + Seed: 888, + }, + }, + }, + Since: 123, + Till: 456, + 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) + // Should have 2 conditions: 1 from targeting + 1 default rule + assert.Equal(t, 2, 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{ + Updated: []dtos.ConfigDTO{}, + Since: 100, + Till: 200, + 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{ + Updated: []dtos.ConfigDTO{ + { + Name: "config_no_conditions", + ChangeNumber: 100, + Targeting: dtos.TargetingDTO{ + Default: "control", + Seed: 555, + }, + }, + }, + Since: 1, + Till: 2, + 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) +}