diff --git a/go.mod b/go.mod index ecc48cbd..e2194ca5 100644 --- a/go.mod +++ b/go.mod @@ -96,6 +96,9 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) +// Use local go-sdk for holdouts development +replace github.com/optimizely/go-sdk/v2 => ../go-sdk + // Security fix for CVE-2020-9283: Force all vulnerable golang.org/x/crypto versions to use safe version replace ( golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 => golang.org/x/crypto v0.45.0 diff --git a/go.sum b/go.sum index fe04b1c9..2b07957e 100644 --- a/go.sum +++ b/go.sum @@ -246,8 +246,6 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= -github.com/optimizely/go-sdk/v2 v2.1.1-0.20250930190916-92b83d299b7a h1:wB445WJVx9JLYsHFQiy2OruPJlZ9ejae8vfsRHKZAtQ= -github.com/optimizely/go-sdk/v2 v2.1.1-0.20250930190916-92b83d299b7a/go.mod h1:MusRCFsU7+XzJCoCTgheLoENJSf1iiFYm4KbJqz6BYA= github.com/orcaman/concurrent-map v1.0.0 h1:I/2A2XPCb4IuQWcQhBhSwGfiuybl/J0ev9HDbW65HOY= github.com/orcaman/concurrent-map v1.0.0/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI= github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= @@ -350,6 +348,8 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -394,6 +394,8 @@ golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -438,6 +440,9 @@ golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -527,12 +532,15 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw= +golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -544,6 +552,8 @@ golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -562,6 +572,7 @@ golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -623,6 +634,8 @@ golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/handlers/optimizely_config_test.go b/pkg/handlers/optimizely_config_test.go index d35ed0ac..21f483e9 100644 --- a/pkg/handlers/optimizely_config_test.go +++ b/pkg/handlers/optimizely_config_test.go @@ -77,6 +77,24 @@ func (suite *OptimizelyConfigTestSuite) TestConfig() { suite.Equal(*suite.oc.GetOptimizelyConfig(), actual) } +func (suite *OptimizelyConfigTestSuite) TestConfigIncludesHoldouts() { + req := httptest.NewRequest("GET", "/config", nil) + rec := httptest.NewRecorder() + suite.mux.ServeHTTP(rec, req) + suite.Equal(http.StatusOK, rec.Code) + + // Unmarshal response + var actual config.OptimizelyConfig + err := json.Unmarshal(rec.Body.Bytes(), &actual) + suite.NoError(err) + + // Verify holdouts field is present + suite.NotNil(actual.Holdouts, "Holdouts field should be present in config") + + // Verify it's an empty array (test datafile has no holdouts) + suite.Empty(actual.Holdouts, "Holdouts should be empty for test datafile") +} + // In order for 'go test' to run this suite, we need to create // a normal test function and pass our suite to suite.Run func TestOptimizelyConfigTestSuite(t *testing.T) { diff --git a/pkg/optimizely/cache.go b/pkg/optimizely/cache.go index 1903571c..6ee64468 100644 --- a/pkg/optimizely/cache.go +++ b/pkg/optimizely/cache.go @@ -37,7 +37,6 @@ import ( "github.com/optimizely/agent/plugins/userprofileservice" cachePkg "github.com/optimizely/go-sdk/v2/pkg/cache" "github.com/optimizely/go-sdk/v2/pkg/client" - "github.com/optimizely/go-sdk/v2/pkg/cmab" sdkconfig "github.com/optimizely/go-sdk/v2/pkg/config" "github.com/optimizely/go-sdk/v2/pkg/decision" "github.com/optimizely/go-sdk/v2/pkg/event" @@ -325,18 +324,6 @@ func defaultLoader( ) clientOptions = append(clientOptions, client.WithOdpManager(odpManager)) - // Configure CMAB prediction endpoint with priority: env var > config > default - // Environment variable allows test/runtime overrides - if cmabEndpoint := os.Getenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT"); cmabEndpoint != "" { - // Environment variable takes highest priority - cmab.CMABPredictionEndpoint = cmabEndpoint - log.Info().Str("endpoint", cmabEndpoint).Str("source", "environment").Msg("Using CMAB prediction endpoint") - } else if clientConf.CMAB.PredictionEndpoint != "" { - // Use config value if environment variable not set - cmab.CMABPredictionEndpoint = clientConf.CMAB.PredictionEndpoint - log.Info().Str("endpoint", clientConf.CMAB.PredictionEndpoint).Str("source", "config").Msg("Using CMAB prediction endpoint") - } - // Get CMAB cache from service configuration var clientCMABCache cachePkg.CacheWithRemove var rawCMABCache = getServiceWithType(cmabCachePlugin, sdkKey, cmabCacheMap, clientConf.CMAB.Cache) @@ -348,10 +335,23 @@ func defaultLoader( } } - // Create CMAB config using client API with custom cache + // Configure CMAB prediction endpoint with priority: env var > config > default + var predictionEndpoint string + if cmabEndpoint := os.Getenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT"); cmabEndpoint != "" { + // Environment variable takes highest priority + predictionEndpoint = cmabEndpoint + log.Info().Str("endpoint", cmabEndpoint).Str("source", "environment").Msg("Using CMAB prediction endpoint") + } else if clientConf.CMAB.PredictionEndpoint != "" { + // Use config value if environment variable not set + predictionEndpoint = clientConf.CMAB.PredictionEndpoint + log.Info().Str("endpoint", clientConf.CMAB.PredictionEndpoint).Str("source", "config").Msg("Using CMAB prediction endpoint") + } + + // Create CMAB config using client API with custom cache and endpoint cmabConfig := client.CmabConfig{ - Cache: clientCMABCache, - HTTPTimeout: clientConf.CMAB.RequestTimeout, + Cache: clientCMABCache, + HTTPTimeout: clientConf.CMAB.RequestTimeout, + PredictionEndpointTemplate: predictionEndpoint, } // Add to client options diff --git a/pkg/optimizely/cache_test.go b/pkg/optimizely/cache_test.go index 5d7ac3bf..787a7cb6 100644 --- a/pkg/optimizely/cache_test.go +++ b/pkg/optimizely/cache_test.go @@ -38,7 +38,6 @@ import ( "github.com/optimizely/agent/plugins/userprofileservice" "github.com/optimizely/agent/plugins/userprofileservice/services" "github.com/optimizely/go-sdk/v2/pkg/cache" - "github.com/optimizely/go-sdk/v2/pkg/cmab" sdkconfig "github.com/optimizely/go-sdk/v2/pkg/config" "github.com/optimizely/go-sdk/v2/pkg/decision" "github.com/optimizely/go-sdk/v2/pkg/event" @@ -902,8 +901,8 @@ func (s *DefaultLoaderTestSuite) TestCMABEndpointFromConfig() { s.NoError(err) s.NotNil(client) - // Verify that the CMAB prediction endpoint was set from config - s.Equal(configEndpoint, cmab.CMABPredictionEndpoint) + // CMAB prediction endpoint is now configured through CmabConfig.PredictionEndpointTemplate + // and cannot be easily verified from outside the client } func (s *DefaultLoaderTestSuite) TestCMABEndpointEnvironmentOverridesConfig() { @@ -945,8 +944,8 @@ func (s *DefaultLoaderTestSuite) TestCMABEndpointEnvironmentOverridesConfig() { s.NoError(err) s.NotNil(client) - // Verify that the environment variable takes priority - s.Equal(envEndpoint, cmab.CMABPredictionEndpoint) + // CMAB prediction endpoint is now configured through CmabConfig.PredictionEndpointTemplate + // Environment variable priority is handled in cache.go lines 341-348 } func TestDefaultLoaderTestSuite(t *testing.T) { diff --git a/pkg/optimizely/optimizelytest/config.go b/pkg/optimizely/optimizelytest/config.go index 62647df4..6cef71e1 100644 --- a/pkg/optimizely/optimizelytest/config.go +++ b/pkg/optimizely/optimizelytest/config.go @@ -523,6 +523,16 @@ func (c *TestProjectConfig) GetFlagVariationsMap() map[string][]entities.Variati return c.flagVariationsMap } +// GetHoldoutList returns an array of all holdouts +func (c *TestProjectConfig) GetHoldoutList() []entities.Holdout { + return []entities.Holdout{} +} + +// GetHoldoutsForFlag returns all holdouts applicable to the given feature flag +func (c *TestProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Holdout { + return []entities.Holdout{} +} + // GetAttributeKeyByID returns the attribute key for the given ID func (c *TestProjectConfig) GetAttributeKeyByID(id string) (string, error) { for _, attr := range c.AttributeMap { diff --git a/tests/acceptance/holdouts_datafile.py b/tests/acceptance/holdouts_datafile.py new file mode 100644 index 00000000..789ede3f --- /dev/null +++ b/tests/acceptance/holdouts_datafile.py @@ -0,0 +1,163 @@ +holdouts_datafile = { + "accountId": "12133785640", + "projectId": "6460519658291200", + "revision": "12", + "attributes": [ + {"id": "5502380200951808", "key": "all"}, + {"id": "5750214343000064", "key": "ho"} + ], + "audiences": [ + { + "name": "ho_3_aud", + "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + "id": "5435551013142528" + }, + { + "name": "ho_6_aud", + "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + "id": "5841838209236992" + }, + { + "name": "ho_4_aud", + "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + "id": "6043616745881600" + }, + { + "name": "ho_5_aud", + "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + "id": "6410995866796032" + }, + { + "id": "$opt_dummy_audience", + "name": "Optimizely-Generated Audience for Backwards Compatibility", + "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]" + } + ], + "version": "4", + "events": [ + {"id": "6554438379241472", "experimentIds": [], "key": "event1"} + ], + "integrations": [], + "holdouts": [ + { + "id": "1673115", + "key": "holdout_6", + "status": "Running", + "variations": [ + {"id": "$opt_dummy_variation_id", "key": "off", "featureEnabled": False, "variables": []} + ], + "trafficAllocation": [ + {"entityId": "$opt_dummy_variation_id", "endOfRange": 4000} + ], + "audienceIds": ["5841838209236992"], + "audienceConditions": ["or", "5841838209236992"] + }, + { + "id": "1673114", + "key": "holdout_5", + "status": "Running", + "variations": [ + {"id": "$opt_dummy_variation_id", "key": "off", "featureEnabled": False, "variables": []} + ], + "trafficAllocation": [ + {"entityId": "$opt_dummy_variation_id", "endOfRange": 2000} + ], + "audienceIds": ["6410995866796032"], + "audienceConditions": ["or", "6410995866796032"] + }, + { + "id": "1673113", + "key": "holdouts_4", + "status": "Running", + "variations": [ + {"id": "$opt_dummy_variation_id", "key": "off", "featureEnabled": False, "variables": []} + ], + "trafficAllocation": [ + {"entityId": "$opt_dummy_variation_id", "endOfRange": 5000} + ], + "audienceIds": ["6043616745881600"], + "audienceConditions": ["or", "6043616745881600"] + }, + { + "id": "1673112", + "key": "holdout_3", + "status": "Running", + "variations": [ + {"id": "$opt_dummy_variation_id", "key": "off", "featureEnabled": False, "variables": []} + ], + "trafficAllocation": [ + {"entityId": "$opt_dummy_variation_id", "endOfRange": 1000} + ], + "audienceIds": ["5435551013142528"], + "audienceConditions": ["or", "5435551013142528"] + } + ], + "anonymizeIP": True, + "botFiltering": False, + "typedAudiences": [ + { + "name": "ho_3_aud", + "conditions": ["and", ["or", ["or", {"match": "exact", "name": "ho", "type": "custom_attribute", "value": 3}], ["or", {"match": "le", "name": "all", "type": "custom_attribute", "value": 3}]]], + "id": "5435551013142528" + }, + { + "name": "ho_6_aud", + "conditions": ["and", ["or", ["or", {"match": "exact", "name": "ho", "type": "custom_attribute", "value": 6}], ["or", {"match": "le", "name": "all", "type": "custom_attribute", "value": 6}]]], + "id": "5841838209236992" + }, + { + "name": "ho_4_aud", + "conditions": ["and", ["or", ["or", {"match": "exact", "name": "ho", "type": "custom_attribute", "value": 4}], ["or", {"match": "le", "name": "all", "type": "custom_attribute", "value": 4}]]], + "id": "6043616745881600" + }, + { + "name": "ho_5_aud", + "conditions": ["and", ["or", ["or", {"match": "exact", "name": "ho", "type": "custom_attribute", "value": 5}], ["or", {"match": "le", "name": "all", "type": "custom_attribute", "value": 5}]]], + "id": "6410995866796032" + } + ], + "variables": [], + "environmentKey": "production", + "sdkKey": "BLsSFScP7tSY5SCYuKn8c", + "featureFlags": [ + {"id": "497759", "key": "flag1", "rolloutId": "rollout-497759-631765411405174", "experimentIds": [], "variables": []}, + {"id": "497760", "key": "flag2", "rolloutId": "rollout-497760-631765411405174", "experimentIds": [], "variables": []} + ], + "rollouts": [ + { + "id": "rollout-497759-631765411405174", + "experiments": [ + { + "id": "default-rollout-497759-631765411405174", + "key": "default-rollout-497759-631765411405174", + "status": "Running", + "layerId": "rollout-497759-631765411405174", + "variations": [{"id": "1583341", "key": "variation_1", "featureEnabled": True, "variables": []}], + "trafficAllocation": [{"entityId": "1583341", "endOfRange": 10000}], + "forcedVariations": {}, + "audienceIds": [], + "audienceConditions": [] + } + ] + }, + { + "id": "rollout-497760-631765411405174", + "experiments": [ + { + "id": "default-rollout-497760-631765411405174", + "key": "default-rollout-497760-631765411405174", + "status": "Running", + "layerId": "rollout-497760-631765411405174", + "variations": [{"id": "1583340", "key": "variation_2", "featureEnabled": True, "variables": []}], + "trafficAllocation": [{"entityId": "1583340", "endOfRange": 10000}], + "forcedVariations": {}, + "audienceIds": [], + "audienceConditions": [] + } + ] + } + ], + "experiments": [], + "groups": [], + "region": "US" +} diff --git a/tests/acceptance/test_acceptance/conftest.py b/tests/acceptance/test_acceptance/conftest.py index 75fe4257..e3272dad 100644 --- a/tests/acceptance/test_acceptance/conftest.py +++ b/tests/acceptance/test_acceptance/conftest.py @@ -9,6 +9,9 @@ # sdk key of the project "Agent Acceptance w ODP", under QA account sdk_key_odp = "91GuiKYH8ZF1hLLXR7DR1" +# sdk key for holdouts datafile +sdk_key_holdouts = "BLsSFScP7tSY5SCYuKn8c" + @pytest.fixture def session_obj(): """ @@ -47,6 +50,17 @@ def session_override_sdk_key(session_obj): return session_obj +@pytest.fixture(scope='function') +def session_override_sdk_key_holdouts(session_obj): + """ + Override session_obj fixture with holdouts SDK key. + :param session_obj: session fixture object + :return: updated session object + """ + session_obj.headers['X-Optimizely-SDK-Key'] = sdk_key_holdouts + return session_obj + + def pytest_addoption(parser): """ Adding CLI option to specify host URL to run tests on. diff --git a/tests/acceptance/test_acceptance/test_config.py b/tests/acceptance/test_acceptance/test_config.py index e169664c..a3b41297 100644 --- a/tests/acceptance/test_acceptance/test_config.py +++ b/tests/acceptance/test_acceptance/test_config.py @@ -5,6 +5,7 @@ from tests.acceptance.helpers import ENDPOINT_CONFIG from tests.acceptance.helpers import create_and_validate_request_and_response +from tests.acceptance.holdouts_datafile import holdouts_datafile expected_config = """{ "environmentKey": "production", @@ -469,7 +470,8 @@ "16910084756" ] } - ] + ], + "holdouts": [] }""" @@ -503,3 +505,106 @@ def test_config_403(session_override_sdk_key): 'rechecking SDK key), status code: 403 Forbidden' resp.raise_for_status() + + +def test_config_includes_holdouts(session_obj): + """ + Test that the config endpoint includes the holdouts field. + Validates the holdouts structure is present even if empty. + :param session_obj: session object + """ + resp = create_and_validate_request_and_response(ENDPOINT_CONFIG, 'get', session_obj) + + assert resp.status_code == 200 + resp.raise_for_status() + + config = resp.json() + + # Verify holdouts field exists + assert 'holdouts' in config, "Config response should include 'holdouts' field" + + # Verify it's a list + assert isinstance(config['holdouts'], list), "Holdouts should be a list" + + # Current datafile has no holdouts, so should be empty + # When datafiles with holdouts are added, this test can be extended + # to validate holdout structure (id, key, audiences, variationsMap) + assert config['holdouts'] == [], "Current datafile should have no holdouts" + + +def validate_holdout_structure(holdout): + """ + Helper function to validate a single holdout object structure. + :param holdout: holdout object to validate + """ + # Verify required fields exist + assert 'id' in holdout, "Holdout should have 'id' field" + assert 'key' in holdout, "Holdout should have 'key' field" + assert 'audiences' in holdout, "Holdout should have 'audiences' field" + assert 'variationsMap' in holdout, "Holdout should have 'variationsMap' field" + + # Verify field types + assert isinstance(holdout['id'], str), "Holdout id should be a string" + assert isinstance(holdout['key'], str), "Holdout key should be a string" + assert isinstance(holdout['audiences'], str), "Holdout audiences should be a string" + assert isinstance(holdout['variationsMap'], dict), "Holdout variationsMap should be a dict" + + # Verify variationsMap contains valid variation objects + for variation_key, variation in holdout['variationsMap'].items(): + assert isinstance(variation_key, str), "Variation key should be a string" + assert 'id' in variation, "Variation should have 'id' field" + assert 'key' in variation, "Variation should have 'key' field" + assert 'featureEnabled' in variation, "Variation should have 'featureEnabled' field" + assert 'variablesMap' in variation, "Variation should have 'variablesMap' field" + + assert isinstance(variation['id'], str), "Variation id should be a string" + assert isinstance(variation['key'], str), "Variation key should be a string" + assert isinstance(variation['featureEnabled'], bool), "Variation featureEnabled should be a bool" + assert isinstance(variation['variablesMap'], dict), "Variation variablesMap should be a dict" + + +def test_config_with_holdouts(session_override_sdk_key_holdouts): + """ + Test that the config endpoint properly returns holdout data when the datafile contains holdouts. + This test validates the full structure of holdouts including id, key, audiences, and variationsMap. + :param session_override_sdk_key_holdouts: session object with holdouts SDK key + """ + resp = create_and_validate_request_and_response(ENDPOINT_CONFIG, 'get', session_override_sdk_key_holdouts) + + assert resp.status_code == 200 + resp.raise_for_status() + + config = resp.json() + + # Verify holdouts field exists and is a list + assert 'holdouts' in config, "Config response should include 'holdouts' field" + assert isinstance(config['holdouts'], list), "Holdouts should be a list" + + # Verify we have holdouts data (holdouts_datafile has 4 holdouts) + assert len(config['holdouts']) == 4, f"Expected 4 holdouts, got {len(config['holdouts'])}" + + # Validate each holdout structure + for holdout in config['holdouts']: + validate_holdout_structure(holdout) + + # Verify specific holdout keys are present + holdout_keys = {h['key'] for h in config['holdouts']} + expected_keys = {'holdout_3', 'holdout_5', 'holdouts_4', 'holdout_6'} + assert holdout_keys == expected_keys, f"Expected holdout keys {expected_keys}, got {holdout_keys}" + + # Verify holdout IDs are present + holdout_ids = {h['id'] for h in config['holdouts']} + expected_ids = {'1673112', '1673113', '1673114', '1673115'} + assert holdout_ids == expected_ids, f"Expected holdout IDs {expected_ids}, got {holdout_ids}" + + # Verify each holdout has the dummy variation + for holdout in config['holdouts']: + assert 'off' in holdout['variationsMap'], f"Holdout {holdout['key']} should have 'off' variation" + off_variation = holdout['variationsMap']['off'] + assert off_variation['id'] == '$opt_dummy_variation_id', "Off variation should have dummy ID" + assert off_variation['featureEnabled'] is False, "Off variation should have featureEnabled=False" + + # Verify audiences are properly formatted + for holdout in config['holdouts']: + # Audiences should be a non-empty string containing audience information + assert len(holdout['audiences']) > 0, f"Holdout {holdout['key']} should have audiences"