diff --git a/optimizely/config_manager.py b/optimizely/config_manager.py index 9d26fa3a..247f5ce5 100644 --- a/optimizely/config_manager.py +++ b/optimizely/config_manager.py @@ -1,4 +1,4 @@ -# Copyright 2019-2020, 2022, Optimizely +# Copyright 2019-2020, 2022-2023, Optimizely # 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 @@ -25,6 +25,7 @@ from . import project_config from .error_handler import NoOpErrorHandler, BaseErrorHandler from .notification_center import NotificationCenter +from .notification_center_registry import _NotificationCenterRegistry from .helpers import enums from .helpers import validator from .optimizely_config import OptimizelyConfig, OptimizelyConfigService @@ -78,6 +79,13 @@ def get_config(self) -> Optional[project_config.ProjectConfig]: The config should be an instance of project_config.ProjectConfig.""" pass + @abstractmethod + def get_sdk_key(self) -> Optional[str]: + """ Get sdk_key for use by optimizely.Optimizely. + The sdk_key should uniquely identify the datafile for a project and environment combination. + """ + pass + class StaticConfigManager(BaseConfigManager): """ Config manager that returns ProjectConfig based on provided datafile. """ @@ -106,9 +114,13 @@ def __init__( ) self._config: project_config.ProjectConfig = None # type: ignore[assignment] self.optimizely_config: Optional[OptimizelyConfig] = None + self._sdk_key: Optional[str] = None self.validate_schema = not skip_json_validation self._set_config(datafile) + def get_sdk_key(self) -> Optional[str]: + return self._sdk_key + def _set_config(self, datafile: Optional[str | bytes]) -> None: """ Looks up and sets datafile and config based on response body. @@ -146,8 +158,16 @@ def _set_config(self, datafile: Optional[str | bytes]) -> None: return self._config = config + self._sdk_key = self._sdk_key or config.sdk_key self.optimizely_config = OptimizelyConfigService(config).get_config() self.notification_center.send_notifications(enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE) + + internal_notification_center = _NotificationCenterRegistry.get_notification_center( + self._sdk_key, self.logger + ) + if internal_notification_center: + internal_notification_center.send_notifications(enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE) + self.logger.debug( 'Received new datafile and updated config. ' f'Old revision number: {previous_revision}. New revision number: {config.get_revision()}.' @@ -181,11 +201,12 @@ def __init__( notification_center: Optional[NotificationCenter] = None, skip_json_validation: Optional[bool] = False, ): - """ Initialize config manager. One of sdk_key or url has to be set to be able to use. + """ Initialize config manager. One of sdk_key or datafile has to be set to be able to use. Args: - sdk_key: Optional string uniquely identifying the datafile. - datafile: Optional JSON string representing the project. + sdk_key: Optional string uniquely identifying the datafile. If not provided, datafile must + contain a sdk_key. + datafile: Optional JSON string representing the project. If not provided, sdk_key is required. update_interval: Optional floating point number representing time interval in seconds at which to request datafile and set ProjectConfig. blocking_timeout: Optional Time in seconds to block the get_config call until config object @@ -209,8 +230,13 @@ def __init__( notification_center=notification_center, skip_json_validation=skip_json_validation, ) + self._sdk_key = sdk_key or self._sdk_key + + if self._sdk_key is None: + raise optimizely_exceptions.InvalidInputException(enums.Errors.MISSING_SDK_KEY) + self.datafile_url = self.get_datafile_url( - sdk_key, url, url_template or self.DATAFILE_URL_TEMPLATE + self._sdk_key, url, url_template or self.DATAFILE_URL_TEMPLATE ) self.set_update_interval(update_interval) self.set_blocking_timeout(blocking_timeout) @@ -415,7 +441,7 @@ def __init__( *args: Any, **kwargs: Any ): - """ Initialize config manager. One of sdk_key or url has to be set to be able to use. + """ Initialize config manager. One of sdk_key or datafile has to be set to be able to use. Args: datafile_access_token: String to be attached to the request header to fetch the authenticated datafile. diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 8ba311a1..56fb4946 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -126,6 +126,7 @@ class Errors: ODP_NOT_INTEGRATED: Final = 'ODP is not integrated.' ODP_NOT_ENABLED: Final = 'ODP is not enabled.' ODP_INVALID_DATA: Final = 'ODP data is not valid.' + MISSING_SDK_KEY: Final = 'SDK key not provided/cannot be found in the datafile.' class ForcedDecisionLogs: diff --git a/optimizely/notification_center_registry.py b/optimizely/notification_center_registry.py new file mode 100644 index 00000000..b07702ab --- /dev/null +++ b/optimizely/notification_center_registry.py @@ -0,0 +1,64 @@ +# Copyright 2023, Optimizely +# 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. + +from __future__ import annotations +from threading import Lock +from typing import Optional +from .logger import Logger as OptimizelyLogger +from .notification_center import NotificationCenter +from .helpers.enums import Errors + + +class _NotificationCenterRegistry: + """ Class managing internal notification centers.""" + _notification_centers: dict[str, NotificationCenter] = {} + _lock = Lock() + + @classmethod + def get_notification_center(cls, sdk_key: Optional[str], logger: OptimizelyLogger) -> Optional[NotificationCenter]: + """Returns an internal notification center for the given sdk_key, creating one + if none exists yet. + + Args: + sdk_key: A string sdk key to uniquely identify the notification center. + logger: Optional logger. + + Returns: + None or NotificationCenter + """ + + if not sdk_key: + logger.error(f'{Errors.MISSING_SDK_KEY} ODP may not work properly without it.') + return None + + with cls._lock: + if sdk_key in cls._notification_centers: + notification_center = cls._notification_centers[sdk_key] + else: + notification_center = NotificationCenter(logger) + cls._notification_centers[sdk_key] = notification_center + + return notification_center + + @classmethod + def remove_notification_center(cls, sdk_key: str) -> None: + """Remove a previously added notification center and clear all its listeners. + + Args: + sdk_key: The sdk_key of the notification center to remove. + """ + + with cls._lock: + notification_center = cls._notification_centers.pop(sdk_key, None) + if notification_center: + notification_center.clear_all_notification_listeners() diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 8408cbcc..00451175 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1,4 +1,4 @@ -# Copyright 2016-2022, Optimizely +# Copyright 2016-2023, Optimizely # 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 @@ -37,6 +37,7 @@ from .helpers.sdk_settings import OptimizelySdkSettings from .helpers.enums import DecisionSources from .notification_center import NotificationCenter +from .notification_center_registry import _NotificationCenterRegistry from .odp.lru_cache import LRUCache from .odp.odp_manager import OdpManager from .optimizely_config import OptimizelyConfig, OptimizelyConfigService @@ -143,18 +144,6 @@ def __init__( self.logger.exception(str(error)) return - self.setup_odp() - - self.odp_manager = OdpManager( - self.sdk_settings.odp_disabled, - self.sdk_settings.segments_cache, - self.sdk_settings.odp_segment_manager, - self.sdk_settings.odp_event_manager, - self.sdk_settings.fetch_segments_timeout, - self.sdk_settings.odp_event_timeout, - self.logger - ) - config_manager_options: dict[str, Any] = { 'datafile': datafile, 'logger': self.logger, @@ -174,8 +163,8 @@ def __init__( else: self.config_manager = StaticConfigManager(**config_manager_options) - if not self.sdk_settings.odp_disabled: - self._update_odp_config_on_datafile_update() + self.odp_manager: OdpManager + self.setup_odp(self.config_manager.get_sdk_key()) self.event_builder = event_builder.EventBuilder() self.decision_service = decision_service.DecisionService(self.logger, user_profile_service) @@ -1303,28 +1292,46 @@ def _decide_for_keys( decisions[key] = decision return decisions - def setup_odp(self) -> None: + def setup_odp(self, sdk_key: Optional[str]) -> None: """ - - Make sure cache is instantiated with provided parameters or defaults. + - Make sure odp manager is instantiated with provided parameters or defaults. - Set up listener to update odp_config when datafile is updated. + - Manually call callback in case datafile was received before the listener was registered. """ - if self.sdk_settings.odp_disabled: - return - self.notification_center.add_notification_listener( - enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE, - self._update_odp_config_on_datafile_update + # no need to instantiate a cache if a custom cache or segment manager is provided. + if ( + not self.sdk_settings.odp_disabled and + not self.sdk_settings.odp_segment_manager and + not self.sdk_settings.segments_cache + ): + self.sdk_settings.segments_cache = LRUCache( + self.sdk_settings.segments_cache_size, + self.sdk_settings.segments_cache_timeout_in_secs + ) + + self.odp_manager = OdpManager( + self.sdk_settings.odp_disabled, + self.sdk_settings.segments_cache, + self.sdk_settings.odp_segment_manager, + self.sdk_settings.odp_event_manager, + self.sdk_settings.fetch_segments_timeout, + self.sdk_settings.odp_event_timeout, + self.logger ) - if self.sdk_settings.odp_segment_manager: + if self.sdk_settings.odp_disabled: return - if not self.sdk_settings.segments_cache: - self.sdk_settings.segments_cache = LRUCache( - self.sdk_settings.segments_cache_size, - self.sdk_settings.segments_cache_timeout_in_secs + internal_notification_center = _NotificationCenterRegistry.get_notification_center(sdk_key, self.logger) + if internal_notification_center: + internal_notification_center.add_notification_listener( + enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE, + self._update_odp_config_on_datafile_update ) + self._update_odp_config_on_datafile_update() + def _update_odp_config_on_datafile_update(self) -> None: config = None diff --git a/tests/base.py b/tests/base.py index 6e74e3aa..875a26e6 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,4 +1,4 @@ -# Copyright 2016-2021, Optimizely +# Copyright 2016-2023 Optimizely # 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 @@ -58,6 +58,7 @@ def fake_server_response(self, status_code: Optional[int] = None, def setUp(self, config_dict='config_dict'): self.config_dict = { 'revision': '42', + 'sdkKey': 'basic-test', 'version': '2', 'events': [ {'key': 'test_event', 'experimentIds': ['111127'], 'id': '111095'}, @@ -150,6 +151,7 @@ def setUp(self, config_dict='config_dict'): # datafile version 4 self.config_dict_with_features = { 'revision': '1', + 'sdkKey': 'features-test', 'accountId': '12001', 'projectId': '111111', 'version': '4', @@ -552,6 +554,7 @@ def setUp(self, config_dict='config_dict'): self.config_dict_with_multiple_experiments = { 'revision': '42', + 'sdkKey': 'multiple-experiments', 'version': '2', 'events': [ {'key': 'test_event', 'experimentIds': ['111127', '111130'], 'id': '111095'}, @@ -657,6 +660,7 @@ def setUp(self, config_dict='config_dict'): self.config_dict_with_unsupported_version = { 'version': '5', + 'sdkKey': 'unsupported-version', 'rollouts': [], 'projectId': '10431130345', 'variables': [], @@ -1073,6 +1077,7 @@ def setUp(self, config_dict='config_dict'): {'key': 'user_signed_up', 'id': '594090', 'experimentIds': ['1323241598', '1323241599']}, ], 'revision': '3', + 'sdkKey': 'typed-audiences', } self.config_dict_with_audience_segments = { @@ -1261,8 +1266,15 @@ def setUp(self, config_dict='config_dict'): } ], 'accountId': '10367498574', - 'events': [], - 'revision': '101' + 'events': [ + { + "experimentIds": ["10420810910"], + "id": "10404198134", + "key": "event1" + } + ], + 'revision': '101', + 'sdkKey': 'segments-test' } config = getattr(self, config_dict) diff --git a/tests/test_config.py b/tests/test_config.py index 3b95b02e..9a16035d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -160,6 +160,7 @@ def test_init__with_v4_datafile(self): # Adding some additional fields like live variables and IP anonymization config_dict = { 'revision': '42', + 'sdkKey': 'test', 'version': '4', 'anonymizeIP': False, 'botFiltering': True, diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 38dcfa33..6f4038cb 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -220,14 +220,14 @@ def test_get_config_blocks(self): @mock.patch('requests.get') class PollingConfigManagerTest(base.BaseTest): - def test_init__no_sdk_key_no_url__fails(self, _): - """ Test that initialization fails if there is no sdk_key or url provided. """ + def test_init__no_sdk_key_no_datafile__fails(self, _): + """ Test that initialization fails if there is no sdk_key or datafile provided. """ self.assertRaisesRegex( optimizely_exceptions.InvalidInputException, - 'Must provide at least one of sdk_key or url.', + enums.Errors.MISSING_SDK_KEY, config_manager.PollingConfigManager, sdk_key=None, - url=None, + datafile=None, ) def test_get_datafile_url__no_sdk_key_no_url_raises(self, _): diff --git a/tests/test_notification_center_registry.py b/tests/test_notification_center_registry.py new file mode 100644 index 00000000..9159d01a --- /dev/null +++ b/tests/test_notification_center_registry.py @@ -0,0 +1,84 @@ +# Copyright 2023, Optimizely +# 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. + +import json +from unittest import mock +import copy + +from optimizely.notification_center_registry import _NotificationCenterRegistry +from optimizely.notification_center import NotificationCenter +from optimizely.optimizely import Optimizely +from optimizely.helpers.enums import NotificationTypes, Errors +from .base import BaseTest + + +class NotificationCenterRegistryTest(BaseTest): + def test_get_notification_center(self): + logger = mock.MagicMock() + sdk_key = 'test' + client = Optimizely(sdk_key=sdk_key, logger=logger) + notification_center = _NotificationCenterRegistry.get_notification_center(sdk_key, logger) + self.assertIsInstance(notification_center, NotificationCenter) + config_notifications = notification_center.notification_listeners[NotificationTypes.OPTIMIZELY_CONFIG_UPDATE] + + self.assertIn((mock.ANY, client._update_odp_config_on_datafile_update), config_notifications) + + logger.error.assert_not_called() + + _NotificationCenterRegistry.get_notification_center(None, logger) + + logger.error.assert_called_once_with(f'{Errors.MISSING_SDK_KEY} ODP may not work properly without it.') + + client.close() + + def test_only_one_notification_center_created(self): + logger = mock.MagicMock() + sdk_key = 'single' + notification_center = _NotificationCenterRegistry.get_notification_center(sdk_key, logger) + client = Optimizely(sdk_key=sdk_key, logger=logger) + + self.assertIs(notification_center, _NotificationCenterRegistry.get_notification_center(sdk_key, logger)) + + logger.error.assert_not_called() + + client.close() + + def test_remove_notification_center(self): + logger = mock.MagicMock() + sdk_key = 'segments-test' + test_datafile = json.dumps(self.config_dict_with_audience_segments) + test_response = self.fake_server_response(status_code=200, content=test_datafile) + notification_center = _NotificationCenterRegistry.get_notification_center(sdk_key, logger) + + with mock.patch('requests.get', return_value=test_response), \ + mock.patch.object(notification_center, 'send_notifications') as mock_send: + + client = Optimizely(sdk_key=sdk_key, logger=logger) + client.config_manager.get_config() + + mock_send.assert_called_once() + mock_send.reset_mock() + + _NotificationCenterRegistry.remove_notification_center(sdk_key) + self.assertNotIn(notification_center, _NotificationCenterRegistry._notification_centers) + + revised_datafile = copy.deepcopy(self.config_dict_with_audience_segments) + revised_datafile['revision'] = str(int(revised_datafile['revision']) + 1) + + # trigger notification + client.config_manager._set_config(json.dumps(revised_datafile)) + mock_send.assert_not_called() + + logger.error.assert_not_called() + + client.close() diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index c6132598..c0a69cf1 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -12,6 +12,7 @@ # limitations under the License. import json +import time from operator import itemgetter from unittest import mock @@ -25,6 +26,7 @@ from optimizely import logger from optimizely import optimizely from optimizely import optimizely_config +from optimizely.odp.odp_config import OdpConfigState from optimizely import project_config from optimizely import version from optimizely.event.event_factory import EventFactory @@ -92,7 +94,10 @@ def test_init__invalid_datafile__logs_error(self): with mock.patch('optimizely.logger.reset_logger', return_value=mock_client_logger): opt_obj = optimizely.Optimizely('invalid_datafile') - mock_client_logger.error.assert_called_once_with('Provided "datafile" is in an invalid format.') + mock_client_logger.error.assert_has_calls([ + mock.call('Provided "datafile" is in an invalid format.'), + mock.call(f'{enums.Errors.MISSING_SDK_KEY} ODP may not work properly without it.') + ], any_order=True) self.assertIsNone(opt_obj.config_manager.get_config()) def test_init__null_datafile__logs_error(self): @@ -102,7 +107,10 @@ def test_init__null_datafile__logs_error(self): with mock.patch('optimizely.logger.reset_logger', return_value=mock_client_logger): opt_obj = optimizely.Optimizely(None) - mock_client_logger.error.assert_called_once_with('Provided "datafile" is in an invalid format.') + mock_client_logger.error.assert_has_calls([ + mock.call('Provided "datafile" is in an invalid format.'), + mock.call(f'{enums.Errors.MISSING_SDK_KEY} ODP may not work properly without it.') + ], any_order=True) self.assertIsNone(opt_obj.config_manager.get_config()) def test_init__empty_datafile__logs_error(self): @@ -112,7 +120,10 @@ def test_init__empty_datafile__logs_error(self): with mock.patch('optimizely.logger.reset_logger', return_value=mock_client_logger): opt_obj = optimizely.Optimizely("") - mock_client_logger.error.assert_called_once_with('Provided "datafile" is in an invalid format.') + mock_client_logger.error.assert_has_calls([ + mock.call('Provided "datafile" is in an invalid format.'), + mock.call(f'{enums.Errors.MISSING_SDK_KEY} ODP may not work properly without it.') + ], any_order=True) self.assertIsNone(opt_obj.config_manager.get_config()) def test_init__invalid_config_manager__logs_error(self): @@ -204,9 +215,10 @@ def test_init__unsupported_datafile_version__logs_error(self): ) as mock_error_handler: opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_unsupported_version)) - mock_client_logger.error.assert_called_once_with( - 'This version of the Python SDK does not support the given datafile version: "5".' - ) + mock_client_logger.error.assert_has_calls([ + mock.call(f'{enums.Errors.MISSING_SDK_KEY} ODP may not work properly without it.'), + mock.call('This version of the Python SDK does not support the given datafile version: "5".') + ], any_order=True) args, kwargs = mock_error_handler.call_args self.assertIsInstance(args[0], exceptions.UnsupportedDatafileVersionException) @@ -276,7 +288,10 @@ def test_invalid_json_raises_schema_validation_off(self): ) as mock_error_handler: opt_obj = optimizely.Optimizely('invalid_json', skip_json_validation=True) - mock_client_logger.error.assert_called_once_with('Provided "datafile" is in an invalid format.') + mock_client_logger.error.assert_has_calls([ + mock.call('Provided "datafile" is in an invalid format.'), + mock.call(f'{enums.Errors.MISSING_SDK_KEY} ODP may not work properly without it.') + ], any_order=True) args, kwargs = mock_error_handler.call_args self.assertIsInstance(args[0], exceptions.InvalidInputException) self.assertEqual(args[0].args[0], 'Provided "datafile" is in an invalid format.') @@ -293,7 +308,10 @@ def test_invalid_json_raises_schema_validation_off(self): {'version': '2', 'events': 'invalid_value', 'experiments': 'invalid_value'}, skip_json_validation=True, ) - mock_client_logger.error.assert_called_once_with('Provided "datafile" is in an invalid format.') + mock_client_logger.error.assert_has_calls([ + mock.call('Provided "datafile" is in an invalid format.'), + mock.call(f'{enums.Errors.MISSING_SDK_KEY} ODP may not work properly without it.') + ], any_order=True) args, kwargs = mock_error_handler.call_args self.assertIsInstance(args[0], exceptions.InvalidInputException) self.assertEqual(args[0].args[0], 'Provided "datafile" is in an invalid format.') @@ -4616,6 +4634,9 @@ def test_get_optimizely_config_with_custom_config_manager(self): return_config = some_obj.config_manager.get_config() class SomeConfigManager: + def get_sdk_key(self): + return return_config.sdk_key + def get_config(self): return return_config @@ -4631,6 +4652,57 @@ def get_config(self): self.assertEqual(1, mock_opt_service.call_count) + def test_odp_updated_with_custom_polling_config(self): + logger = mock.MagicMock() + + test_datafile = json.dumps(self.config_dict_with_audience_segments) + test_response = self.fake_server_response(status_code=200, content=test_datafile) + + def delay(*args, **kwargs): + time.sleep(.5) + return mock.DEFAULT + + with mock.patch('requests.get', return_value=test_response, side_effect=delay): + # initialize config_manager with delay, so it will receive the datafile after client initialization + custom_config_manager = config_manager.PollingConfigManager(sdk_key='segments-test', logger=logger) + client = optimizely.Optimizely(config_manager=custom_config_manager) + odp_manager = client.odp_manager + + # confirm odp config has not yet been updated + self.assertEqual(odp_manager.odp_config.odp_state(), OdpConfigState.UNDETERMINED) + + # wait for datafile + custom_config_manager.get_config() + + # wait for odp config to be updated + odp_manager.event_manager.event_queue.join() + + self.assertEqual(odp_manager.odp_config.odp_state(), OdpConfigState.INTEGRATED) + + logger.error.assert_not_called() + + client.close() + + def test_odp_events_not_sent_with_legacy_apis(self): + logger = mock.MagicMock() + experiment_key = 'experiment-segment' + feature_key = 'flag-segment' + user_id = 'test_user' + + test_datafile = json.dumps(self.config_dict_with_audience_segments) + client = optimizely.Optimizely(test_datafile, logger=logger) + + with mock.patch.object(client.odp_manager.event_manager, 'send_event') as send_event_mock: + client.activate(experiment_key, user_id) + client.track('event1', user_id) + client.get_variation(experiment_key, user_id) + client.get_all_feature_variables(feature_key, user_id) + client.is_feature_enabled(feature_key, user_id) + + send_event_mock.assert_not_called() + + client.close() + class OptimizelyWithExceptionTest(base.BaseTest): def setUp(self): diff --git a/tests/test_optimizely_config.py b/tests/test_optimizely_config.py index 640100d7..e33c1272 100644 --- a/tests/test_optimizely_config.py +++ b/tests/test_optimizely_config.py @@ -26,7 +26,7 @@ def setUp(self): self.opt_config_service = optimizely_config.OptimizelyConfigService(self.project_config) self.expected_config = { - 'sdk_key': '', + 'sdk_key': 'features-test', 'environment_key': '', 'attributes': [{'key': 'test_attribute', 'id': '111094'}], 'events': [{'key': 'test_event', 'experiment_ids': ['111127'], 'id': '111095'}], diff --git a/tests/test_optimizely_factory.py b/tests/test_optimizely_factory.py index 1792f80f..be41755a 100644 --- a/tests/test_optimizely_factory.py +++ b/tests/test_optimizely_factory.py @@ -12,9 +12,11 @@ # limitations under the License. import json +import time from unittest import mock from optimizely.config_manager import PollingConfigManager +from optimizely.odp.odp_config import OdpConfigState from optimizely.error_handler import NoOpErrorHandler from optimizely.event_dispatcher import EventDispatcher from optimizely.notification_center import NotificationCenter @@ -26,6 +28,10 @@ @mock.patch('requests.get') class OptimizelyFactoryTest(base.BaseTest): + def delay(*args, **kwargs): + time.sleep(.5) + return mock.DEFAULT + def setUp(self): super().setUp() self.datafile = '{ revision: "42" }' @@ -181,3 +187,82 @@ def test_update_odp_config_correctly(self, _): self.assertEqual(odp_config.get_api_host(), odp_settings['host']) client.close() + + def test_update_odp_config_correctly_with_custom_config_manager_and_delay(self, _): + logger = mock.MagicMock() + + test_datafile = json.dumps(self.config_dict_with_audience_segments) + test_response = self.fake_server_response(status_code=200, content=test_datafile) + + with mock.patch('requests.get', return_value=test_response, side_effect=self.delay): + # initialize config_manager with delay, so it will receive the datafile after client initialization + config_manager = PollingConfigManager(sdk_key='test', logger=logger) + client = OptimizelyFactory.default_instance_with_config_manager(config_manager=config_manager) + odp_manager = client.odp_manager + + # confirm odp config has not yet been updated + self.assertEqual(odp_manager.odp_config.odp_state(), OdpConfigState.UNDETERMINED) + + # wait for datafile + client.config_manager.get_config() + + # wait for odp config to be updated + odp_manager.event_manager.event_queue.join() + + self.assertEqual(odp_manager.odp_config.odp_state(), OdpConfigState.INTEGRATED) + + logger.error.assert_not_called() + + client.close() + + def test_update_odp_config_correctly_with_delay(self, _): + logger = mock.MagicMock() + + test_datafile = json.dumps(self.config_dict_with_audience_segments) + test_response = self.fake_server_response(status_code=200, content=test_datafile) + + with mock.patch('requests.get', return_value=test_response, side_effect=self.delay): + # initialize config_manager with delay, so it will receive the datafile after client initialization + client = OptimizelyFactory.default_instance(sdk_key='test') + odp_manager = client.odp_manager + + # confirm odp config has not yet been updated + self.assertEqual(odp_manager.odp_config.odp_state(), OdpConfigState.UNDETERMINED) + + # wait for datafile + client.config_manager.get_config() + + # wait for odp config to be updated + odp_manager.event_manager.event_queue.join() + + self.assertEqual(odp_manager.odp_config.odp_state(), OdpConfigState.INTEGRATED) + + logger.error.assert_not_called() + + client.close() + + def test_odp_updated_with_custom_instance(self, _): + logger = mock.MagicMock() + + test_datafile = json.dumps(self.config_dict_with_audience_segments) + test_response = self.fake_server_response(status_code=200, content=test_datafile) + + with mock.patch('requests.get', return_value=test_response, side_effect=self.delay): + # initialize config_manager with delay, so it will receive the datafile after client initialization + client = OptimizelyFactory.custom_instance(sdk_key='test') + odp_manager = client.odp_manager + + # confirm odp config has not yet been updated + self.assertEqual(odp_manager.odp_config.odp_state(), OdpConfigState.UNDETERMINED) + + # wait for datafile + client.config_manager.get_config() + + # wait for odp config to be updated + odp_manager.event_manager.event_queue.join() + + self.assertEqual(odp_manager.odp_config.odp_state(), OdpConfigState.INTEGRATED) + + logger.error.assert_not_called() + + client.close()