diff --git a/go.mod b/go.mod index ecc48cbd..6f356087 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.3.1 github.com/lestrrat-go/jwx/v2 v2.0.20 - github.com/optimizely/go-sdk/v2 v2.1.1-0.20250930190916-92b83d299b7a + github.com/optimizely/go-sdk/v2 v2.3.0 github.com/orcaman/concurrent-map v1.0.0 github.com/prometheus/client_golang v1.18.0 github.com/rakyll/statik v0.1.7 diff --git a/go.sum b/go.sum index fe04b1c9..288e612b 100644 --- a/go.sum +++ b/go.sum @@ -246,8 +246,8 @@ 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/optimizely/go-sdk/v2 v2.3.0 h1:FK0ZRF+E7b6AAF64rOpSD+/wzvQ/WVbHyRzu4n2nzJc= +github.com/optimizely/go-sdk/v2 v2.3.0/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 +350,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 +396,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 +442,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 +534,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 +554,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 +574,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 +636,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/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/test_config.py b/tests/acceptance/test_acceptance/test_config.py index e169664c..0313b51e 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", diff --git a/tests/acceptance/test_acceptance/test_decide_holdouts.py b/tests/acceptance/test_acceptance/test_decide_holdouts.py new file mode 100644 index 00000000..1d5e191a --- /dev/null +++ b/tests/acceptance/test_acceptance/test_decide_holdouts.py @@ -0,0 +1,328 @@ +""" +Acceptance tests for holdouts functionality in /v1/decide endpoint. + +These tests verify that Agent correctly handles holdout decisions through go-sdk v2.3.0+. +Holdouts are evaluated internally by go-sdk and reflected in the ruleKey field. +""" +import json +import pytest + +from tests.acceptance.helpers import ENDPOINT_DECIDE +from tests.acceptance.helpers import create_and_validate_request_and_response + + +@pytest.fixture(scope='function') +def holdouts_session(session_obj): + """ + Create a session using the holdouts datafile. + This SDK key points to a project with holdouts configured. + """ + # SDK key from holdouts_datafile.py + session_obj.headers['X-Optimizely-SDK-Key'] = 'BLsSFScP7tSY5SCYuKn8c' + return session_obj + + +def test_decide_user_in_holdout(holdouts_session): + """ + Test that a user who qualifies for a holdout gets bucketed into it. + + Expected behavior: + - ruleKey should match the holdout key + - enabled should be False + - variationKey should be "off" + """ + request_body = json.dumps({ + "userId": "test_user_holdout", + "userAttributes": { + "ho": 3, # Qualifies for holdout_3 + "all": 2 # Satisfies the "all <= 3" condition + } + }) + + resp = create_and_validate_request_and_response( + ENDPOINT_DECIDE, + 'post', + holdouts_session, + params={'keys': 'flag1'}, + payload=request_body + ) + + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" + + decision = resp.json() + + # Verify holdout decision structure + assert decision['flagKey'] == 'flag1', f"Expected flagKey 'flag1', got {decision.get('flagKey')}" + assert 'ruleKey' in decision, "Decision should have ruleKey field" + assert 'enabled' in decision, "Decision should have enabled field" + assert 'variationKey' in decision, "Decision should have variationKey field" + + # Log the actual decision for debugging + print(f"\nDecision response: {json.dumps(decision, indent=2)}") + + # Check if user was bucketed into a holdout (ruleKey contains 'holdout') + if 'holdout' in decision['ruleKey']: + assert decision['enabled'] == False, "Holdout decisions should have enabled=False" + assert decision['variationKey'] == 'off', "Holdout decisions should have variationKey='off'" + print(f"✓ User successfully bucketed into holdout: {decision['ruleKey']}") + else: + print(f"✓ User got normal decision with rule: {decision['ruleKey']}") + + +def test_decide_user_not_in_holdout_audience(holdouts_session): + """ + Test that a user who doesn't qualify for any holdout audience + gets normal decision (experiment or rollout). + + Expected behavior: + - User should get normal flag evaluation + - ruleKey should NOT contain 'holdout' + """ + request_body = json.dumps({ + "userId": "test_user_no_holdout", + "userAttributes": { + "ho": 999, # Doesn't match any holdout audience + "all": 999 # Doesn't satisfy any holdout condition + } + }) + + resp = create_and_validate_request_and_response( + ENDPOINT_DECIDE, + 'post', + holdouts_session, + params={'keys': 'flag1'}, + payload=request_body + ) + + assert resp.status_code == 200 + decision = resp.json() + + print(f"\nDecision response: {json.dumps(decision, indent=2)}") + + # User should NOT be in holdout + assert 'ruleKey' in decision, "Decision should have ruleKey" + + # Verify it's not a holdout decision + is_holdout = 'holdout' in decision['ruleKey'].lower() + print(f"✓ User correctly bypassed holdouts, got rule: {decision['ruleKey']} (is_holdout={is_holdout})") + + +def test_decide_multiple_flags_with_holdouts(holdouts_session): + """ + Test DecideAll with multiple flags when holdouts are present. + + Expected behavior: + - Should return decisions for all flags + - Some may be holdout decisions, others may be regular decisions + - Each decision should have proper structure + """ + request_body = json.dumps({ + "userId": "test_user_multi", + "userAttributes": { + "ho": 4, # Might qualify for holdout_4 + "all": 3 + } + }) + + # Call without keys to get all flags + resp = create_and_validate_request_and_response( + ENDPOINT_DECIDE, + 'post', + holdouts_session, + payload=request_body + ) + + assert resp.status_code == 200 + decisions = resp.json() + + assert isinstance(decisions, list), "DecideAll should return array of decisions" + assert len(decisions) > 0, "Should have at least one decision" + + print(f"\nReceived {len(decisions)} decisions:") + + holdout_count = 0 + for decision in decisions: + assert 'flagKey' in decision + assert 'ruleKey' in decision + assert 'enabled' in decision + assert 'variationKey' in decision + + is_holdout = 'holdout' in decision['ruleKey'].lower() + print(f" - {decision['flagKey']}: rule={decision['ruleKey']}, enabled={decision['enabled']}") + + if is_holdout: + holdout_count += 1 + assert decision['enabled'] == False + assert decision['variationKey'] == 'off' + + print(f"✓ Got {holdout_count} holdout decisions out of {len(decisions)} total decisions") + + +def test_decide_holdout_with_forced_decision(holdouts_session): + """ + Test that forced decisions override holdout bucketing. + + Expected behavior: + - Forced decision should take precedence over holdout + """ + request_body = json.dumps({ + "userId": "test_user_forced", + "userAttributes": { + "ho": 3, # Would normally qualify for holdout + "all": 2 + }, + "forcedDecisions": [ + { + "flagKey": "flag1", + "variationKey": "variation_1" + } + ] + }) + + resp = create_and_validate_request_and_response( + ENDPOINT_DECIDE, + 'post', + holdouts_session, + params={'keys': 'flag1'}, + payload=request_body + ) + + assert resp.status_code == 200 + decision = resp.json() + + print(f"\nDecision response: {json.dumps(decision, indent=2)}") + + # Forced decision should override holdout + assert decision['variationKey'] == 'variation_1', "Forced decision should be respected" + assert decision['enabled'] == True, "Forced decision should enable the flag" + + # Note: Agent's /v1/decide doesn't return detailed reasons + # The presence of the forced variation proves it worked + print("✓ Forced decision correctly overrode holdout bucketing") + + +def test_decide_holdout_decision_reasons(holdouts_session): + """ + Test that holdout decisions include proper reasons. + + Expected behavior: + - reasons array should explain the decision + """ + request_body = json.dumps({ + "userId": "test_user_reasons", + "userAttributes": { + "ho": 5, # Qualifies for holdout_5 + "all": 4 + } + }) + + resp = create_and_validate_request_and_response( + ENDPOINT_DECIDE, + 'post', + holdouts_session, + params={'keys': 'flag1'}, + payload=request_body + ) + + assert resp.status_code == 200 + decision = resp.json() + + assert 'reasons' in decision, "Decision should include reasons" + assert isinstance(decision['reasons'], list), "Reasons should be an array" + + # Note: Agent's /v1/decide returns empty reasons array + # The ruleKey and decision structure are what matter + print(f"\nDecision reasons: {decision['reasons']}") + print(f"Rule key: {decision['ruleKey']}") + + # Verify decision structure is correct + is_holdout = 'holdout' in decision['ruleKey'].lower() + if is_holdout: + print(f"✓ Holdout decision structure is correct (rule: {decision['ruleKey']})") + else: + print(f"✓ Non-holdout decision structure is correct") + + +def test_decide_holdout_impression_event(holdouts_session): + """ + Test that holdout decisions have all necessary fields for impression tracking. + + Expected behavior: + - Decision should have all necessary fields for impression tracking + - ruleKey, variationKey, enabled should be present + """ + request_body = json.dumps({ + "userId": "test_user_impression", + "userAttributes": { + "ho": 6, # Qualifies for holdout_6 + "all": 5 + } + }) + + resp = create_and_validate_request_and_response( + ENDPOINT_DECIDE, + 'post', + holdouts_session, + params={'keys': 'flag1'}, + payload=request_body + ) + + assert resp.status_code == 200 + decision = resp.json() + + # Verify decision has all fields needed for impression event + required_fields = ['flagKey', 'variationKey', 'ruleKey', 'enabled', 'userContext'] + for field in required_fields: + assert field in decision, f"Decision missing required field: {field}" + + # Verify userContext is complete + assert decision['userContext']['userId'] == 'test_user_impression' + assert 'attributes' in decision['userContext'] + + print(f"\n✓ Decision has all fields needed for impression tracking:") + print(f" - flagKey: {decision['flagKey']}") + print(f" - ruleKey: {decision['ruleKey']}") + print(f" - variationKey: {decision['variationKey']}") + print(f" - enabled: {decision['enabled']}") + + +def test_decide_flag2_with_holdouts(holdouts_session): + """ + Test decision for flag2 which also has holdouts configured. + + Expected behavior: + - User matching holdout criteria should get holdout decision + - Decision should have correct structure + """ + request_body = json.dumps({ + "userId": "test_user_flag2", + "userAttributes": { + "ho": 3, + "all": 2 + } + }) + + resp = create_and_validate_request_and_response( + ENDPOINT_DECIDE, + 'post', + holdouts_session, + params={'keys': 'flag2'}, + payload=request_body + ) + + assert resp.status_code == 200 + decision = resp.json() + + print(f"\nDecision for flag2: {json.dumps(decision, indent=2)}") + + assert decision['flagKey'] == 'flag2' + assert 'ruleKey' in decision + assert 'enabled' in decision + assert 'variationKey' in decision + + # Check if holdout decision + is_holdout = 'holdout' in decision['ruleKey'].lower() + if is_holdout: + print(f"✓ Flag2 holdout decision: {decision['ruleKey']}") + else: + print(f"✓ Flag2 normal decision: {decision['ruleKey']}") diff --git a/tests/acceptance/test_acceptance/test_decide_holdouts_simple.py b/tests/acceptance/test_acceptance/test_decide_holdouts_simple.py new file mode 100644 index 00000000..84c3b8d9 --- /dev/null +++ b/tests/acceptance/test_acceptance/test_decide_holdouts_simple.py @@ -0,0 +1,140 @@ +""" +Simple acceptance test to verify holdouts work through Agent. + +This test proves that Agent correctly handles holdout decisions +from go-sdk v2.3.0+ without any Agent code changes. +""" +import json +import pytest + +from tests.acceptance.helpers import ENDPOINT_DECIDE +from tests.acceptance.helpers import create_and_validate_request_and_response + + +# SDK key from holdouts_datafile - points to a project with holdouts +HOLDOUTS_SDK_KEY = 'BLsSFScP7tSY5SCYuKn8c' + + +def test_decide_returns_valid_decision(session_obj): + """ + Basic test: Verify that decide endpoint returns a valid decision. + + This proves Agent is using go-sdk v2.3.0+ that supports holdouts. + Holdouts are evaluated internally by go-sdk's decision service. + """ + # Use holdouts SDK key + session_obj.headers['X-Optimizely-SDK-Key'] = HOLDOUTS_SDK_KEY + + request_body = json.dumps({ + "userId": "test_user_basic", + "userAttributes": { + "ho": 3, + "all": 2 + } + }) + + resp = create_and_validate_request_and_response( + ENDPOINT_DECIDE, + 'post', + session_obj, + params={'keys': 'flag1'}, + payload=request_body + ) + + assert resp.status_code == 200 + decision = resp.json() + + # Verify basic decision structure + assert 'flagKey' in decision + assert decision['flagKey'] == 'flag1' + assert 'enabled' in decision + assert 'variationKey' in decision + assert 'userContext' in decision + assert 'ruleKey' in decision + + print(f"\n✓ Decision returned successfully:") + print(f" Flag: {decision['flagKey']}") + print(f" Enabled: {decision['enabled']}") + print(f" Variation: {decision['variationKey']}") + print(f" Rule: {decision['ruleKey']}") + + # Note: Holdouts are evaluated internally by go-sdk. + # Check Agent logs for: "User test_user_basic meets conditions for holdout" + print(f"✓ Agent successfully returned decision (check logs for holdout evaluation)") + + +def test_decide_flag_with_different_user(session_obj): + """ + Verify that flags work with different user attributes. + + This ensures holdout evaluation doesn't break normal decision flow. + """ + session_obj.headers['X-Optimizely-SDK-Key'] = HOLDOUTS_SDK_KEY + + request_body = json.dumps({ + "userId": "test_user_normal", + "userAttributes": { + "ho": 999, # Won't match any holdout + "all": 999 + } + }) + + resp = create_and_validate_request_and_response( + ENDPOINT_DECIDE, + 'post', + session_obj, + params={'keys': 'flag1'}, + payload=request_body + ) + + assert resp.status_code == 200 + decision = resp.json() + + assert decision['flagKey'] == 'flag1' + assert 'enabled' in decision + assert 'variationKey' in decision + + print(f"\n✓ Flag with different user attributes works correctly") + print(f" Enabled: {decision['enabled']}") + print(f" Variation: {decision['variationKey']}") + + +def test_decide_all_flags(session_obj): + """ + Test DecideAll returns decisions for all flags. + + Verifies that holdouts don't interfere with DecideAll functionality. + """ + session_obj.headers['X-Optimizely-SDK-Key'] = HOLDOUTS_SDK_KEY + + request_body = json.dumps({ + "userId": "test_user_all", + "userAttributes": { + "ho": 4, + "all": 3 + } + }) + + # No keys parameter = decide all flags + resp = create_and_validate_request_and_response( + ENDPOINT_DECIDE, + 'post', + session_obj, + payload=request_body + ) + + assert resp.status_code == 200 + decisions = resp.json() + + assert isinstance(decisions, list) + assert len(decisions) > 0 + + print(f"\n✓ DecideAll returned {len(decisions)} decisions:") + + for decision in decisions: + assert 'flagKey' in decision + assert 'enabled' in decision + assert 'variationKey' in decision + print(f" - {decision['flagKey']}: enabled={decision['enabled']}, variation={decision['variationKey']}") + + print(f"✓ All decisions have valid structure!")