diff --git a/pkg/config/datafileprojectconfig/config.go b/pkg/config/datafileprojectconfig/config.go index 13526875..13baf1d7 100644 --- a/pkg/config/datafileprojectconfig/config.go +++ b/pkg/config/datafileprojectconfig/config.go @@ -59,10 +59,11 @@ type DatafileProjectConfig struct { environmentKey string region string - flagVariationsMap map[string][]entities.Variation - holdouts []entities.Holdout - holdoutIDMap map[string]entities.Holdout - flagHoldoutsMap map[string][]entities.Holdout + flagVariationsMap map[string][]entities.Variation + holdouts []entities.Holdout + holdoutIDMap map[string]entities.Holdout + flagHoldoutsMap map[string][]entities.Holdout + experimentHoldoutsMap map[string][]entities.Holdout } // GetDatafile returns a string representation of the environment's datafile @@ -292,6 +293,19 @@ func (c DatafileProjectConfig) GetHoldoutsForFlag(featureKey string) []entities. return []entities.Holdout{} } +// GetHoldoutsForExperiment returns the holdouts that apply to a specific experiment +func (c DatafileProjectConfig) GetHoldoutsForExperiment(experimentID string) []entities.Holdout { + if holdouts, exists := c.experimentHoldoutsMap[experimentID]; exists { + return holdouts + } + return []entities.Holdout{} +} + +// GetHoldoutList returns all holdouts +func (c DatafileProjectConfig) GetHoldoutList() []entities.Holdout { + return c.holdouts +} + // NewDatafileProjectConfig initializes a new datafile from a json byte array using the default JSON datafile parser func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogProducer) (*DatafileProjectConfig, error) { datafile, err := Parse(jsonDatafile) @@ -334,7 +348,7 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP featureMap := mappers.MapFeatures(datafile.FeatureFlags, rolloutMap, experimentIDMap) audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...)) flagVariationsMap := mappers.MapFlagVariations(featureMap) - holdouts, holdoutIDMap, flagHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts, featureMap) + holdouts, holdoutIDMap, flagHoldoutsMap, experimentHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts, featureMap) attributeKeyMap := make(map[string]entities.Attribute) attributeIDToKeyMap := make(map[string]string) @@ -350,36 +364,37 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP } config := &DatafileProjectConfig{ - hostForODP: hostForODP, - publicKeyForODP: publicKeyForODP, - datafile: string(jsonDatafile), - accountID: datafile.AccountID, - anonymizeIP: datafile.AnonymizeIP, - attributeKeyToIDMap: attributeKeyToIDMap, - audienceMap: audienceMap, - attributeMap: attributeMap, - botFiltering: datafile.BotFiltering, - sdkKey: datafile.SDKKey, - environmentKey: datafile.EnvironmentKey, - experimentKeyToIDMap: experimentKeyMap, - experimentMap: experimentIDMap, - groupMap: groupMap, - eventMap: eventMap, - featureMap: featureMap, - projectID: datafile.ProjectID, - revision: datafile.Revision, - rollouts: rollouts, - integrations: integrations, - segments: audienceSegmentList, - rolloutMap: rolloutMap, - sendFlagDecisions: datafile.SendFlagDecisions, - flagVariationsMap: flagVariationsMap, - attributeKeyMap: attributeKeyMap, - attributeIDToKeyMap: attributeIDToKeyMap, - region: region, - holdouts: holdouts, - holdoutIDMap: holdoutIDMap, - flagHoldoutsMap: flagHoldoutsMap, + hostForODP: hostForODP, + publicKeyForODP: publicKeyForODP, + datafile: string(jsonDatafile), + accountID: datafile.AccountID, + anonymizeIP: datafile.AnonymizeIP, + attributeKeyToIDMap: attributeKeyToIDMap, + audienceMap: audienceMap, + attributeMap: attributeMap, + botFiltering: datafile.BotFiltering, + sdkKey: datafile.SDKKey, + environmentKey: datafile.EnvironmentKey, + experimentKeyToIDMap: experimentKeyMap, + experimentMap: experimentIDMap, + groupMap: groupMap, + eventMap: eventMap, + featureMap: featureMap, + projectID: datafile.ProjectID, + revision: datafile.Revision, + rollouts: rollouts, + integrations: integrations, + segments: audienceSegmentList, + rolloutMap: rolloutMap, + sendFlagDecisions: datafile.SendFlagDecisions, + flagVariationsMap: flagVariationsMap, + attributeKeyMap: attributeKeyMap, + attributeIDToKeyMap: attributeIDToKeyMap, + region: region, + holdouts: holdouts, + holdoutIDMap: holdoutIDMap, + flagHoldoutsMap: flagHoldoutsMap, + experimentHoldoutsMap: experimentHoldoutsMap, } logger.Info("Datafile is valid.") diff --git a/pkg/config/datafileprojectconfig/config_test.go b/pkg/config/datafileprojectconfig/config_test.go index 96984729..8dac1ea3 100644 --- a/pkg/config/datafileprojectconfig/config_test.go +++ b/pkg/config/datafileprojectconfig/config_test.go @@ -18,12 +18,14 @@ package datafileprojectconfig import ( + stdjson "encoding/json" "errors" "fmt" "os" "path/filepath" "testing" + datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities" "github.com/optimizely/go-sdk/v2/pkg/entities" "github.com/optimizely/go-sdk/v2/pkg/logging" @@ -583,7 +585,7 @@ func TestCmabExperiments(t *testing.T) { // Parse the datafile to modify it var datafileJSON map[string]interface{} - err = json.Unmarshal(datafile, &datafileJSON) + err = stdjson.Unmarshal(datafile, &datafileJSON) assert.NoError(t, err) // Add CMAB to the first experiment with traffic allocation as an integer @@ -595,7 +597,7 @@ func TestCmabExperiments(t *testing.T) { } // Convert back to JSON - modifiedDatafile, err := json.Marshal(datafileJSON) + modifiedDatafile, err := stdjson.Marshal(datafileJSON) assert.NoError(t, err) // Create project config from modified datafile @@ -632,7 +634,7 @@ func TestCmabExperimentsNil(t *testing.T) { // Parse the datafile to get experiment keys var datafileJSON map[string]interface{} - err = json.Unmarshal(datafile, &datafileJSON) + err = stdjson.Unmarshal(datafile, &datafileJSON) assert.NoError(t, err) experiments := datafileJSON["experiments"].([]interface{}) @@ -789,3 +791,263 @@ func TestGetHoldoutsForFlagWithDifferentFlag(t *testing.T) { assert.Len(t, actual, 0) assert.Equal(t, []entities.Holdout{}, actual) } + +// Test experiments field in Holdout and experiment-to-holdout mapping +func TestHoldout_ExperimentsField(t *testing.T) { + jsonDatafile := `{ + "version": "4", + "accountId": "12345", + "projectId": "67890", + "revision": "1", + "holdouts": [ + { + "id": "holdout_with_experiments", + "key": "local_holdout", + "status": "Running", + "variations": [], + "trafficAllocation": [], + "audienceIds": [], + "experiments": ["exp_1", "exp_2"] + }, + { + "id": "holdout_without_experiments", + "key": "global_holdout", + "status": "Running", + "variations": [], + "trafficAllocation": [], + "audienceIds": [] + } + ] + }` + + config, err := NewDatafileProjectConfig([]byte(jsonDatafile), logging.GetLogger("", "test")) + assert.NoError(t, err) + + // Get holdouts from config + holdouts := config.GetHoldoutList() + assert.Len(t, holdouts, 2) + + // Find holdout with experiments + var localHoldout, globalHoldout entities.Holdout + for _, h := range holdouts { + if h.ID == "holdout_with_experiments" { + localHoldout = h + } else if h.ID == "holdout_without_experiments" { + globalHoldout = h + } + } + + // Test local holdout + assert.Equal(t, "holdout_with_experiments", localHoldout.ID) + assert.Len(t, localHoldout.Experiments, 2) + assert.Contains(t, localHoldout.Experiments, "exp_1") + assert.Contains(t, localHoldout.Experiments, "exp_2") + assert.True(t, localHoldout.IsLocal()) + + // Test global holdout + assert.Equal(t, "holdout_without_experiments", globalHoldout.ID) + assert.Empty(t, globalHoldout.Experiments) + assert.False(t, globalHoldout.IsLocal()) +} + +func TestHoldout_IsLocal(t *testing.T) { + // Test IsLocal returns true when experiments is not empty + holdout1 := entities.Holdout{ + ID: "local_holdout", + Experiments: []string{"exp_1", "exp_2"}, + } + assert.True(t, holdout1.IsLocal()) + + // Test IsLocal returns false when experiments is empty + holdout2 := entities.Holdout{ + ID: "global_holdout", + Experiments: []string{}, + } + assert.False(t, holdout2.IsLocal()) + + // Test IsLocal returns false when experiments is nil + holdout3 := entities.Holdout{ + ID: "nil_experiments", + Experiments: nil, + } + assert.False(t, holdout3.IsLocal()) +} + +func TestGetHoldoutsForExperiment(t *testing.T) { + jsonDatafile := `{ + "version": "4", + "accountId": "12345", + "projectId": "67890", + "revision": "1", + "holdouts": [ + { + "id": "holdout_1", + "key": "local_holdout_1", + "status": "Running", + "variations": [], + "trafficAllocation": [], + "audienceIds": [], + "experiments": ["exp_1"] + }, + { + "id": "holdout_2", + "key": "local_holdout_2", + "status": "Running", + "variations": [], + "trafficAllocation": [], + "audienceIds": [], + "experiments": ["exp_1", "exp_2"] + }, + { + "id": "holdout_3", + "key": "global_holdout", + "status": "Running", + "variations": [], + "trafficAllocation": [], + "audienceIds": [] + } + ] + }` + + config, err := NewDatafileProjectConfig([]byte(jsonDatafile), logging.GetLogger("", "test")) + assert.NoError(t, err) + + // Test exp_1 should have holdout_1 and holdout_2 + holdouts := config.GetHoldoutsForExperiment("exp_1") + assert.Len(t, holdouts, 2) + holdoutIDs := make([]string, len(holdouts)) + for i, h := range holdouts { + holdoutIDs[i] = h.ID + } + assert.Contains(t, holdoutIDs, "holdout_1") + assert.Contains(t, holdoutIDs, "holdout_2") + + // Test exp_2 should have only holdout_2 + holdouts = config.GetHoldoutsForExperiment("exp_2") + assert.Len(t, holdouts, 1) + assert.Equal(t, "holdout_2", holdouts[0].ID) + + // Test non-existent experiment should return empty array + holdouts = config.GetHoldoutsForExperiment("non_existent") + assert.Empty(t, holdouts) +} + +func TestExperimentHoldoutsMapping_MultipleExperiments(t *testing.T) { + jsonDatafile := `{ + "version": "4", + "accountId": "12345", + "projectId": "67890", + "revision": "1", + "holdouts": [ + { + "id": "holdout_multi", + "key": "multi_exp_holdout", + "status": "Running", + "variations": [], + "trafficAllocation": [], + "audienceIds": [], + "experiments": ["exp_a", "exp_b", "exp_c"] + } + ] + }` + + config, err := NewDatafileProjectConfig([]byte(jsonDatafile), logging.GetLogger("", "test")) + assert.NoError(t, err) + + // All three experiments should map to the same holdout + for _, expID := range []string{"exp_a", "exp_b", "exp_c"} { + holdouts := config.GetHoldoutsForExperiment(expID) + assert.Len(t, holdouts, 1) + assert.Equal(t, "holdout_multi", holdouts[0].ID) + } +} + +func TestExperimentHoldoutsMapping_EmptyExperiments(t *testing.T) { + jsonDatafile := `{ + "version": "4", + "accountId": "12345", + "projectId": "67890", + "revision": "1", + "holdouts": [ + { + "id": "global_holdout", + "key": "global", + "status": "Running", + "variations": [], + "trafficAllocation": [], + "audienceIds": [] + } + ] + }` + + config, err := NewDatafileProjectConfig([]byte(jsonDatafile), logging.GetLogger("", "test")) + assert.NoError(t, err) + + // Global holdout should not appear in experiment mapping + holdouts := config.GetHoldoutsForExperiment("any_experiment") + assert.Empty(t, holdouts) + + // Verify the holdout still exists in the list + allHoldouts := config.GetHoldoutList() + assert.Len(t, allHoldouts, 1) + assert.Equal(t, "global_holdout", allHoldouts[0].ID) +} + +func TestHoldout_BackwardCompatibility(t *testing.T) { + // Test that holdouts without experiments field still work (backward compatibility) + jsonDatafile := `{ + "version": "4", + "accountId": "12345", + "projectId": "67890", + "revision": "1", + "holdouts": [ + { + "id": "old_holdout", + "key": "legacy_holdout", + "status": "Running", + "variations": [], + "trafficAllocation": [], + "audienceIds": [] + } + ] + }` + + config, err := NewDatafileProjectConfig([]byte(jsonDatafile), logging.GetLogger("", "test")) + assert.NoError(t, err) + + holdouts := config.GetHoldoutList() + assert.Len(t, holdouts, 1) + assert.Equal(t, "old_holdout", holdouts[0].ID) + assert.Empty(t, holdouts[0].Experiments) + assert.False(t, holdouts[0].IsLocal()) +} + +func TestHoldout_JSONSerialization(t *testing.T) { + // Test that experiments field is properly serialized when present + holdoutWithExperiments := datafileEntities.Holdout{ + ID: "test_holdout", + Key: "test_key", + Status: string(entities.HoldoutStatusRunning), + Experiments: []string{"exp_1"}, + } + + jsonBytes, err := stdjson.Marshal(holdoutWithExperiments) + assert.NoError(t, err) + jsonStr := string(jsonBytes) + assert.Contains(t, jsonStr, `"experiments"`) + assert.Contains(t, jsonStr, `"exp_1"`) + + // Test that empty experiments array is omitted due to omitempty tag + holdoutWithoutExperiments := datafileEntities.Holdout{ + ID: "test_holdout_2", + Key: "test_key_2", + Status: string(entities.HoldoutStatusRunning), + Experiments: []string{}, + } + + jsonBytes, err = stdjson.Marshal(holdoutWithoutExperiments) + assert.NoError(t, err) + jsonStr = string(jsonBytes) + // Empty array should be omitted due to omitempty tag + assert.NotContains(t, jsonStr, `"experiments"`) +} diff --git a/pkg/config/datafileprojectconfig/entities/entities.go b/pkg/config/datafileprojectconfig/entities/entities.go index 76edd48d..7ae4eea4 100644 --- a/pkg/config/datafileprojectconfig/entities/entities.go +++ b/pkg/config/datafileprojectconfig/entities/entities.go @@ -124,6 +124,7 @@ type Holdout struct { TrafficAllocation []TrafficAllocation `json:"trafficAllocation"` IncludedFlags []string `json:"includedFlags,omitempty"` ExcludedFlags []string `json:"excludedFlags,omitempty"` + Experiments []string `json:"experiments,omitempty"` } // Integration represents a integration from the Optimizely datafile diff --git a/pkg/config/datafileprojectconfig/mappers/holdout.go b/pkg/config/datafileprojectconfig/mappers/holdout.go index 6108d04d..9aa9c5bc 100644 --- a/pkg/config/datafileprojectconfig/mappers/holdout.go +++ b/pkg/config/datafileprojectconfig/mappers/holdout.go @@ -28,10 +28,12 @@ func MapHoldouts(holdouts []datafileEntities.Holdout, featureMap map[string]enti holdoutList []entities.Holdout, holdoutIDMap map[string]entities.Holdout, flagHoldoutsMap map[string][]entities.Holdout, + experimentHoldoutsMap map[string][]entities.Holdout, ) { holdoutList = []entities.Holdout{} holdoutIDMap = make(map[string]entities.Holdout) flagHoldoutsMap = make(map[string][]entities.Holdout) + experimentHoldoutsMap = make(map[string][]entities.Holdout) globalHoldouts := []entities.Holdout{} includedHoldouts := make(map[string][]entities.Holdout) @@ -62,6 +64,11 @@ func MapHoldouts(holdouts []datafileEntities.Holdout, featureMap map[string]enti includedHoldouts[flagID] = append(includedHoldouts[flagID], mappedHoldout) } } + + // Build experiment-to-holdout mapping + for _, experimentID := range holdout.Experiments { + experimentHoldoutsMap[experimentID] = append(experimentHoldoutsMap[experimentID], mappedHoldout) + } } // Build flagHoldoutsMap by combining global and specific holdouts @@ -86,7 +93,7 @@ func MapHoldouts(holdouts []datafileEntities.Holdout, featureMap map[string]enti } } - return holdoutList, holdoutIDMap, flagHoldoutsMap + return holdoutList, holdoutIDMap, flagHoldoutsMap, experimentHoldoutsMap } func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout { @@ -139,5 +146,6 @@ func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout { Variations: variations, TrafficAllocation: trafficAllocation, AudienceConditionTree: audienceConditionTree, + Experiments: datafileHoldout.Experiments, } } diff --git a/pkg/config/datafileprojectconfig/mappers/holdout_test.go b/pkg/config/datafileprojectconfig/mappers/holdout_test.go index 7b192005..56c80454 100644 --- a/pkg/config/datafileprojectconfig/mappers/holdout_test.go +++ b/pkg/config/datafileprojectconfig/mappers/holdout_test.go @@ -28,7 +28,7 @@ func TestMapHoldoutsEmpty(t *testing.T) { rawHoldouts := []datafileEntities.Holdout{} featureMap := map[string]entities.Feature{} - holdoutList, holdoutIDMap, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + holdoutList, holdoutIDMap, flagHoldoutsMap, _ := MapHoldouts(rawHoldouts, featureMap) assert.Empty(t, holdoutList) assert.Empty(t, holdoutIDMap) @@ -58,7 +58,7 @@ func TestMapHoldoutsGlobalHoldout(t *testing.T) { "feature_3": {ID: "feature_3", Key: "feature_3"}, } - holdoutList, holdoutIDMap, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + holdoutList, holdoutIDMap, flagHoldoutsMap, _ := MapHoldouts(rawHoldouts, featureMap) // Verify holdout list and ID map assert.Len(t, holdoutList, 1) @@ -98,7 +98,7 @@ func TestMapHoldoutsSpecificHoldout(t *testing.T) { "feature_3": {ID: "feature_3", Key: "feature_3"}, } - holdoutList, holdoutIDMap, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + holdoutList, holdoutIDMap, flagHoldoutsMap, _ := MapHoldouts(rawHoldouts, featureMap) // Verify holdout list and ID map assert.Len(t, holdoutList, 1) @@ -130,7 +130,7 @@ func TestMapHoldoutsNotRunning(t *testing.T) { "feature_1": {ID: "feature_1", Key: "feature_1"}, } - holdoutList, holdoutIDMap, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + holdoutList, holdoutIDMap, flagHoldoutsMap, _ := MapHoldouts(rawHoldouts, featureMap) // Non-running holdouts should be filtered out assert.Empty(t, holdoutList) @@ -172,7 +172,7 @@ func TestMapHoldoutsMixed(t *testing.T) { "feature_2": {ID: "feature_2", Key: "feature_2"}, } - holdoutList, holdoutIDMap, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + holdoutList, holdoutIDMap, flagHoldoutsMap, _ := MapHoldouts(rawHoldouts, featureMap) // Verify both holdouts are in the list assert.Len(t, holdoutList, 2) @@ -223,7 +223,7 @@ func TestMapHoldoutsPrecedence(t *testing.T) { "feature_1": {ID: "feature_1", Key: "feature_1"}, } - _, _, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + _, _, flagHoldoutsMap, _ := MapHoldouts(rawHoldouts, featureMap) // feature_1 should have BOTH holdouts, with global FIRST (precedence) assert.Contains(t, flagHoldoutsMap, "feature_1") @@ -257,7 +257,7 @@ func TestMapHoldoutsExcludedFlagsNotInMap(t *testing.T) { "feature_excluded": {ID: "feature_excluded", Key: "feature_excluded"}, } - _, _, flagHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + _, _, flagHoldoutsMap, _ := MapHoldouts(rawHoldouts, featureMap) // feature_included should have the global holdout assert.Contains(t, flagHoldoutsMap, "feature_included") @@ -288,7 +288,7 @@ func TestMapHoldoutsWithAudienceConditions(t *testing.T) { "feature_1": {ID: "feature_1", Key: "feature_1"}, } - holdoutList, _, _ := MapHoldouts(rawHoldouts, featureMap) + holdoutList, _, _, _ := MapHoldouts(rawHoldouts, featureMap) // Verify audience conditions are mapped assert.Len(t, holdoutList, 1) @@ -328,7 +328,7 @@ func TestMapHoldoutsVariationsMapping(t *testing.T) { "feature_1": {ID: "feature_1", Key: "feature_1"}, } - holdoutList, _, _ := MapHoldouts(rawHoldouts, featureMap) + holdoutList, _, _, _ := MapHoldouts(rawHoldouts, featureMap) // Verify variations are mapped correctly assert.Len(t, holdoutList, 1) @@ -343,3 +343,74 @@ func TestMapHoldoutsVariationsMapping(t *testing.T) { assert.Equal(t, "var_2", holdoutList[0].TrafficAllocation[1].EntityID) assert.Equal(t, 10000, holdoutList[0].TrafficAllocation[1].EndOfRange) } + +func TestMapHoldoutsWithExperiments(t *testing.T) { + rawHoldouts := []datafileEntities.Holdout{ + { + ID: "holdout_1", + Key: "local_holdout_1", + Status: string(entities.HoldoutStatusRunning), + Experiments: []string{"exp_1"}, + }, + { + ID: "holdout_2", + Key: "local_holdout_2", + Status: string(entities.HoldoutStatusRunning), + Experiments: []string{"exp_1", "exp_2"}, + }, + { + ID: "holdout_3", + Key: "global_holdout", + Status: string(entities.HoldoutStatusRunning), + Experiments: []string{}, + }, + } + featureMap := map[string]entities.Feature{} + + holdoutList, _, _, experimentHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + + // Verify all holdouts are in the list + assert.Len(t, holdoutList, 3) + + // Verify experiments field is mapped correctly + assert.Len(t, holdoutList[0].Experiments, 1) + assert.Contains(t, holdoutList[0].Experiments, "exp_1") + assert.Len(t, holdoutList[1].Experiments, 2) + assert.Contains(t, holdoutList[1].Experiments, "exp_1") + assert.Contains(t, holdoutList[1].Experiments, "exp_2") + assert.Empty(t, holdoutList[2].Experiments) + + // Verify experiment holdouts map + assert.Len(t, experimentHoldoutsMap, 2) + assert.Len(t, experimentHoldoutsMap["exp_1"], 2) + assert.Len(t, experimentHoldoutsMap["exp_2"], 1) + + // Check exp_1 has holdout_1 and holdout_2 + exp1HoldoutIDs := []string{experimentHoldoutsMap["exp_1"][0].ID, experimentHoldoutsMap["exp_1"][1].ID} + assert.Contains(t, exp1HoldoutIDs, "holdout_1") + assert.Contains(t, exp1HoldoutIDs, "holdout_2") + + // Check exp_2 has only holdout_2 + assert.Equal(t, "holdout_2", experimentHoldoutsMap["exp_2"][0].ID) +} + +func TestMapHoldoutsMultipleExperiments(t *testing.T) { + rawHoldouts := []datafileEntities.Holdout{ + { + ID: "holdout_multi", + Key: "multi_exp_holdout", + Status: string(entities.HoldoutStatusRunning), + Experiments: []string{"exp_a", "exp_b", "exp_c"}, + }, + } + featureMap := map[string]entities.Feature{} + + _, _, _, experimentHoldoutsMap := MapHoldouts(rawHoldouts, featureMap) + + // All three experiments should map to the same holdout + assert.Len(t, experimentHoldoutsMap, 3) + for _, expID := range []string{"exp_a", "exp_b", "exp_c"} { + assert.Len(t, experimentHoldoutsMap[expID], 1) + assert.Equal(t, "holdout_multi", experimentHoldoutsMap[expID][0].ID) + } +} diff --git a/pkg/entities/experiment.go b/pkg/entities/experiment.go index 6d04e581..fe1ac00c 100644 --- a/pkg/entities/experiment.go +++ b/pkg/entities/experiment.go @@ -78,4 +78,10 @@ type Holdout struct { Variations map[string]Variation // keyed by variation ID TrafficAllocation []Range AudienceConditionTree *TreeNode + Experiments []string // Experiment IDs this holdout targets +} + +// IsLocal returns true if the holdout targets specific experiments +func (h Holdout) IsLocal() bool { + return len(h.Experiments) > 0 }