diff --git a/pkg/client/factory.go b/pkg/client/factory.go index e7e8dd1d..e4a59d53 100644 --- a/pkg/client/factory.go +++ b/pkg/client/factory.go @@ -406,6 +406,12 @@ func convertDecideOptions(options []decide.OptimizelyDecideOptions) *decide.Opti finalOptions.IncludeReasons = true case decide.ExcludeVariables: finalOptions.ExcludeVariables = true + case decide.IgnoreCMABCache: + finalOptions.IgnoreCMABCache = true + case decide.ResetCMABCache: + finalOptions.ResetCMABCache = true + case decide.InvalidateUserCMABCache: + finalOptions.InvalidateUserCMABCache = true } } return &finalOptions diff --git a/pkg/client/factory_test.go b/pkg/client/factory_test.go index f650ce72..fb6326a2 100644 --- a/pkg/client/factory_test.go +++ b/pkg/client/factory_test.go @@ -385,3 +385,52 @@ func TestOptimizelyClientWithNoTracer(t *testing.T) { tracer := optimizelyClient.tracer.(*tracing.NoopTracer) assert.NotNil(t, tracer) } + +func TestConvertDecideOptionsWithCMABOptions(t *testing.T) { + // Test with IgnoreCMABCache option + options := []decide.OptimizelyDecideOptions{decide.IgnoreCMABCache} + convertedOptions := convertDecideOptions(options) + assert.True(t, convertedOptions.IgnoreCMABCache) + assert.False(t, convertedOptions.ResetCMABCache) + assert.False(t, convertedOptions.InvalidateUserCMABCache) + + // Test with ResetCMABCache option + options = []decide.OptimizelyDecideOptions{decide.ResetCMABCache} + convertedOptions = convertDecideOptions(options) + assert.False(t, convertedOptions.IgnoreCMABCache) + assert.True(t, convertedOptions.ResetCMABCache) + assert.False(t, convertedOptions.InvalidateUserCMABCache) + + // Test with InvalidateUserCMABCache option + options = []decide.OptimizelyDecideOptions{decide.InvalidateUserCMABCache} + convertedOptions = convertDecideOptions(options) + assert.False(t, convertedOptions.IgnoreCMABCache) + assert.False(t, convertedOptions.ResetCMABCache) + assert.True(t, convertedOptions.InvalidateUserCMABCache) + + // Test with all CMAB options + options = []decide.OptimizelyDecideOptions{ + decide.IgnoreCMABCache, + decide.ResetCMABCache, + decide.InvalidateUserCMABCache, + } + convertedOptions = convertDecideOptions(options) + assert.True(t, convertedOptions.IgnoreCMABCache) + assert.True(t, convertedOptions.ResetCMABCache) + assert.True(t, convertedOptions.InvalidateUserCMABCache) + + // Test with CMAB options mixed with other options + options = []decide.OptimizelyDecideOptions{ + decide.DisableDecisionEvent, + decide.IgnoreCMABCache, + decide.EnabledFlagsOnly, + decide.ResetCMABCache, + decide.InvalidateUserCMABCache, + } + convertedOptions = convertDecideOptions(options) + assert.True(t, convertedOptions.DisableDecisionEvent) + assert.True(t, convertedOptions.EnabledFlagsOnly) + assert.True(t, convertedOptions.IgnoreCMABCache) + assert.True(t, convertedOptions.ResetCMABCache) + assert.True(t, convertedOptions.InvalidateUserCMABCache) +} diff --git a/pkg/config/datafileprojectconfig/config.go b/pkg/config/datafileprojectconfig/config.go index 2ed782dd..c55fda8c 100644 --- a/pkg/config/datafileprojectconfig/config.go +++ b/pkg/config/datafileprojectconfig/config.go @@ -247,6 +247,15 @@ func (c DatafileProjectConfig) GetExperimentByKey(experimentKey string) (entitie return entities.Experiment{}, fmt.Errorf(`experiment with key "%s" not found`, experimentKey) } +// GetExperimentByID returns the experiment with the given ID +func (c DatafileProjectConfig) GetExperimentByID(experimentID string) (entities.Experiment, error) { + if experiment, ok := c.experimentMap[experimentID]; ok { + return experiment, nil + } + + return entities.Experiment{}, fmt.Errorf(`experiment with ID "%s" not found`, experimentID) +} + // GetGroupByID returns the group with the given ID func (c DatafileProjectConfig) GetGroupByID(groupID string) (entities.Group, error) { if group, ok := c.groupMap[groupID]; ok { diff --git a/pkg/config/datafileprojectconfig/config_test.go b/pkg/config/datafileprojectconfig/config_test.go index 554c7f42..ec14fbcb 100644 --- a/pkg/config/datafileprojectconfig/config_test.go +++ b/pkg/config/datafileprojectconfig/config_test.go @@ -590,7 +590,7 @@ func TestCmabExperiments(t *testing.T) { experiments := datafileJSON["experiments"].([]interface{}) exp0 := experiments[0].(map[string]interface{}) exp0["cmab"] = map[string]interface{}{ - "attributes": []string{"808797688", "808797689"}, + "attributeIds": []string{"808797688", "808797689"}, "trafficAllocation": 5000, // Changed from array to integer } @@ -655,6 +655,34 @@ func TestCmabExperimentsNil(t *testing.T) { } } +func TestGetExperimentByID(t *testing.T) { + // Create a test config with some experiments + testConfig := DatafileProjectConfig{ + experimentMap: map[string]entities.Experiment{ + "exp1": {ID: "exp1", Key: "experiment_1"}, + "exp2": {ID: "exp2", Key: "experiment_2"}, + }, + } + + // Test getting an experiment that exists + experiment, err := testConfig.GetExperimentByID("exp1") + assert.NoError(t, err) + assert.Equal(t, "exp1", experiment.ID) + assert.Equal(t, "experiment_1", experiment.Key) + + // Test getting another experiment that exists + experiment, err = testConfig.GetExperimentByID("exp2") + assert.NoError(t, err) + assert.Equal(t, "exp2", experiment.ID) + assert.Equal(t, "experiment_2", experiment.Key) + + // Test getting an experiment that doesn't exist + experiment, err = testConfig.GetExperimentByID("non_existent") + assert.Error(t, err) + assert.Equal(t, `experiment with ID "non_existent" not found`, err.Error()) + assert.Equal(t, entities.Experiment{}, experiment) +} + func TestGetAttributeKeyByID(t *testing.T) { // Setup id := "id" diff --git a/pkg/config/datafileprojectconfig/entities/entities.go b/pkg/config/datafileprojectconfig/entities/entities.go index 7234cf7e..cecd0b02 100644 --- a/pkg/config/datafileprojectconfig/entities/entities.go +++ b/pkg/config/datafileprojectconfig/entities/entities.go @@ -36,7 +36,7 @@ type Attribute struct { // It contains a list of attribute IDs that are used for the CMAB algorithm and // traffic allocation settings for the CMAB implementation. type Cmab struct { - AttributeIds []string `json:"attributes"` + AttributeIds []string `json:"attributeIds"` TrafficAllocation int `json:"trafficAllocation"` } diff --git a/pkg/config/interface.go b/pkg/config/interface.go index e6ee9776..1c9f4736 100644 --- a/pkg/config/interface.go +++ b/pkg/config/interface.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2022, Optimizely, Inc. and contributors * + * Copyright 2019-2025, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -31,6 +31,8 @@ type ProjectConfig interface { GetAnonymizeIP() bool GetAttributeID(id string) string // returns "" if there is no id GetAttributeByKey(key string) (entities.Attribute, error) + GetAttributeKeyByID(id string) (string, error) // method is intended for internal use only + GetExperimentByID(id string) (entities.Experiment, error) // method is intended for internal use only GetAudienceList() (audienceList []entities.Audience) GetAudienceByID(string) (entities.Audience, error) GetAudienceMap() map[string]entities.Audience diff --git a/pkg/decide/decide_options.go b/pkg/decide/decide_options.go index cd50189d..8d3bc0d3 100644 --- a/pkg/decide/decide_options.go +++ b/pkg/decide/decide_options.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020-2021, Optimizely, Inc. and contributors * + * Copyright 2020-2025, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -33,6 +33,12 @@ const ( IncludeReasons OptimizelyDecideOptions = "INCLUDE_REASONS" // ExcludeVariables when set, excludes variable values from the decision result. ExcludeVariables OptimizelyDecideOptions = "EXCLUDE_VARIABLES" + // IgnoreCMABCache instructs the SDK to ignore the CMAB cache and make a fresh request + IgnoreCMABCache OptimizelyDecideOptions = "IGNORE_CMAB_CACHE" + // ResetCMABCache instructs the SDK to reset the entire CMAB cache + ResetCMABCache OptimizelyDecideOptions = "RESET_CMAB_CACHE" + // InvalidateUserCMABCache instructs the SDK to invalidate CMAB cache entries for the current user + InvalidateUserCMABCache OptimizelyDecideOptions = "INVALIDATE_USER_CMAB_CACHE" ) // Options defines options for controlling flag decisions. @@ -42,6 +48,9 @@ type Options struct { IgnoreUserProfileService bool IncludeReasons bool ExcludeVariables bool + IgnoreCMABCache bool + ResetCMABCache bool + InvalidateUserCMABCache bool } // TranslateOptions converts string options array to array of OptimizelyDecideOptions @@ -59,6 +68,12 @@ func TranslateOptions(options []string) ([]OptimizelyDecideOptions, error) { decideOptions = append(decideOptions, ExcludeVariables) case IncludeReasons: decideOptions = append(decideOptions, IncludeReasons) + case IgnoreCMABCache: + decideOptions = append(decideOptions, IgnoreCMABCache) + case ResetCMABCache: + decideOptions = append(decideOptions, ResetCMABCache) + case InvalidateUserCMABCache: + decideOptions = append(decideOptions, InvalidateUserCMABCache) default: return []OptimizelyDecideOptions{}, errors.New("invalid option: " + val) } diff --git a/pkg/decide/decide_options_test.go b/pkg/decide/decide_options_test.go index aebfca02..097074e2 100644 --- a/pkg/decide/decide_options_test.go +++ b/pkg/decide/decide_options_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2021, Optimizely, Inc. and contributors * + * Copyright 2021-2025, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -68,3 +68,47 @@ func TestTranslateOptionsInvalidCases(t *testing.T) { assert.Equal(t, fmt.Errorf("invalid option: %v", options[0]), err) assert.Len(t, translatedOptions, 0) } + +// TestTranslateOptionsCMABOptions tests the new CMAB-related options +func TestTranslateOptionsCMABOptions(t *testing.T) { + // Test IGNORE_CMAB_CACHE option + options := []string{"IGNORE_CMAB_CACHE"} + translatedOptions, err := TranslateOptions(options) + assert.NoError(t, err) + assert.Len(t, translatedOptions, 1) + assert.Equal(t, IgnoreCMABCache, translatedOptions[0]) + + // Test RESET_CMAB_CACHE option + options = []string{"RESET_CMAB_CACHE"} + translatedOptions, err = TranslateOptions(options) + assert.NoError(t, err) + assert.Len(t, translatedOptions, 1) + assert.Equal(t, ResetCMABCache, translatedOptions[0]) + + // Test INVALIDATE_USER_CMAB_CACHE option + options = []string{"INVALIDATE_USER_CMAB_CACHE"} + translatedOptions, err = TranslateOptions(options) + assert.NoError(t, err) + assert.Len(t, translatedOptions, 1) + assert.Equal(t, InvalidateUserCMABCache, translatedOptions[0]) + + // Test all CMAB options together + options = []string{"IGNORE_CMAB_CACHE", "RESET_CMAB_CACHE", "INVALIDATE_USER_CMAB_CACHE"} + translatedOptions, err = TranslateOptions(options) + assert.NoError(t, err) + assert.Len(t, translatedOptions, 3) + assert.Equal(t, IgnoreCMABCache, translatedOptions[0]) + assert.Equal(t, ResetCMABCache, translatedOptions[1]) + assert.Equal(t, InvalidateUserCMABCache, translatedOptions[2]) + + // Test CMAB options with other options + options = []string{"DISABLE_DECISION_EVENT", "IGNORE_CMAB_CACHE", "ENABLED_FLAGS_ONLY", "RESET_CMAB_CACHE", "INVALIDATE_USER_CMAB_CACHE"} + translatedOptions, err = TranslateOptions(options) + assert.NoError(t, err) + assert.Len(t, translatedOptions, 5) + assert.Equal(t, DisableDecisionEvent, translatedOptions[0]) + assert.Equal(t, IgnoreCMABCache, translatedOptions[1]) + assert.Equal(t, EnabledFlagsOnly, translatedOptions[2]) + assert.Equal(t, ResetCMABCache, translatedOptions[3]) + assert.Equal(t, InvalidateUserCMABCache, translatedOptions[4]) +} diff --git a/pkg/decision/cmab.go b/pkg/decision/cmab.go new file mode 100644 index 00000000..97d4ec81 --- /dev/null +++ b/pkg/decision/cmab.go @@ -0,0 +1,60 @@ +/**************************************************************************** + * Copyright 2025, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package decision provides CMAB decision service interfaces and types +package decision + +import ( + "github.com/optimizely/go-sdk/v2/pkg/config" + "github.com/optimizely/go-sdk/v2/pkg/decide" + "github.com/optimizely/go-sdk/v2/pkg/entities" +) + +// CmabDecision represents a decision from the CMAB service +type CmabDecision struct { + VariationID string + CmabUUID string + Reasons []string +} + +// CmabCacheValue represents a cached CMAB decision with attribute hash +type CmabCacheValue struct { + AttributesHash string + VariationID string + CmabUUID string +} + +// CmabService defines the interface for CMAB decision services +type CmabService interface { + // GetDecision returns a CMAB decision for the given rule and user context + GetDecision( + projectConfig config.ProjectConfig, + userContext entities.UserContext, + ruleID string, + options *decide.Options, + ) (CmabDecision, error) +} + +// CmabClient defines the interface for CMAB API clients +type CmabClient interface { + // FetchDecision fetches a decision from the CMAB API + FetchDecision( + ruleID string, + userID string, + attributes map[string]interface{}, + cmabUUID string, + ) (string, error) +} diff --git a/pkg/decision/cmab_service.go b/pkg/decision/cmab_service.go new file mode 100644 index 00000000..995170ba --- /dev/null +++ b/pkg/decision/cmab_service.go @@ -0,0 +1,273 @@ +/**************************************************************************** + * Copyright 2025, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package decision provides CMAB decision service implementation +package decision + +import ( + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/optimizely/go-sdk/v2/pkg/cache" + "github.com/optimizely/go-sdk/v2/pkg/config" + "github.com/optimizely/go-sdk/v2/pkg/decide" + "github.com/optimizely/go-sdk/v2/pkg/entities" + "github.com/optimizely/go-sdk/v2/pkg/logging" + "github.com/twmb/murmur3" +) + +// DefaultCmabService implements the CmabService interface +type DefaultCmabService struct { + cmabCache cache.CacheWithRemove + cmabClient CmabClient + logger logging.OptimizelyLogProducer +} + +// CmabServiceOptions defines options for creating a CMAB service +type CmabServiceOptions struct { + Logger logging.OptimizelyLogProducer + CmabCache cache.CacheWithRemove + CmabClient CmabClient +} + +// NewDefaultCmabService creates a new instance of DefaultCmabService +func NewDefaultCmabService(options CmabServiceOptions) *DefaultCmabService { + logger := options.Logger + if logger == nil { + logger = logging.GetLogger("", "DefaultCmabService") + } + + return &DefaultCmabService{ + cmabCache: options.CmabCache, + cmabClient: options.CmabClient, + logger: logger, + } +} + +// GetDecision returns a CMAB decision for the given rule and user context +func (s *DefaultCmabService) GetDecision( + projectConfig config.ProjectConfig, + userContext entities.UserContext, + ruleID string, + options *decide.Options, +) (CmabDecision, error) { + // Initialize reasons slice for decision + reasons := []string{} + + // Filter attributes based on CMAB configuration + filteredAttributes := s.filterAttributes(projectConfig, userContext, ruleID) + + // Check if we should ignore the cache + if options != nil && hasOption(options, decide.IgnoreCMABCache) { + reasons = append(reasons, "Ignoring CMAB cache as requested") + decision, err := s.fetchDecisionWithRetry(ruleID, userContext.ID, filteredAttributes) + if err != nil { + return CmabDecision{Reasons: reasons}, err + } + decision.Reasons = append(reasons, decision.Reasons...) + return decision, nil + } + + // Reset cache if requested + if options != nil && hasOption(options, decide.ResetCMABCache) { + s.cmabCache.Reset() + reasons = append(reasons, "Reset CMAB cache as requested") + } + + // Create cache key + cacheKey := s.getCacheKey(userContext.ID, ruleID) + + // Invalidate user cache if requested + if options != nil && hasOption(options, decide.InvalidateUserCMABCache) { + s.cmabCache.Remove(cacheKey) + reasons = append(reasons, "Invalidated user CMAB cache as requested") + } + + // Generate attributes hash for cache validation + attributesJSON, err := s.getAttributesJSON(filteredAttributes) + if err != nil { + reasons = append(reasons, fmt.Sprintf("Failed to serialize attributes: %v", err)) + return CmabDecision{Reasons: reasons}, fmt.Errorf("failed to serialize attributes: %w", err) + } + hasher := murmur3.SeedNew32(1) // Use seed 1 for consistency + _, err = hasher.Write([]byte(attributesJSON)) + if err != nil { + reasons = append(reasons, fmt.Sprintf("Failed to hash attributes: %v", err)) + return CmabDecision{Reasons: reasons}, fmt.Errorf("failed to hash attributes: %w", err) + } + attributesHash := strconv.FormatUint(uint64(hasher.Sum32()), 10) + + // Try to get from cache + cachedValue := s.cmabCache.Lookup(cacheKey) + if cachedValue != nil { + // Need to type assert since Lookup returns interface{} + if cacheVal, ok := cachedValue.(CmabCacheValue); ok { + // Check if attributes have changed + if cacheVal.AttributesHash == attributesHash { + s.logger.Debug(fmt.Sprintf("Returning cached CMAB decision for rule %s and user %s", ruleID, userContext.ID)) + reasons = append(reasons, "Returning cached CMAB decision") + return CmabDecision{ + VariationID: cacheVal.VariationID, + CmabUUID: cacheVal.CmabUUID, + Reasons: reasons, + }, nil + } + + // Attributes changed, remove from cache + s.cmabCache.Remove(cacheKey) + reasons = append(reasons, "Attributes changed, invalidating cache") + } + } + + // Fetch new decision + decision, err := s.fetchDecisionWithRetry(ruleID, userContext.ID, filteredAttributes) + if err != nil { + decision.Reasons = append(reasons, decision.Reasons...) + return decision, err + } + + // Cache the decision + cacheValue := CmabCacheValue{ + AttributesHash: attributesHash, + VariationID: decision.VariationID, + CmabUUID: decision.CmabUUID, + } + + s.cmabCache.Save(cacheKey, cacheValue) + reasons = append(reasons, "Fetched new CMAB decision and cached it") + decision.Reasons = append(reasons, decision.Reasons...) + + return decision, nil +} + +// fetchDecisionWithRetry fetches a decision from the CMAB API with retry logic +func (s *DefaultCmabService) fetchDecisionWithRetry( + ruleID string, + userID string, + attributes map[string]interface{}, +) (CmabDecision, error) { + cmabUUID := uuid.New().String() + reasons := []string{} + + // Retry configuration + maxRetries := 3 + backoffFactor := 2 + initialBackoff := 100 * time.Millisecond + + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + // Exponential backoff if this is a retry + if attempt > 0 { + backoffDuration := initialBackoff * time.Duration(backoffFactor^attempt) + time.Sleep(backoffDuration) + reasons = append(reasons, fmt.Sprintf("Retry attempt %d/%d after backoff", attempt+1, maxRetries)) + } + + s.logger.Debug(fmt.Sprintf("Fetching CMAB decision for rule %s and user %s (attempt %d/%d)", + ruleID, userID, attempt+1, maxRetries)) + + variationID, err := s.cmabClient.FetchDecision(ruleID, userID, attributes, cmabUUID) + if err == nil { + reasons = append(reasons, fmt.Sprintf("Successfully fetched CMAB decision on attempt %d/%d", attempt+1, maxRetries)) + return CmabDecision{ + VariationID: variationID, + CmabUUID: cmabUUID, + Reasons: reasons, + }, nil + } + + lastErr = err + s.logger.Warning(fmt.Sprintf("CMAB API request failed (attempt %d/%d): %v", + attempt+1, maxRetries, err)) + } + + reasons = append(reasons, fmt.Sprintf("Failed to fetch CMAB decision after %d attempts", maxRetries)) + return CmabDecision{Reasons: reasons}, fmt.Errorf("failed to fetch CMAB decision after %d attempts: %w", + maxRetries, lastErr) +} + +// filterAttributes filters user attributes based on CMAB configuration +func (s *DefaultCmabService) filterAttributes( + projectConfig config.ProjectConfig, + userContext entities.UserContext, + ruleID string, +) map[string]interface{} { + filteredAttributes := make(map[string]interface{}) + + // Get experiment by ID directly using the interface method + targetExperiment, err := projectConfig.GetExperimentByID(ruleID) + if err != nil || targetExperiment.Cmab == nil { + return filteredAttributes + } + + // Get attribute IDs from CMAB configuration + cmabAttributeIDs := targetExperiment.Cmab.AttributeIds + + // Filter attributes based on CMAB configuration + for _, attributeID := range cmabAttributeIDs { + // Get the attribute key for this ID + attributeKey, err := projectConfig.GetAttributeKeyByID(attributeID) + if err != nil { + s.logger.Debug(fmt.Sprintf("Attribute with ID %s not found in project config: %v", attributeID, err)) + continue + } + + if value, exists := userContext.Attributes[attributeKey]; exists { + filteredAttributes[attributeKey] = value + } + } + + return filteredAttributes +} + +// getAttributesJSON serializes attributes to a JSON string +func (s *DefaultCmabService) getAttributesJSON(attributes map[string]interface{}) (string, error) { + // Serialize to JSON - json.Marshal already sorts map keys alphabetically + jsonBytes, err := json.Marshal(attributes) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// getCacheKey generates a cache key for the user and rule +func (s *DefaultCmabService) getCacheKey(userID, ruleID string) string { + // Include length of userID to avoid ambiguity when IDs contain the separator + return fmt.Sprintf("%d:%s:%s", len(userID), userID, ruleID) +} + +// hasOption checks if a specific CMAB option is set +func hasOption(options *decide.Options, option decide.OptimizelyDecideOptions) bool { + if options == nil { + return false + } + + switch option { + case decide.IgnoreCMABCache: + return options.IgnoreCMABCache + case decide.ResetCMABCache: + return options.ResetCMABCache + case decide.InvalidateUserCMABCache: + return options.InvalidateUserCMABCache + default: + return false + } +} diff --git a/pkg/decision/cmab_service_test.go b/pkg/decision/cmab_service_test.go new file mode 100644 index 00000000..08333ef1 --- /dev/null +++ b/pkg/decision/cmab_service_test.go @@ -0,0 +1,736 @@ +/**************************************************************************** + * Copyright 2025, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package decision // +package decision + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "testing" + + "github.com/optimizely/go-sdk/v2/pkg/decide" + "github.com/optimizely/go-sdk/v2/pkg/entities" + "github.com/optimizely/go-sdk/v2/pkg/logging" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "github.com/twmb/murmur3" +) + +// MockCmabClient is a mock implementation of CmabClient +type MockCmabClient struct { + mock.Mock +} + +func (m *MockCmabClient) FetchDecision(ruleID, userID string, attributes map[string]interface{}, cmabUUID string) (string, error) { + args := m.Called(ruleID, userID, attributes, cmabUUID) + return args.String(0), args.Error(1) +} + +// MockCache is a mock implementation of cache.CacheWithRemove +type MockCache struct { + mock.Mock +} + +func (m *MockCache) Save(key string, value interface{}) { + m.Called(key, value) +} + +func (m *MockCache) Lookup(key string) interface{} { + args := m.Called(key) + return args.Get(0) +} + +func (m *MockCache) Reset() { + m.Called() +} + +func (m *MockCache) Remove(key string) { + m.Called(key) +} + +// MockProjectConfig is a mock implementation of config.ProjectConfig +type MockProjectConfig struct { + mock.Mock +} + +func (m *MockProjectConfig) GetProjectID() string { + args := m.Called() + return args.String(0) +} + +func (m *MockProjectConfig) GetRevision() string { + args := m.Called() + return args.String(0) +} + +func (m *MockProjectConfig) GetAccountID() string { + args := m.Called() + return args.String(0) +} + +func (m *MockProjectConfig) GetAnonymizeIP() bool { + args := m.Called() + return args.Bool(0) +} + +func (m *MockProjectConfig) GetAttributeID(key string) string { + args := m.Called(key) + return args.String(0) +} + +func (m *MockProjectConfig) GetAttributes() []entities.Attribute { + args := m.Called() + return args.Get(0).([]entities.Attribute) +} + +func (m *MockProjectConfig) GetAttributeByKey(key string) (entities.Attribute, error) { + args := m.Called(key) + return args.Get(0).(entities.Attribute), args.Error(1) +} + +func (m *MockProjectConfig) GetAttributeKeyByID(id string) (string, error) { + args := m.Called(id) + return args.String(0), args.Error(1) +} + +func (m *MockProjectConfig) GetAudienceByID(id string) (entities.Audience, error) { + args := m.Called(id) + return args.Get(0).(entities.Audience), args.Error(1) +} + +func (m *MockProjectConfig) GetEventByKey(key string) (entities.Event, error) { + args := m.Called(key) + return args.Get(0).(entities.Event), args.Error(1) +} + +func (m *MockProjectConfig) GetEvents() []entities.Event { + args := m.Called() + return args.Get(0).([]entities.Event) +} + +func (m *MockProjectConfig) GetFeatureByKey(featureKey string) (entities.Feature, error) { + args := m.Called(featureKey) + return args.Get(0).(entities.Feature), args.Error(1) +} + +func (m *MockProjectConfig) GetExperimentByKey(experimentKey string) (entities.Experiment, error) { + args := m.Called(experimentKey) + return args.Get(0).(entities.Experiment), args.Error(1) +} + +func (m *MockProjectConfig) GetExperimentByID(id string) (entities.Experiment, error) { + args := m.Called(id) + return args.Get(0).(entities.Experiment), args.Error(1) +} + +func (m *MockProjectConfig) GetExperimentList() []entities.Experiment { + args := m.Called() + return args.Get(0).([]entities.Experiment) +} + +func (m *MockProjectConfig) GetPublicKeyForODP() string { + args := m.Called() + return args.String(0) +} + +func (m *MockProjectConfig) GetHostForODP() string { + args := m.Called() + return args.String(0) +} + +func (m *MockProjectConfig) GetSegmentList() []string { + args := m.Called() + return args.Get(0).([]string) +} + +func (m *MockProjectConfig) GetBotFiltering() bool { + args := m.Called() + return args.Bool(0) +} + +func (m *MockProjectConfig) GetSdkKey() string { + args := m.Called() + return args.String(0) +} + +func (m *MockProjectConfig) GetEnvironmentKey() string { + args := m.Called() + return args.String(0) +} + +func (m *MockProjectConfig) GetVariableByKey(featureKey, variableKey string) (entities.Variable, error) { + args := m.Called(featureKey, variableKey) + return args.Get(0).(entities.Variable), args.Error(1) +} + +func (m *MockProjectConfig) GetFeatureList() []entities.Feature { + args := m.Called() + return args.Get(0).([]entities.Feature) +} + +func (m *MockProjectConfig) GetIntegrationList() []entities.Integration { + args := m.Called() + return args.Get(0).([]entities.Integration) +} + +func (m *MockProjectConfig) GetRolloutList() []entities.Rollout { + args := m.Called() + return args.Get(0).([]entities.Rollout) +} + +func (m *MockProjectConfig) GetAudienceList() []entities.Audience { + args := m.Called() + return args.Get(0).([]entities.Audience) +} + +func (m *MockProjectConfig) GetAudienceMap() map[string]entities.Audience { + args := m.Called() + return args.Get(0).(map[string]entities.Audience) +} + +func (m *MockProjectConfig) GetGroupByID(groupID string) (entities.Group, error) { + args := m.Called(groupID) + return args.Get(0).(entities.Group), args.Error(1) +} + +func (m *MockProjectConfig) SendFlagDecisions() bool { + args := m.Called() + return args.Bool(0) +} + +func (m *MockProjectConfig) GetFlagVariationsMap() map[string][]entities.Variation { + args := m.Called() + return args.Get(0).(map[string][]entities.Variation) +} + +func (m *MockProjectConfig) GetDatafile() string { + args := m.Called() + return args.String(0) +} + +type CmabServiceTestSuite struct { + suite.Suite + mockClient *MockCmabClient + mockCache *MockCache + mockConfig *MockProjectConfig + cmabService *DefaultCmabService + testRuleID string + testUserID string + testAttributes map[string]interface{} +} + +func (s *CmabServiceTestSuite) SetupTest() { + s.mockClient = new(MockCmabClient) + s.mockCache = new(MockCache) + s.mockConfig = new(MockProjectConfig) + + // Set up the CMAB service + s.cmabService = NewDefaultCmabService(CmabServiceOptions{ + Logger: logging.GetLogger("test", "CmabService"), + CmabCache: s.mockCache, + CmabClient: s.mockClient, + }) + + s.testRuleID = "rule-123" + s.testUserID = "user-456" + s.testAttributes = map[string]interface{}{ + "age": 30, + "location": "San Francisco", + } +} + +func (s *CmabServiceTestSuite) TestGetDecision() { + // Setup mock experiment with CMAB configuration + experiment := entities.Experiment{ + ID: s.testRuleID, + Cmab: &entities.Cmab{ + AttributeIds: []string{"attr1", "attr2"}, + }, + } + + // Setup mock config + s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil) + s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil) + s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil) + + // Create user context + userContext := entities.UserContext{ + ID: s.testUserID, + Attributes: s.testAttributes, + } + + // Setup cache key + cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID) + + // Setup cache lookup - return nil to simulate cache miss + s.mockCache.On("Lookup", cacheKey).Return(nil) + + // Setup mock API response + expectedVariationID := "variant-1" + s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return(expectedVariationID, nil) + + // Setup cache save + s.mockCache.On("Save", cacheKey, mock.Anything).Return() + + // Test with no options + decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil) + s.NoError(err) + s.Equal(expectedVariationID, decision.VariationID) + s.NotEmpty(decision.CmabUUID) + + // Verify expectations + s.mockConfig.AssertExpectations(s.T()) + s.mockCache.AssertExpectations(s.T()) + s.mockClient.AssertExpectations(s.T()) +} + +func (s *CmabServiceTestSuite) TestGetDecisionWithCache() { + // Setup mock experiment with CMAB configuration + experiment := entities.Experiment{ + ID: s.testRuleID, + Cmab: &entities.Cmab{ + AttributeIds: []string{"attr1", "attr2"}, + }, + } + + // Setup mock config + s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil) + s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil) + s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil) + + // Create user context + userContext := entities.UserContext{ + ID: s.testUserID, + Attributes: s.testAttributes, + } + + // Setup cache key + cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID) + + // Calculate attributes hash using murmur3 as in your implementation + attributesJSON, _ := s.cmabService.getAttributesJSON(s.testAttributes) + hasher := murmur3.SeedNew32(1) + hasher.Write([]byte(attributesJSON)) + attributesHash := strconv.FormatUint(uint64(hasher.Sum32()), 10) + + // Setup cache hit with matching attributes hash + cachedValue := CmabCacheValue{ + AttributesHash: attributesHash, + VariationID: "cached-variant", + CmabUUID: "cached-uuid", + } + s.mockCache.On("Lookup", cacheKey).Return(cachedValue) + + // Test with cache hit + decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil) + s.NoError(err) + s.Equal("cached-variant", decision.VariationID) + s.Equal("cached-uuid", decision.CmabUUID) + + // Verify API was not called + s.mockClient.AssertNotCalled(s.T(), "FetchDecision") +} + +func (s *CmabServiceTestSuite) TestGetDecisionWithIgnoreCache() { + // Setup mock experiment with CMAB configuration + experiment := entities.Experiment{ + ID: s.testRuleID, + Cmab: &entities.Cmab{ + AttributeIds: []string{"attr1", "attr2"}, + }, + } + + // Setup mock config + s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil) + s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil) + s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil) + + // Create user context + userContext := entities.UserContext{ + ID: s.testUserID, + Attributes: s.testAttributes, + } + + // Setup cache key + cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID) + + // Setup mock API response + expectedVariationID := "variant-1" + s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return(expectedVariationID, nil) + + // Test with IgnoreCMABCache option + options := &decide.Options{ + IgnoreCMABCache: true, + } + + decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, options) + s.NoError(err) + s.Equal(expectedVariationID, decision.VariationID) + + // Verify API was called (cache was ignored) + s.mockClient.AssertCalled(s.T(), "FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything) + + // Verify cache lookup was not called + s.mockCache.AssertNotCalled(s.T(), "Lookup", cacheKey) + + // Verify cache save was not called + s.mockCache.AssertNotCalled(s.T(), "Save", cacheKey, mock.Anything) +} + +func (s *CmabServiceTestSuite) TestGetDecisionWithResetCache() { + // Setup mock experiment with CMAB configuration + experiment := entities.Experiment{ + ID: s.testRuleID, + Cmab: &entities.Cmab{ + AttributeIds: []string{"attr1", "attr2"}, + }, + } + + // Setup mock config + s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil) + s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil) + s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil) + + // Create user context + userContext := entities.UserContext{ + ID: s.testUserID, + Attributes: s.testAttributes, + } + + // Setup cache reset + s.mockCache.On("Reset").Return() + + // Setup cache key + cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID) + + // Setup cache lookup after reset + s.mockCache.On("Lookup", cacheKey).Return(nil) + + // Setup mock API response + expectedVariationID := "variant-1" + s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return(expectedVariationID, nil) + + // Setup cache save + s.mockCache.On("Save", cacheKey, mock.Anything).Return() + + // Test with ResetCMABCache option + options := &decide.Options{ + ResetCMABCache: true, + } + + decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, options) + s.NoError(err) + s.Equal(expectedVariationID, decision.VariationID) + + // Verify cache was reset + s.mockCache.AssertCalled(s.T(), "Reset") +} + +func (s *CmabServiceTestSuite) TestGetDecisionWithInvalidateUserCache() { + // Setup mock experiment with CMAB configuration + experiment := entities.Experiment{ + ID: s.testRuleID, + Cmab: &entities.Cmab{ + AttributeIds: []string{"attr1", "attr2"}, + }, + } + + // Setup mock config + s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil) + s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil) + s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil) + + // Create user context + userContext := entities.UserContext{ + ID: s.testUserID, + Attributes: s.testAttributes, + } + + // Setup cache key + cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID) + + // Setup cache remove + s.mockCache.On("Remove", cacheKey).Return() + + // Setup cache lookup after remove + s.mockCache.On("Lookup", cacheKey).Return(nil) + + // Setup mock API response + expectedVariationID := "variant-1" + s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return(expectedVariationID, nil) + + // Setup cache save + s.mockCache.On("Save", cacheKey, mock.Anything).Return() + + // Test with InvalidateUserCMABCache option + options := &decide.Options{ + InvalidateUserCMABCache: true, + } + + decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, options) + s.NoError(err) + s.Equal(expectedVariationID, decision.VariationID) + + // Verify user cache was invalidated + s.mockCache.AssertCalled(s.T(), "Remove", cacheKey) +} + +func (s *CmabServiceTestSuite) TestGetDecisionError() { + // Setup mock experiment with CMAB configuration + experiment := entities.Experiment{ + ID: s.testRuleID, + Cmab: &entities.Cmab{ + AttributeIds: []string{"attr1", "attr2"}, + }, + } + + // Setup mock config + s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil) + s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil) + s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil) + + // Create user context + userContext := entities.UserContext{ + ID: s.testUserID, + Attributes: s.testAttributes, + } + + // Setup cache key + cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID) + + // Setup cache miss + s.mockCache.On("Lookup", cacheKey).Return(nil) + + // Setup mock API error + expectedError := errors.New("API error") + s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return("", expectedError) + + // Test error handling + decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil) + s.Error(err) + s.Equal("", decision.VariationID) // Should be empty +} + +func (s *CmabServiceTestSuite) TestFilterAttributes() { + // Setup mock experiment with CMAB configuration + experiment := entities.Experiment{ + ID: s.testRuleID, + Cmab: &entities.Cmab{ + AttributeIds: []string{"attr1", "attr2", "attr3"}, + }, + } + + // Setup mock config + s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil) + s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil) + s.mockConfig.On("GetAttributeKeyByID", "attr3").Return("", errors.New("attribute not found")) + s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil) + + // Create user context with extra attributes that should be filtered out + userContext := entities.UserContext{ + ID: s.testUserID, + Attributes: map[string]interface{}{ + "age": 30, + "location": "San Francisco", + "extra_key": "should be filtered out", + }, + } + + // Call filterAttributes directly + filteredAttrs := s.cmabService.filterAttributes(s.mockConfig, userContext, s.testRuleID) + + // Verify only the configured attributes are included + s.Equal(2, len(filteredAttrs)) + s.Equal(30, filteredAttrs["age"]) + s.Equal("San Francisco", filteredAttrs["location"]) + s.NotContains(filteredAttrs, "extra_key") +} + +func (s *CmabServiceTestSuite) TestOnlyFilteredAttributesPassedToClient() { + // Setup mock experiment with CMAB configuration + experiment := entities.Experiment{ + ID: s.testRuleID, + Cmab: &entities.Cmab{ + AttributeIds: []string{"attr1", "attr2"}, + }, + } + + // Setup mock config + s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil) + s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil) + s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil) + + // Create user context with extra attributes that should be filtered out + userContext := entities.UserContext{ + ID: s.testUserID, + Attributes: map[string]interface{}{ + "age": 30, + "location": "San Francisco", + "extra_key": "should be filtered out", + }, + } + + // Expected filtered attributes + expectedFilteredAttrs := map[string]interface{}{ + "age": 30, + "location": "San Francisco", + } + + // Setup cache key + cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID) + + // Setup cache lookup + s.mockCache.On("Lookup", cacheKey).Return(nil) + + // Setup mock API response with attribute verification + expectedVariationID := "variant-1" + s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.MatchedBy(func(attrs map[string]interface{}) bool { + // Verify only the filtered attributes are passed + if len(attrs) != 2 { + return false + } + if attrs["age"] != 30 { + return false + } + if attrs["location"] != "San Francisco" { + return false + } + if _, exists := attrs["extra_key"]; exists { + return false + } + return true + }), mock.Anything).Return(expectedVariationID, nil) + + // Setup cache save + s.mockCache.On("Save", cacheKey, mock.Anything).Return() + + // Call GetDecision + decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil) + s.NoError(err) + s.Equal(expectedVariationID, decision.VariationID) + + // Verify client was called with the filtered attributes + s.mockClient.AssertCalled(s.T(), "FetchDecision", s.testRuleID, s.testUserID, mock.MatchedBy(func(attrs map[string]interface{}) bool { + return reflect.DeepEqual(attrs, expectedFilteredAttrs) + }), mock.Anything) +} + +func (s *CmabServiceTestSuite) TestCacheInvalidatedWhenAttributesChange() { + // Setup mock experiment with CMAB configuration + experiment := entities.Experiment{ + ID: s.testRuleID, + Cmab: &entities.Cmab{ + AttributeIds: []string{"attr1", "attr2"}, + }, + } + + // Setup mock config + s.mockConfig.On("GetAttributeKeyByID", "attr1").Return("age", nil) + s.mockConfig.On("GetAttributeKeyByID", "attr2").Return("location", nil) + s.mockConfig.On("GetExperimentByID", s.testRuleID).Return(experiment, nil) + + // Create user context + userContext := entities.UserContext{ + ID: s.testUserID, + Attributes: map[string]interface{}{ + "age": 30, + "location": "San Francisco", + }, + } + + // Setup cache key + cacheKey := s.cmabService.getCacheKey(s.testUserID, s.testRuleID) + + // First, create a cached value with a different attributes hash + oldAttributesHash := "old-hash" + cachedValue := CmabCacheValue{ + AttributesHash: oldAttributesHash, + VariationID: "cached-variant", + CmabUUID: "cached-uuid", + } + + // Setup cache lookup to return the cached value + s.mockCache.On("Lookup", cacheKey).Return(cachedValue) + + // Setup cache remove (should be called when attributes change) + s.mockCache.On("Remove", cacheKey).Return() + + // Setup mock API response (should be called when attributes change) + expectedVariationID := "new-variant" + s.mockClient.On("FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything).Return(expectedVariationID, nil) + + // Setup cache save for the new decision + s.mockCache.On("Save", cacheKey, mock.Anything).Return() + + // Call GetDecision + decision, err := s.cmabService.GetDecision(s.mockConfig, userContext, s.testRuleID, nil) + s.NoError(err) + s.Equal(expectedVariationID, decision.VariationID) + + // Verify cache was looked up + s.mockCache.AssertCalled(s.T(), "Lookup", cacheKey) + + // Verify cache entry was removed due to attribute change + s.mockCache.AssertCalled(s.T(), "Remove", cacheKey) + + // Verify API was called to get a new decision + s.mockClient.AssertCalled(s.T(), "FetchDecision", s.testRuleID, s.testUserID, mock.Anything, mock.Anything) + + // Verify new decision was cached + s.mockCache.AssertCalled(s.T(), "Save", cacheKey, mock.MatchedBy(func(value CmabCacheValue) bool { + return value.VariationID == expectedVariationID && value.AttributesHash != oldAttributesHash + })) +} + +func (s *CmabServiceTestSuite) TestGetAttributesJSON() { + // Test with empty attributes + emptyJSON, err := s.cmabService.getAttributesJSON(map[string]interface{}{}) + s.NoError(err) + s.Equal("{}", emptyJSON) + + // Test with attributes + attributes := map[string]interface{}{ + "c": 3, + "a": 1, + "b": 2, + } + json, err := s.cmabService.getAttributesJSON(attributes) + s.NoError(err) + // Keys should be sorted alphabetically + s.Equal(`{"a":1,"b":2,"c":3}`, json) +} + +func (s *CmabServiceTestSuite) TestGetCacheKey() { + // Update the expected format to match the new implementation + expected := fmt.Sprintf("%d:%s:%s", len("user123"), "user123", "rule456") + actual := s.cmabService.getCacheKey("user123", "rule456") + s.Equal(expected, actual) +} + +func (s *CmabServiceTestSuite) TestNewDefaultCmabService() { + // Test with default options + service := NewDefaultCmabService(CmabServiceOptions{}) + + // Only check that the service is created, not the specific fields + s.NotNil(service) +} + +func TestCmabServiceTestSuite(t *testing.T) { + suite.Run(t, new(CmabServiceTestSuite)) +}