Skip to content

Commit 7700714

Browse files
authored
feat: Add ExperimentWhitelistService (#129)
Summary: Add ExperimentWhitelistService, which returns decisions based on the contents of forcedVariations in experiments in the datafile. NewCompositeExperimentService creates an ExperimentWhitelistService as its first child service, taking precedence over normal bucketing. To support ExperimentWhitelistService, I added a Whitelist field to the Experiment entity. The value comes directly from forcedVariations of the datafile experiment. I named it Whitelist to match the term we use to talk about this kind of override, even though it's not named that in the datafile. Test plan: Added unit tests
1 parent 9bc5b0b commit 7700714

File tree

9 files changed

+234
-8
lines changed

9 files changed

+234
-8
lines changed

.travis.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,20 @@ jobs:
1818
- $GOPATH/bin/golangci-lint run --out-format=tab --tests=false optimizely/...
1919
- stage: 'Integration Tests'
2020
merge_mode: replace
21-
env: SDK=go
21+
env: SDK=go SDK_BRANCH=$TRAVIS_PULL_REQUEST_BRANCH
2222
cache: false
2323
language: minimal
2424
install: skip
2525
before_script:
2626
- mkdir $HOME/travisci-tools && pushd $HOME/travisci-tools && git init && git pull https://[email protected]/optimizely/travisci-tools.git && popd
2727
script:
28-
- "$HOME/travisci-tools/fsc-trigger/trigger_fullstack-sdk-compat.sh"
28+
# TODO: Remove sohail/gosdkonly branch specification here, after
29+
# we can run FSC tests on master: https://optimizely.atlassian.net/browse/OASIS-5425
30+
- $HOME/travisci-tools/trigger-script-with-status-update.sh sohail/gosdkonly
2931
after_success: travis_terminate 0
3032
- &test
3133
stage: 'Unit test'
32-
env: GIMME_GO_VERSION=master GIMME_OS=linux GIMME_ARCH=amd64
34+
env: GIMME_GO_VERSION=master GIMME_OS=linux GIMME_ARCH=amd64
3335
script:
3436
- go test -v -race ./... -coverprofile=profile.cov
3537
- <<: *test
@@ -48,7 +50,7 @@ jobs:
4850
- <<: *test
4951
stage: 'Unit test'
5052
env: GIMME_GO_VERSION=1.12.x
51-
before_script:
53+
before_script:
5254
- go get github.com/mattn/goveralls
5355
after_success:
5456
- $GOPATH/bin/goveralls -coverprofile=profile.cov -service=travis-ci

optimizely/config/datafileprojectconfig/mappers/experiment.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func mapExperiment(rawExperiment datafileEntities.Experiment) entities.Experimen
6969
Variations: make(map[string]entities.Variation),
7070
TrafficAllocation: make([]entities.Range, len(rawExperiment.TrafficAllocation)),
7171
AudienceConditionTree: audienceConditionTree,
72+
Whitelist: rawExperiment.ForcedVariations,
7273
}
7374

7475
for _, variation := range rawExperiment.Variations {

optimizely/decision/composite_experiment_service.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ type CompositeExperimentService struct {
2929
// NewCompositeExperimentService creates a new instance of the CompositeExperimentService
3030
func NewCompositeExperimentService() *CompositeExperimentService {
3131
// These decision services are applied in order:
32-
// 1. Bucketing
33-
// @TODO(mng): Prepend forced variation and whitelisting services
32+
// 1. Whitelist
33+
// 2. Bucketing
34+
// @TODO(mng): Prepend forced variation
3435
return &CompositeExperimentService{
3536
experimentServices: []ExperimentService{
37+
NewExperimentWhitelistService(),
3638
NewExperimentBucketerService(),
3739
},
3840
}

optimizely/decision/composite_experiment_service_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ func (s *CompositeExperimentTestSuite) TestGetDecisionNoDecisionsMade() {
115115
s.mockExperimentService2.AssertExpectations(s.T())
116116
}
117117

118+
func (s *CompositeExperimentTestSuite) TestNewCompositeExperimentService() {
119+
// Assert that the service is instantiated with the correct child services in the right order
120+
compositeExperimentService := NewCompositeExperimentService()
121+
s.Equal(2, len(compositeExperimentService.experimentServices))
122+
s.IsType(&ExperimentWhitelistService{}, compositeExperimentService.experimentServices[0])
123+
s.IsType(&ExperimentBucketerService{}, compositeExperimentService.experimentServices[1])
124+
}
125+
118126
func TestCompositeExperimentTestSuite(t *testing.T) {
119127
suite.Run(t, new(CompositeExperimentTestSuite))
120128
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/****************************************************************************
2+
* Copyright 2019, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
// Package decision //
18+
package decision
19+
20+
import (
21+
"errors"
22+
23+
"github.com/optimizely/go-sdk/optimizely/decision/reasons"
24+
"github.com/optimizely/go-sdk/optimizely/entities"
25+
)
26+
27+
// ExperimentWhitelistService makes a decision using an experiment's whitelist (a map of user id to variation keys)
28+
// Implements the ExperimentService interface
29+
type ExperimentWhitelistService struct{}
30+
31+
// NewExperimentWhitelistService returns a new instance of ExperimentWhitelistService
32+
func NewExperimentWhitelistService() *ExperimentWhitelistService {
33+
return &ExperimentWhitelistService{}
34+
}
35+
36+
// GetDecision returns a decision with a variation when a variation assignment is found in the experiment whitelist for the given user and experiment
37+
func (s ExperimentWhitelistService) GetDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext) (ExperimentDecision, error) {
38+
decision := ExperimentDecision{}
39+
40+
if decisionContext.Experiment == nil {
41+
return decision, errors.New("decisionContext Experiment is nil")
42+
}
43+
44+
variationKey, ok := decisionContext.Experiment.Whitelist[userContext.ID]
45+
if !ok {
46+
decision.Reason = reasons.NoWhitelistVariationAssignment
47+
return decision, nil
48+
}
49+
50+
variation, ok := decisionContext.Experiment.Variations[variationKey]
51+
if !ok {
52+
decision.Reason = reasons.InvalidWhitelistVariationAssignment
53+
return decision, nil
54+
}
55+
56+
decision.Reason = reasons.WhitelistVariationAssignmentFound
57+
decision.Variation = &variation
58+
return decision, nil
59+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/****************************************************************************
2+
* Copyright 2019, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
// Package decision //
18+
package decision
19+
20+
import (
21+
"testing"
22+
23+
"github.com/optimizely/go-sdk/optimizely/decision/reasons"
24+
"github.com/optimizely/go-sdk/optimizely/entities"
25+
"github.com/stretchr/testify/suite"
26+
)
27+
28+
type ExperimentWhitelistServiceTestSuite struct {
29+
suite.Suite
30+
mockConfig *mockProjectConfig
31+
whitelistService *ExperimentWhitelistService
32+
}
33+
34+
func (s *ExperimentWhitelistServiceTestSuite) SetupTest() {
35+
s.mockConfig = new(mockProjectConfig)
36+
s.whitelistService = NewExperimentWhitelistService()
37+
}
38+
39+
func (s *ExperimentWhitelistServiceTestSuite) TestWhitelistIncludesDecision() {
40+
testDecisionContext := ExperimentDecisionContext{
41+
Experiment: &testExpWhitelist,
42+
ProjectConfig: s.mockConfig,
43+
}
44+
45+
testUserContext := entities.UserContext{
46+
ID: "test_user_1",
47+
}
48+
49+
decision, err := s.whitelistService.GetDecision(testDecisionContext, testUserContext)
50+
51+
s.NoError(err)
52+
s.NotNil(decision.Variation)
53+
}
54+
55+
func (s *ExperimentWhitelistServiceTestSuite) TestNoUserEntryInWhitelist() {
56+
testDecisionContext := ExperimentDecisionContext{
57+
Experiment: &testExpWhitelist,
58+
ProjectConfig: s.mockConfig,
59+
}
60+
61+
// user context has test_user_3, but there's only a whitelist entry for test_user_1 and test_user_2
62+
testUserContext := entities.UserContext{
63+
ID: "test_user_3",
64+
}
65+
66+
decision, err := s.whitelistService.GetDecision(testDecisionContext, testUserContext)
67+
68+
s.NoError(err)
69+
s.Nil(decision.Variation)
70+
s.Exactly(decision.Reason, reasons.NoWhitelistVariationAssignment)
71+
}
72+
73+
func (s *ExperimentWhitelistServiceTestSuite) TestEmptyWhitelist() {
74+
testDecisionContext := ExperimentDecisionContext{
75+
// testExp1111 has no whitelist
76+
Experiment: &testExp1111,
77+
ProjectConfig: s.mockConfig,
78+
}
79+
80+
testUserContext := entities.UserContext{
81+
ID: "test_user_1",
82+
}
83+
84+
decision, err := s.whitelistService.GetDecision(testDecisionContext, testUserContext)
85+
86+
s.NoError(err)
87+
s.Nil(decision.Variation)
88+
s.Exactly(decision.Reason, reasons.NoWhitelistVariationAssignment)
89+
}
90+
91+
func (s *ExperimentWhitelistServiceTestSuite) TestInvalidVariationInUserEntry() {
92+
testDecisionContext := ExperimentDecisionContext{
93+
Experiment: &testExpWhitelist,
94+
ProjectConfig: s.mockConfig,
95+
}
96+
97+
testUserContext := entities.UserContext{
98+
// In the whitelist, test_user_2 is mapped to an invalid variation key (no variation with that key exists in the experiment)
99+
ID: "test_user_2",
100+
}
101+
102+
decision, err := s.whitelistService.GetDecision(testDecisionContext, testUserContext)
103+
104+
s.NoError(err)
105+
s.Nil(decision.Variation)
106+
s.Exactly(decision.Reason, reasons.InvalidWhitelistVariationAssignment)
107+
}
108+
109+
func (s *ExperimentWhitelistServiceTestSuite) TestNoExperimentInDecisionContext() {
110+
testDecisionContext := ExperimentDecisionContext{
111+
Experiment: nil,
112+
ProjectConfig: s.mockConfig,
113+
}
114+
115+
testUserContext := entities.UserContext{
116+
ID: "test_user_1",
117+
}
118+
119+
decision, err := s.whitelistService.GetDecision(testDecisionContext, testUserContext)
120+
121+
s.Error(err)
122+
s.Nil(decision.Variation)
123+
}
124+
125+
func TestExperimentWhitelistTestSuite(t *testing.T) {
126+
suite.Run(t, new(ExperimentWhitelistServiceTestSuite))
127+
}

optimizely/decision/helpers_test.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,32 @@ const testTargetedExp1116Key = "test_targeted_experiment_1116"
213213
var testTargetedExp1116Var2228 = entities.Variation{ID: "2228", Key: "2228"}
214214
var testTargetedExp1116 = entities.Experiment{
215215
AudienceConditionTree: &entities.TreeNode{Operator: "or", Item: "7771"},
216-
ID: "1116",
217-
Key: testTargetedExp1116Key,
216+
ID: "1116",
217+
Key: testTargetedExp1116Key,
218218
Variations: map[string]entities.Variation{
219219
"2228": testTargetedExp1116Var2228,
220220
},
221221
TrafficAllocation: []entities.Range{
222222
entities.Range{EntityID: "2228", EndOfRange: 10000},
223223
},
224224
}
225+
226+
// Experiment with a whitelist
227+
const testExpWhitelistKey = "test_experiment_whitelist"
228+
229+
var testExpWhitelistVar2229 = entities.Variation{ID: "2229", Key: "2229"}
230+
var testExpWhitelist = entities.Experiment{
231+
ID: "1117",
232+
Key: testExpWhitelistKey,
233+
Variations: map[string]entities.Variation{
234+
"2229": testExpWhitelistVar2229,
235+
},
236+
TrafficAllocation: []entities.Range{
237+
entities.Range{EntityID: "2229", EndOfRange: 10000},
238+
},
239+
Whitelist: map[string]string{
240+
"test_user_1": "2229",
241+
// Note: this is an invalid entry, there is no variation 2230 in this experiment
242+
"test_user_2": "2230",
243+
},
244+
}

optimizely/decision/reasons/reason.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,10 @@ const (
3737
NotBucketedIntoVariation Reason = "Not bucketed into a variation"
3838
// NotInGroup - the user is not bucketed into the mutex group
3939
NotInGroup Reason = "Not bucketed into any experiment in mutex group"
40+
// NoWhitelistVariationAssignment - there is no variation assignment for the given user and experiment
41+
NoWhitelistVariationAssignment Reason = "No whitelist variation assignment"
42+
// InvalidWhitelistVariationAssignment - A variation assignment was found for the given user and experiment, but no variation with that key exists in the given experiment
43+
InvalidWhitelistVariationAssignment Reason = "Invalid whitelist variation assignment"
44+
// WhitelistVariationAssignmentFound - a valid variation assignment was found for the given user and experiment
45+
WhitelistVariationAssignmentFound Reason = "Whitelist variation assignment found"
4046
)

optimizely/entities/experiment.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type Experiment struct {
3535
TrafficAllocation []Range
3636
GroupID string
3737
AudienceConditionTree *TreeNode
38+
Whitelist map[string]string
3839
}
3940

4041
// Range represents bucketing range that the specify entityID falls into

0 commit comments

Comments
 (0)