Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions optimizely/decision_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,11 +529,18 @@ def get_variation(
self.logger.info(message)
decide_reasons.append(message)
# Store this new decision and return the variation for the user
# CMAB experiments are excluded from user profile storage to allow dynamic decision-making
if user_profile_tracker is not None and not ignore_user_profile:
try:
user_profile_tracker.update_user_profile(experiment, variation)
except:
self.logger.exception(f'Unable to save user profile for user "{user_id}".')
if not experiment.cmab:
try:
user_profile_tracker.update_user_profile(experiment, variation)
except:
self.logger.exception(f'Unable to save user profile for user "{user_id}".')
else:
self.logger.debug(
f'Skipping user profile update for CMAB experiment "{experiment.key}". '
f'CMAB decisions are dynamic and not stored for sticky bucketing.'
)
return {
'cmab_uuid': cmab_uuid,
'error': False,
Expand Down
174 changes: 174 additions & 0 deletions tests/test_decision_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,180 @@ def test_get_variation_cmab_experiment_with_whitelisted_variation(self):
mock_bucket.assert_not_called()
mock_cmab_decision.assert_not_called()

def test_get_variation_cmab_experiment_does_not_save_user_profile(self):
"""Test that CMAB experiments do not save bucketing decisions to user profile."""

# Create a user context
user = optimizely_user_context.OptimizelyUserContext(
optimizely_client=None,
logger=None,
user_id="test_user",
user_attributes={}
)

# Create a user profile service and tracker
user_profile_service = user_profile.UserProfileService()
user_profile_tracker = user_profile.UserProfileTracker(user.user_id, user_profile_service)

# Create a CMAB experiment
cmab_experiment = entities.Experiment(
'111150',
'cmab_experiment',
'Running',
'111150',
[], # No audience IDs
{},
[
entities.Variation('111151', 'variation_1'),
entities.Variation('111152', 'variation_2')
],
[
{'entityId': '111151', 'endOfRange': 5000},
{'entityId': '111152', 'endOfRange': 10000}
],
cmab={'trafficAllocation': 5000}
)

with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \
mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \
mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id',
return_value=['$', []]), \
mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \
mock.patch.object(self.project_config, 'get_variation_from_id',
return_value=entities.Variation('111151', 'variation_1')), \
mock.patch.object(user_profile_tracker, 'update_user_profile') as mock_update_profile, \
mock.patch.object(self.decision_service, 'logger') as mock_logger:

# Configure CMAB service to return a decision
mock_cmab_service.get_decision.return_value = (
{
'variation_id': '111151',
'cmab_uuid': 'test-cmab-uuid-123'
},
[] # reasons list
)

# Call get_variation with the CMAB experiment and user profile tracker
variation_result = self.decision_service.get_variation(
self.project_config,
cmab_experiment,
user,
user_profile_tracker
)
variation = variation_result['variation']
cmab_uuid = variation_result['cmab_uuid']

# Verify the variation and cmab_uuid are returned
self.assertEqual(entities.Variation('111151', 'variation_1'), variation)
self.assertEqual('test-cmab-uuid-123', cmab_uuid)

# Verify user profile was NOT updated for CMAB experiment
mock_update_profile.assert_not_called()

# Verify debug log was called to explain CMAB exclusion
mock_logger.debug.assert_called_with(
'Skipping user profile update for CMAB experiment "cmab_experiment". '
'CMAB decisions are dynamic and not stored for sticky bucketing.'
)

def test_get_variation_standard_experiment_saves_user_profile(self):
"""Test that standard (non-CMAB) experiments DO save bucketing decisions to user profile."""

user = optimizely_user_context.OptimizelyUserContext(
optimizely_client=None,
logger=None,
user_id="test_user",
user_attributes={}
)

# Create a user profile service and tracker
user_profile_service = user_profile.UserProfileService()
user_profile_tracker = user_profile.UserProfileTracker(user.user_id, user_profile_service)

# Get a standard (non-CMAB) experiment
experiment = self.project_config.get_experiment_from_key("test_experiment")

with mock.patch('optimizely.decision_service.DecisionService.get_whitelisted_variation',
return_value=[None, []]), \
mock.patch('optimizely.decision_service.DecisionService.get_stored_variation',
return_value=None), \
mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions',
return_value=[True, []]), \
mock.patch('optimizely.bucketer.Bucketer.bucket',
return_value=[entities.Variation("111129", "variation"), []]), \
mock.patch.object(user_profile_tracker, 'update_user_profile') as mock_update_profile:

# Call get_variation with standard experiment and user profile tracker
variation_result = self.decision_service.get_variation(
self.project_config,
experiment,
user,
user_profile_tracker
)
variation = variation_result['variation']

# Verify variation was returned
self.assertEqual(entities.Variation("111129", "variation"), variation)

# Verify user profile WAS updated for standard experiment
mock_update_profile.assert_called_once_with(experiment, variation)

def test_get_variation_cmab_experiment_with_ignore_ups_option(self):
"""Test that CMAB experiments with IGNORE_USER_PROFILE_SERVICE option don't attempt profile update."""

user = optimizely_user_context.OptimizelyUserContext(
optimizely_client=None,
logger=None,
user_id="test_user",
user_attributes={}
)

# Create a user profile tracker
user_profile_service = user_profile.UserProfileService()
user_profile_tracker = user_profile.UserProfileTracker(user.user_id, user_profile_service)

# Create a CMAB experiment
cmab_experiment = entities.Experiment(
'111150',
'cmab_experiment',
'Running',
'111150',
[],
{},
[entities.Variation('111151', 'variation_1')],
[{'entityId': '111151', 'endOfRange': 10000}],
cmab={'trafficAllocation': 5000}
)

with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \
mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \
mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id',
return_value=['$', []]), \
mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \
mock.patch.object(self.project_config, 'get_variation_from_id',
return_value=entities.Variation('111151', 'variation_1')), \
mock.patch.object(user_profile_tracker, 'update_user_profile') as mock_update_profile, \
mock.patch.object(self.decision_service, 'logger'):

mock_cmab_service.get_decision.return_value = (
{'variation_id': '111151', 'cmab_uuid': 'test-uuid'},
[]
)

# Call with IGNORE_USER_PROFILE_SERVICE option
variation_result = self.decision_service.get_variation(
self.project_config,
cmab_experiment,
user,
user_profile_tracker,
[],
options=['IGNORE_USER_PROFILE_SERVICE']
)

# Verify variation returned but profile not updated
self.assertIsNotNone(variation_result['variation'])
mock_update_profile.assert_not_called()


class FeatureFlagDecisionTests(base.BaseTest):
def setUp(self):
Expand Down