diff --git a/pkg/config/datafileprojectconfig/config.go b/pkg/config/datafileprojectconfig/config.go index 759b79ea..55bec93e 100644 --- a/pkg/config/datafileprojectconfig/config.go +++ b/pkg/config/datafileprojectconfig/config.go @@ -324,6 +324,13 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...)) flagVariationsMap := mappers.MapFlagVariations(featureMap) + // Process holdouts and populate HoldoutIDs for features + holdoutMaps := mappers.MapHoldouts(datafile.Holdouts, audienceMap) + for featureKey, feature := range featureMap { + feature.HoldoutIDs = mappers.GetHoldoutsForFlag(feature.ID, holdoutMaps) + featureMap[featureKey] = feature + } + attributeKeyMap := make(map[string]entities.Attribute) attributeIDToKeyMap := make(map[string]string) diff --git a/pkg/config/datafileprojectconfig/entities/entities.go b/pkg/config/datafileprojectconfig/entities/entities.go index 6b7f2ab8..ce8a8b5f 100644 --- a/pkg/config/datafileprojectconfig/entities/entities.go +++ b/pkg/config/datafileprojectconfig/entities/entities.go @@ -40,18 +40,24 @@ type Cmab struct { TrafficAllocation int `json:"trafficAllocation"` } -// Experiment represents an Experiment object from the Optimizely datafile -type Experiment struct { +// ExperimentCore contains the shared properties between Experiment and Holdout +// This represents the common bucketing and targeting logic +type ExperimentCore struct { ID string `json:"id"` Key string `json:"key"` - LayerID string `json:"layerId"` - Status string `json:"status"` Variations []Variation `json:"variations"` TrafficAllocation []TrafficAllocation `json:"trafficAllocation"` AudienceIds []string `json:"audienceIds"` - ForcedVariations map[string]string `json:"forcedVariations"` AudienceConditions interface{} `json:"audienceConditions"` - Cmab *Cmab `json:"cmab,omitempty"` // is optional +} + +// Experiment represents an Experiment object from the Optimizely datafile +type Experiment struct { + ExperimentCore + LayerID string `json:"layerId"` + Status string `json:"status"` + ForcedVariations map[string]string `json:"forcedVariations"` + Cmab *Cmab `json:"cmab,omitempty"` // is optional } // Group represents an Group object from the Optimizely datafile @@ -62,6 +68,29 @@ type Group struct { Experiments []Experiment `json:"experiments"` } +// HoldoutStatus represents the status of a holdout +type HoldoutStatus string + +const ( + // HoldoutStatusDraft - the holdout status is draft + HoldoutStatusDraft HoldoutStatus = "Draft" + // HoldoutStatusRunning - the holdout status is running + HoldoutStatusRunning HoldoutStatus = "Running" + // HoldoutStatusConcluded - the holdout status is concluded + HoldoutStatusConcluded HoldoutStatus = "Concluded" + // HoldoutStatusArchived - the holdout status is archived + HoldoutStatusArchived HoldoutStatus = "Archived" +) + +// Holdout represents a Holdout object from the Optimizely datafile +// Holdouts share core properties with Experiments through ExperimentCore embedding +type Holdout struct { + ExperimentCore + Status HoldoutStatus `json:"status"` + IncludedFlags []string `json:"includedFlags"` + ExcludedFlags []string `json:"excludedFlags"` +} + // FeatureFlag represents a FeatureFlag object from the Optimizely datafile type FeatureFlag struct { ID string `json:"id"` @@ -132,6 +161,7 @@ type Datafile struct { Integrations []Integration `json:"integrations"` TypedAudiences []Audience `json:"typedAudiences"` Variables []string `json:"variables"` + Holdouts []Holdout `json:"holdouts"` AccountID string `json:"accountId"` ProjectID string `json:"projectId"` Revision string `json:"revision"` diff --git a/pkg/config/datafileprojectconfig/mappers/experiment_test.go b/pkg/config/datafileprojectconfig/mappers/experiment_test.go index fd3a5899..af14ffc4 100644 --- a/pkg/config/datafileprojectconfig/mappers/experiment_test.go +++ b/pkg/config/datafileprojectconfig/mappers/experiment_test.go @@ -125,10 +125,12 @@ func TestMapExperiments(t *testing.T) { func TestMapExperimentsWithStringAudienceCondition(t *testing.T) { rawExperiment := datafileEntities.Experiment{ - ID: "11111", - AudienceIds: []string{"31111"}, - Key: "test_experiment_11111", - AudienceConditions: "31111", + ExperimentCore: datafileEntities.ExperimentCore{ + ID: "11111", + AudienceIds: []string{"31111"}, + Key: "test_experiment_11111", + AudienceConditions: "31111", + }, } rawExperiments := []datafileEntities.Experiment{rawExperiment} @@ -167,7 +169,9 @@ func TestMapExperimentsWithStringAudienceCondition(t *testing.T) { func TestMergeExperiments(t *testing.T) { rawExperiment := datafileEntities.Experiment{ - ID: "11111", + ExperimentCore: datafileEntities.ExperimentCore{ + ID: "11111", + }, } rawGroup := datafileEntities.Group{ Policy: "random", @@ -184,7 +188,9 @@ func TestMergeExperiments(t *testing.T) { }, Experiments: []datafileEntities.Experiment{ { - ID: "11112", + ExperimentCore: datafileEntities.ExperimentCore{ + ID: "11112", + }, }, }, } @@ -195,10 +201,14 @@ func TestMergeExperiments(t *testing.T) { expectedExperiments := []datafileEntities.Experiment{ { - ID: "11111", + ExperimentCore: datafileEntities.ExperimentCore{ + ID: "11111", + }, }, { - ID: "11112", + ExperimentCore: datafileEntities.ExperimentCore{ + ID: "11112", + }, }, } @@ -302,15 +312,17 @@ func TestMapCmab(t *testing.T) { func TestMapExperimentWithCmab(t *testing.T) { // Create a raw experiment with CMAB configuration rawExperiment := datafileEntities.Experiment{ - ID: "exp1", - Key: "experiment_1", - LayerID: "layer1", - Variations: []datafileEntities.Variation{ - {ID: "var1", Key: "variation_1"}, - }, - TrafficAllocation: []datafileEntities.TrafficAllocation{ - {EntityID: "var1", EndOfRange: 10000}, + ExperimentCore: datafileEntities.ExperimentCore{ + ID: "exp1", + Key: "experiment_1", + Variations: []datafileEntities.Variation{ + {ID: "var1", Key: "variation_1"}, + }, + TrafficAllocation: []datafileEntities.TrafficAllocation{ + {EntityID: "var1", EndOfRange: 10000}, + }, }, + LayerID: "layer1", Cmab: &datafileEntities.Cmab{ AttributeIds: []string{"attr1", "attr2"}, TrafficAllocation: 5000, // Changed from array to int diff --git a/pkg/config/datafileprojectconfig/mappers/feature.go b/pkg/config/datafileprojectconfig/mappers/feature.go index 5514857c..ab78419b 100644 --- a/pkg/config/datafileprojectconfig/mappers/feature.go +++ b/pkg/config/datafileprojectconfig/mappers/feature.go @@ -62,6 +62,7 @@ func MapFeatures(featureFlags []datafileEntities.FeatureFlag, rolloutMap map[str feature.ExperimentIDs = featureFlag.ExperimentIDs feature.FeatureExperiments = featureExperiments feature.VariableMap = variableMap + // HoldoutIDs will be populated later when holdouts are processed from the datafile featureMap[featureFlag.Key] = feature } return featureMap diff --git a/pkg/config/datafileprojectconfig/mappers/feature_test.go b/pkg/config/datafileprojectconfig/mappers/feature_test.go index b5dc54ba..884b037e 100644 --- a/pkg/config/datafileprojectconfig/mappers/feature_test.go +++ b/pkg/config/datafileprojectconfig/mappers/feature_test.go @@ -67,6 +67,7 @@ func TestMapFeatures(t *testing.T) { Rollout: rollout, FeatureExperiments: []entities.Experiment{experiment31111}, VariableMap: map[string]entities.Variable{variable.Key: variable}, + HoldoutIDs: []string(nil), }, } expectedExperimentMap := map[string]entities.Experiment{ @@ -77,3 +78,37 @@ func TestMapFeatures(t *testing.T) { assert.Equal(t, expectedFeatureMap, featureMap) assert.Equal(t, expectedExperimentMap, experimentMap) } + +func TestMapFeaturesWithHoldoutIds(t *testing.T) { + const testFeatureFlagString = `{ + "id": "22222", + "key": "test_feature_22222", + "rolloutId": "42222", + "experimentIds": ["32222"], + "variables": [{"defaultValue":"test","id":"2","key":"var","type":"string"}] + }` + + var rawFeatureFlag datafileEntities.FeatureFlag + var json = jsoniter.ConfigCompatibleWithStandardLibrary + json.Unmarshal([]byte(testFeatureFlagString), &rawFeatureFlag) + + rawFeatureFlags := []datafileEntities.FeatureFlag{rawFeatureFlag} + rollout := entities.Rollout{ID: "42222"} + rolloutMap := map[string]entities.Rollout{ + "42222": rollout, + } + experiment32222 := entities.Experiment{ID: "32222"} + experimentMap := map[string]entities.Experiment{ + "32222": experiment32222, + } + featureMap := MapFeatures(rawFeatureFlags, rolloutMap, experimentMap) + + // Verify that the feature is created properly + // HoldoutIDs will be populated later when holdouts are processed from the datafile + feature := featureMap["test_feature_22222"] + + // For now, HoldoutIDs should be nil/empty since they're not in the datafile directly + assert.Nil(t, feature.HoldoutIDs) + assert.Equal(t, "22222", feature.ID) + assert.Equal(t, "test_feature_22222", feature.Key) +} diff --git a/pkg/config/datafileprojectconfig/mappers/holdout.go b/pkg/config/datafileprojectconfig/mappers/holdout.go new file mode 100644 index 00000000..8d54b807 --- /dev/null +++ b/pkg/config/datafileprojectconfig/mappers/holdout.go @@ -0,0 +1,113 @@ +/**************************************************************************** + * Copyright 2025, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package mappers ... +package mappers + +import ( + datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities" + "github.com/optimizely/go-sdk/v2/pkg/entities" +) + +// HoldoutMaps contains the different holdout mappings for efficient lookup +type HoldoutMaps struct { + HoldoutIDMap map[string]entities.Holdout // Map holdout ID to holdout + GlobalHoldouts []entities.Holdout // Holdouts with no specific flag inclusion + IncludedHoldouts map[string][]entities.Holdout // Map flag ID to holdouts that include it + ExcludedHoldouts map[string][]entities.Holdout // Map flag ID to holdouts that exclude it + FlagHoldoutsMap map[string][]string // Cached map of flag ID to holdout IDs +} + +// MapHoldouts maps the raw datafile holdout entities to SDK Holdout entities +// and creates the necessary mappings for efficient holdout lookup +func MapHoldouts(holdouts []datafileEntities.Holdout, audienceMap map[string]entities.Audience) HoldoutMaps { + holdoutMaps := HoldoutMaps{ + HoldoutIDMap: make(map[string]entities.Holdout), + GlobalHoldouts: []entities.Holdout{}, + IncludedHoldouts: make(map[string][]entities.Holdout), + ExcludedHoldouts: make(map[string][]entities.Holdout), + FlagHoldoutsMap: make(map[string][]string), + } + + for _, datafileHoldout := range holdouts { + // Create minimal runtime holdout entity - only what's needed for flag dependency + holdout := entities.Holdout{ + ID: datafileHoldout.ID, + Key: datafileHoldout.Key, + Status: entities.HoldoutStatus(datafileHoldout.Status), + IncludedFlags: datafileHoldout.IncludedFlags, + ExcludedFlags: datafileHoldout.ExcludedFlags, + } + + // Add to ID map + holdoutMaps.HoldoutIDMap[holdout.ID] = holdout + + // Categorize holdouts based on flag targeting + if len(datafileHoldout.IncludedFlags) == 0 { + // This is a global holdout (applies to all flags unless excluded) + holdoutMaps.GlobalHoldouts = append(holdoutMaps.GlobalHoldouts, holdout) + + // Add to excluded flags map + for _, flagID := range datafileHoldout.ExcludedFlags { + holdoutMaps.ExcludedHoldouts[flagID] = append(holdoutMaps.ExcludedHoldouts[flagID], holdout) + } + } else { + // This holdout specifically includes certain flags + for _, flagID := range datafileHoldout.IncludedFlags { + holdoutMaps.IncludedHoldouts[flagID] = append(holdoutMaps.IncludedHoldouts[flagID], holdout) + } + } + } + + return holdoutMaps +} + +// GetHoldoutsForFlag returns the holdout IDs that apply to a specific flag +// This follows the logic from JavaScript SDK: global holdouts (minus excluded) + specifically included +func GetHoldoutsForFlag(flagID string, holdoutMaps HoldoutMaps) []string { + // Check cache first + if cachedHoldoutIDs, exists := holdoutMaps.FlagHoldoutsMap[flagID]; exists { + return cachedHoldoutIDs + } + + holdoutIDs := []string{} + + // Add global holdouts that don't exclude this flag + for _, holdout := range holdoutMaps.GlobalHoldouts { + isExcluded := false + for _, excludedFlagID := range holdout.ExcludedFlags { + if excludedFlagID == flagID { + isExcluded = true + break + } + } + if !isExcluded { + holdoutIDs = append(holdoutIDs, holdout.ID) + } + } + + // Add holdouts that specifically include this flag + if includedHoldouts, exists := holdoutMaps.IncludedHoldouts[flagID]; exists { + for _, holdout := range includedHoldouts { + holdoutIDs = append(holdoutIDs, holdout.ID) + } + } + + // Cache the result + holdoutMaps.FlagHoldoutsMap[flagID] = holdoutIDs + + return holdoutIDs +} diff --git a/pkg/config/datafileprojectconfig/mappers/holdout_test.go b/pkg/config/datafileprojectconfig/mappers/holdout_test.go new file mode 100644 index 00000000..ec2e2b53 --- /dev/null +++ b/pkg/config/datafileprojectconfig/mappers/holdout_test.go @@ -0,0 +1,141 @@ +/**************************************************************************** + * Copyright 2025, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +package mappers + +import ( + "testing" + + datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities" + "github.com/optimizely/go-sdk/v2/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func TestMapHoldouts(t *testing.T) { + // Create test holdouts - minimal fields only + holdouts := []datafileEntities.Holdout{ + { + ExperimentCore: datafileEntities.ExperimentCore{ + ID: "holdout_1", + Key: "global_holdout", + }, + Status: datafileEntities.HoldoutStatusRunning, + IncludedFlags: []string{}, // Global holdout + ExcludedFlags: []string{"feature_3"}, + }, + { + ExperimentCore: datafileEntities.ExperimentCore{ + ID: "holdout_2", + Key: "feature_specific_holdout", + }, + Status: datafileEntities.HoldoutStatusRunning, + IncludedFlags: []string{"feature_1", "feature_2"}, + ExcludedFlags: []string{}, + }, + } + + audienceMap := map[string]entities.Audience{ + "audience1": { + ID: "audience1", + Name: "Test Audience", + }, + } + + // Map holdouts + holdoutMaps := MapHoldouts(holdouts, audienceMap) + + // Verify holdout ID map + assert.Len(t, holdoutMaps.HoldoutIDMap, 2) + assert.Contains(t, holdoutMaps.HoldoutIDMap, "holdout_1") + assert.Contains(t, holdoutMaps.HoldoutIDMap, "holdout_2") + + // Verify global holdouts + assert.Len(t, holdoutMaps.GlobalHoldouts, 1) + assert.Equal(t, "holdout_1", holdoutMaps.GlobalHoldouts[0].ID) + + // Verify included holdouts + assert.Len(t, holdoutMaps.IncludedHoldouts["feature_1"], 1) + assert.Equal(t, "holdout_2", holdoutMaps.IncludedHoldouts["feature_1"][0].ID) + assert.Len(t, holdoutMaps.IncludedHoldouts["feature_2"], 1) + assert.Equal(t, "holdout_2", holdoutMaps.IncludedHoldouts["feature_2"][0].ID) + + // Verify excluded holdouts + assert.Len(t, holdoutMaps.ExcludedHoldouts["feature_3"], 1) + assert.Equal(t, "holdout_1", holdoutMaps.ExcludedHoldouts["feature_3"][0].ID) +} + +func TestGetHoldoutsForFlag(t *testing.T) { + // Create test holdouts similar to JavaScript SDK test + holdout1 := entities.Holdout{ + ID: "holdout_1", + Key: "global_holdout", + IncludedFlags: []string{}, // Global + ExcludedFlags: []string{}, + } + + holdout2 := entities.Holdout{ + ID: "holdout_2", + Key: "global_with_exclusion", + IncludedFlags: []string{}, // Global + ExcludedFlags: []string{"feature_3"}, + } + + holdout3 := entities.Holdout{ + ID: "holdout_3", + Key: "feature_specific", + IncludedFlags: []string{"feature_1"}, + ExcludedFlags: []string{}, + } + + holdoutMaps := HoldoutMaps{ + HoldoutIDMap: map[string]entities.Holdout{ + "holdout_1": holdout1, + "holdout_2": holdout2, + "holdout_3": holdout3, + }, + GlobalHoldouts: []entities.Holdout{holdout1, holdout2}, + IncludedHoldouts: map[string][]entities.Holdout{ + "feature_1": {holdout3}, + }, + ExcludedHoldouts: map[string][]entities.Holdout{ + "feature_3": {holdout2}, + }, + FlagHoldoutsMap: make(map[string][]string), + } + + // Test feature_1: should get global holdouts + specifically included + holdoutIDs := GetHoldoutsForFlag("feature_1", holdoutMaps) + assert.Len(t, holdoutIDs, 3) + assert.Contains(t, holdoutIDs, "holdout_1") + assert.Contains(t, holdoutIDs, "holdout_2") + assert.Contains(t, holdoutIDs, "holdout_3") + + // Test feature_2: should get only global holdouts + holdoutIDs = GetHoldoutsForFlag("feature_2", holdoutMaps) + assert.Len(t, holdoutIDs, 2) + assert.Contains(t, holdoutIDs, "holdout_1") + assert.Contains(t, holdoutIDs, "holdout_2") + + // Test feature_3: should get global holdouts minus excluded + holdoutIDs = GetHoldoutsForFlag("feature_3", holdoutMaps) + assert.Len(t, holdoutIDs, 1) + assert.Contains(t, holdoutIDs, "holdout_1") + assert.NotContains(t, holdoutIDs, "holdout_2") // Excluded + + // Test caching - second call should return cached result + cachedHoldoutIDs := GetHoldoutsForFlag("feature_3", holdoutMaps) + assert.Equal(t, holdoutIDs, cachedHoldoutIDs) +} diff --git a/pkg/decision/experiment_bucketer_service_test.go b/pkg/decision/experiment_bucketer_service_test.go index 1ce0a478..9000c5c8 100644 --- a/pkg/decision/experiment_bucketer_service_test.go +++ b/pkg/decision/experiment_bucketer_service_test.go @@ -14,105 +14,104 @@ * limitations under the License. * ***************************************************************************/ - package decision - - import ( - "fmt" - "testing" - - "github.com/optimizely/go-sdk/v2/pkg/decide" - "github.com/optimizely/go-sdk/v2/pkg/decision/reasons" - "github.com/optimizely/go-sdk/v2/pkg/logging" - - "github.com/optimizely/go-sdk/v2/pkg/entities" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" - ) - - type MockBucketer struct { - mock.Mock - } - - func (m *MockBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error) { - args := m.Called(bucketingID, experiment, group) - return args.Get(0).(*entities.Variation), args.Get(1).(reasons.Reason), args.Error(2) - } - - // Add the new method to satisfy the ExperimentBucketer interface - func (m *MockBucketer) BucketToEntityID(bucketingID string, experiment entities.Experiment, group entities.Group) (string, reasons.Reason, error) { - args := m.Called(bucketingID, experiment, group) - return args.String(0), args.Get(1).(reasons.Reason), args.Error(2) - } - - type MockLogger struct { - mock.Mock - } - - func (m *MockLogger) Debug(message string) { - m.Called(message) - } - - func (m *MockLogger) Info(message string) { - m.Called(message) - } - - func (m *MockLogger) Warning(message string) { - m.Called(message) - } - - func (m *MockLogger) Error(message string, err interface{}) { - m.Called(message, err) - } - - type ExperimentBucketerTestSuite struct { - suite.Suite - mockBucketer *MockBucketer - mockLogger *MockLogger - mockConfig *mockProjectConfig - options *decide.Options - reasons decide.DecisionReasons - } - - func (s *ExperimentBucketerTestSuite) SetupTest() { - s.mockBucketer = new(MockBucketer) - s.mockLogger = new(MockLogger) - s.mockConfig = new(mockProjectConfig) - s.options = &decide.Options{} - s.reasons = decide.NewDecisionReasons(s.options) - } - - func (s *ExperimentBucketerTestSuite) TestGetDecisionNoTargeting() { - testUserContext := entities.UserContext{ - ID: "test_user_1", - } - - expectedDecision := ExperimentDecision{ - Variation: &testExp1111Var2222, - Decision: Decision{ - Reason: reasons.BucketedIntoVariation, - }, - } - - testDecisionContext := ExperimentDecisionContext{ - Experiment: &testExp1111, - ProjectConfig: s.mockConfig, - } - s.mockBucketer.On("Bucket", testUserContext.ID, testExp1111, entities.Group{}).Return(&testExp1111Var2222, reasons.BucketedIntoVariation, nil) - s.mockLogger.On("Debug", fmt.Sprintf(logging.ExperimentAudiencesEvaluatedTo.String(), "test_experiment_1111", true)) - experimentBucketerService := ExperimentBucketerService{ - bucketer: s.mockBucketer, - logger: s.mockLogger, - } - s.options.IncludeReasons = true - decision, rsons, err := experimentBucketerService.GetDecision(testDecisionContext, testUserContext, s.options) - messages := rsons.ToReport() - s.Len(messages, 1) - s.Equal(`Audiences for experiment test_experiment_1111 collectively evaluated to true.`, messages[0]) - s.Equal(expectedDecision, decision) - s.NoError(err) - s.mockLogger.AssertExpectations(s.T()) - } - +package decision + +import ( + "fmt" + "testing" + + "github.com/optimizely/go-sdk/v2/pkg/decide" + "github.com/optimizely/go-sdk/v2/pkg/decision/reasons" + "github.com/optimizely/go-sdk/v2/pkg/logging" + + "github.com/optimizely/go-sdk/v2/pkg/entities" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type MockBucketer struct { + mock.Mock +} + +func (m *MockBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error) { + args := m.Called(bucketingID, experiment, group) + return args.Get(0).(*entities.Variation), args.Get(1).(reasons.Reason), args.Error(2) +} + +// Add the new method to satisfy the ExperimentBucketer interface +func (m *MockBucketer) BucketToEntityID(bucketingID string, experiment entities.Experiment, group entities.Group) (string, reasons.Reason, error) { + args := m.Called(bucketingID, experiment, group) + return args.String(0), args.Get(1).(reasons.Reason), args.Error(2) +} + +type MockLogger struct { + mock.Mock +} + +func (m *MockLogger) Debug(message string) { + m.Called(message) +} + +func (m *MockLogger) Info(message string) { + m.Called(message) +} + +func (m *MockLogger) Warning(message string) { + m.Called(message) +} + +func (m *MockLogger) Error(message string, err interface{}) { + m.Called(message, err) +} + +type ExperimentBucketerTestSuite struct { + suite.Suite + mockBucketer *MockBucketer + mockLogger *MockLogger + mockConfig *mockProjectConfig + options *decide.Options + reasons decide.DecisionReasons +} + +func (s *ExperimentBucketerTestSuite) SetupTest() { + s.mockBucketer = new(MockBucketer) + s.mockLogger = new(MockLogger) + s.mockConfig = new(mockProjectConfig) + s.options = &decide.Options{} + s.reasons = decide.NewDecisionReasons(s.options) +} + +func (s *ExperimentBucketerTestSuite) TestGetDecisionNoTargeting() { + testUserContext := entities.UserContext{ + ID: "test_user_1", + } + + expectedDecision := ExperimentDecision{ + Variation: &testExp1111Var2222, + Decision: Decision{ + Reason: reasons.BucketedIntoVariation, + }, + } + + testDecisionContext := ExperimentDecisionContext{ + Experiment: &testExp1111, + ProjectConfig: s.mockConfig, + } + s.mockBucketer.On("Bucket", testUserContext.ID, testExp1111, entities.Group{}).Return(&testExp1111Var2222, reasons.BucketedIntoVariation, nil) + s.mockLogger.On("Debug", fmt.Sprintf(logging.ExperimentAudiencesEvaluatedTo.String(), "test_experiment_1111", true)) + experimentBucketerService := ExperimentBucketerService{ + bucketer: s.mockBucketer, + logger: s.mockLogger, + } + s.options.IncludeReasons = true + decision, rsons, err := experimentBucketerService.GetDecision(testDecisionContext, testUserContext, s.options) + messages := rsons.ToReport() + s.Len(messages, 1) + s.Equal(`Audiences for experiment test_experiment_1111 collectively evaluated to true.`, messages[0]) + s.Equal(expectedDecision, decision) + s.NoError(err) + s.mockLogger.AssertExpectations(s.T()) +} func (s *ExperimentBucketerTestSuite) TestGetDecisionWithTargetingPasses() { testUserContext := entities.UserContext{ diff --git a/pkg/entities/experiment.go b/pkg/entities/experiment.go index 50001cc2..4887071d 100644 --- a/pkg/entities/experiment.go +++ b/pkg/entities/experiment.go @@ -59,3 +59,26 @@ type VariationVariable struct { ID string Value string } + +// HoldoutStatus represents the status of a holdout +type HoldoutStatus string + +const ( + // HoldoutStatusDraft - the holdout status is draft + HoldoutStatusDraft HoldoutStatus = "Draft" + // HoldoutStatusRunning - the holdout status is running + HoldoutStatusRunning HoldoutStatus = "Running" + // HoldoutStatusConcluded - the holdout status is concluded + HoldoutStatusConcluded HoldoutStatus = "Concluded" + // HoldoutStatusArchived - the holdout status is archived + HoldoutStatusArchived HoldoutStatus = "Archived" +) + +// Holdout represents a holdout that can be applied to feature flags +type Holdout struct { + ID string + Key string + Status HoldoutStatus + IncludedFlags []string // Flag IDs this holdout specifically includes + ExcludedFlags []string // Flag IDs this holdout specifically excludes +} diff --git a/pkg/entities/feature.go b/pkg/entities/feature.go index 226adff4..e252c117 100644 --- a/pkg/entities/feature.go +++ b/pkg/entities/feature.go @@ -25,6 +25,7 @@ type Feature struct { ExperimentIDs []string Rollout Rollout VariableMap map[string]Variable + HoldoutIDs []string } // Rollout represents a feature rollout