diff --git a/optimizely/exceptions.py b/optimizely/exceptions.py index d6003ab1..e7644064 100644 --- a/optimizely/exceptions.py +++ b/optimizely/exceptions.py @@ -64,3 +64,21 @@ class UnsupportedDatafileVersionException(Exception): """ Raised when provided version in datafile is not supported. """ pass + + +class OdpNotEnabled(Exception): + """ Raised when Optimizely Data Platform (ODP) is not enabled. """ + + pass + + +class OdpNotIntegrated(Exception): + """ Raised when Optimizely Data Platform (ODP) is not integrated. """ + + pass + + +class OdpInvalidData(Exception): + """ Raised when passing invalid ODP data. """ + + pass diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 02bc9136..886d269a 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -123,8 +123,9 @@ class Errors: INVALID_SEGMENT_IDENTIFIER: Final = 'Audience segments fetch failed (invalid identifier).' FETCH_SEGMENTS_FAILED: Final = 'Audience segments fetch failed ({}).' ODP_EVENT_FAILED: Final = 'ODP event send failed ({}).' - ODP_NOT_ENABLED: Final = 'ODP is not enabled.' 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.' class ForcedDecisionLogs: @@ -214,3 +215,15 @@ class OdpEventManagerConfig: DEFAULT_BATCH_SIZE: Final = 10 DEFAULT_FLUSH_INTERVAL: Final = 1 DEFAULT_RETRY_COUNT: Final = 3 + + +class OdpManagerConfig: + """ODP Manager configs.""" + KEY_FOR_USER_ID: Final = 'fs_user_id' + EVENT_TYPE: Final = 'fullstack' + + +class OdpSegmentsCacheConfig: + """ODP Segment Cache configs.""" + DEFAULT_CAPACITY: Final = 10_000 + DEFAULT_TIMEOUT_SECS: Final = 600 diff --git a/optimizely/odp/odp_event_manager.py b/optimizely/odp/odp_event_manager.py index df02e3ed..f608213e 100644 --- a/optimizely/odp/odp_event_manager.py +++ b/optimizely/odp/odp_event_manager.py @@ -12,23 +12,25 @@ # limitations under the License. from __future__ import annotations + +import time from enum import Enum +from queue import Empty, Queue, Full from threading import Thread from typing import Optional -import time -from queue import Empty, Queue, Full from optimizely import logger as _logging -from .odp_event import OdpEvent, OdpDataDict +from optimizely.helpers.enums import OdpEventManagerConfig, Errors, OdpManagerConfig from .odp_config import OdpConfig, OdpConfigState +from .odp_event import OdpEvent, OdpDataDict from .zaius_rest_api_manager import ZaiusRestApiManager -from optimizely.helpers.enums import OdpEventManagerConfig, Errors class Signal(Enum): """Enum for sending signals to the event queue.""" SHUTDOWN = 1 FLUSH = 2 + UPDATE_CONFIG = 3 class OdpEventManager: @@ -55,7 +57,11 @@ def __init__( """ self.logger = logger or _logging.NoOpLogger() self.zaius_manager = api_manager or ZaiusRestApiManager(self.logger) + self.odp_config = odp_config + self.api_key = odp_config.get_api_key() + self.api_host = odp_config.get_api_host() + self.event_queue: Queue[OdpEvent | Signal] = Queue(OdpEventManagerConfig.DEFAULT_QUEUE_CAPACITY) self.batch_size = OdpEventManagerConfig.DEFAULT_BATCH_SIZE self.flush_interval = OdpEventManagerConfig.DEFAULT_FLUSH_INTERVAL @@ -101,7 +107,11 @@ def _run(self) -> None: self.logger.debug('ODP event queue: received flush signal.') self._flush_batch() self.event_queue.task_done() - continue + + elif item == Signal.UPDATE_CONFIG: + self.logger.debug('ODP event queue: received update config signal.') + self._update_config() + self.event_queue.task_done() elif isinstance(item, OdpEvent): self._add_to_batch(item) @@ -136,10 +146,7 @@ def _flush_batch(self) -> None: self.logger.debug('ODP event queue: nothing to flush.') return - api_key = self.odp_config.get_api_key() - api_host = self.odp_config.get_api_host() - - if not api_key or not api_host: + if not self.api_key or not self.api_host: self.logger.debug(Errors.ODP_NOT_INTEGRATED) self._current_batch.clear() return @@ -149,7 +156,7 @@ def _flush_batch(self) -> None: for i in range(1 + self.retry_count): try: - should_retry = self.zaius_manager.send_odp_events(api_key, api_host, self._current_batch) + should_retry = self.zaius_manager.send_odp_events(self.api_key, self.api_host, self._current_batch) except Exception as error: should_retry = False self.logger.error(Errors.ODP_EVENT_FAILED.format(f'Error: {error} {self._current_batch}')) @@ -236,3 +243,22 @@ def dispatch(self, event: OdpEvent) -> None: self.event_queue.put_nowait(event) except Full: self.logger.warning(Errors.ODP_EVENT_FAILED.format("Queue is full")) + + def identify_user(self, user_id: str) -> None: + self.send_event(OdpManagerConfig.EVENT_TYPE, 'identified', + {OdpManagerConfig.KEY_FOR_USER_ID: user_id}, {}) + + def update_config(self) -> None: + """Adds update config signal to event_queue.""" + try: + self.event_queue.put_nowait(Signal.UPDATE_CONFIG) + except Full: + self.logger.error("Error updating ODP config for the event queue") + + def _update_config(self) -> None: + """Updates the configuration used to send events.""" + if len(self._current_batch) > 0: + self._flush_batch() + + self.api_host = self.odp_config.get_api_host() + self.api_key = self.odp_config.get_api_key() diff --git a/optimizely/odp/odp_manager.py b/optimizely/odp/odp_manager.py new file mode 100644 index 00000000..72c61514 --- /dev/null +++ b/optimizely/odp/odp_manager.py @@ -0,0 +1,133 @@ +# Copyright 2022, 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 typing import Optional, Any + +from optimizely import exceptions as optimizely_exception +from optimizely import logger as optimizely_logger +from optimizely.helpers.enums import Errors, OdpManagerConfig, OdpSegmentsCacheConfig +from optimizely.helpers.validator import are_odp_data_types_valid +from optimizely.odp.lru_cache import OptimizelySegmentsCache, LRUCache +from optimizely.odp.odp_config import OdpConfig, OdpConfigState +from optimizely.odp.odp_event_manager import OdpEventManager +from optimizely.odp.odp_segment_manager import OdpSegmentManager +from optimizely.odp.zaius_graphql_api_manager import ZaiusGraphQLApiManager + + +class OdpManager: + """Orchestrates segment manager, event manager and odp config.""" + + def __init__( + self, + disable: bool, + segments_cache: Optional[OptimizelySegmentsCache] = None, + segment_manager: Optional[OdpSegmentManager] = None, + event_manager: Optional[OdpEventManager] = None, + logger: Optional[optimizely_logger.Logger] = None + ) -> None: + + self.enabled = not disable + self.odp_config = OdpConfig() + self.logger = logger or optimizely_logger.NoOpLogger() + + self.segment_manager = segment_manager + self.event_manager = event_manager + + if not self.enabled: + self.logger.info('ODP is disabled.') + return + + if not self.segment_manager: + if not segments_cache: + segments_cache = LRUCache( + OdpSegmentsCacheConfig.DEFAULT_CAPACITY, + OdpSegmentsCacheConfig.DEFAULT_TIMEOUT_SECS + ) + self.segment_manager = OdpSegmentManager( + self.odp_config, + segments_cache, + ZaiusGraphQLApiManager(logger), logger + ) + else: + self.segment_manager.odp_config = self.odp_config + + if event_manager: + event_manager.odp_config = self.odp_config + self.event_manager = event_manager + else: + self.event_manager = OdpEventManager(self.odp_config, logger) + + self.event_manager.start() + + def fetch_qualified_segments(self, user_id: str, options: list[str]) -> Optional[list[str]]: + if not self.enabled or not self.segment_manager: + self.logger.error(Errors.ODP_NOT_ENABLED) + return None + + user_key = OdpManagerConfig.KEY_FOR_USER_ID + user_value = user_id + + return self.segment_manager.fetch_qualified_segments(user_key, user_value, options) + + def identify_user(self, user_id: str) -> None: + if not self.enabled or not self.event_manager: + self.logger.debug('ODP identify event is not dispatched (ODP disabled).') + return + if self.odp_config.odp_state() == OdpConfigState.NOT_INTEGRATED: + self.logger.debug('ODP identify event is not dispatched (ODP not integrated).') + return + + self.event_manager.identify_user(user_id) + + def send_event(self, type: str, action: str, identifiers: dict[str, str], data: dict[str, Any]) -> None: + """ + Send an event to the ODP server. + + Args: + type: The event type. + action: The event action name. + identifiers: A dictionary for identifiers. + data: A dictionary for associated data. The default event data will be added to this data + before sending to the ODP server. + + Raises custom exception if error is detected. + """ + if not self.enabled or not self.event_manager: + raise optimizely_exception.OdpNotEnabled(Errors.ODP_NOT_ENABLED) + + if self.odp_config.odp_state() == OdpConfigState.NOT_INTEGRATED: + raise optimizely_exception.OdpNotIntegrated(Errors.ODP_NOT_INTEGRATED) + + if not are_odp_data_types_valid(data): + raise optimizely_exception.OdpInvalidData(Errors.ODP_INVALID_DATA) + + self.event_manager.send_event(type, action, identifiers, data) + + def update_odp_config(self, api_key: Optional[str], api_host: Optional[str], + segments_to_check: list[str]) -> None: + if not self.enabled: + return + + config_changed = self.odp_config.update(api_key, api_host, segments_to_check) + if not config_changed: + self.logger.debug('Odp config was not changed.') + return + + # reset segments cache when odp integration or segments to check are changed + if self.segment_manager: + self.segment_manager.reset() + + if self.event_manager: + self.event_manager.update_config() diff --git a/optimizely/odp/odp_segment_manager.py b/optimizely/odp/odp_segment_manager.py index 33c829a1..a5d363fd 100644 --- a/optimizely/odp/odp_segment_manager.py +++ b/optimizely/odp/odp_segment_manager.py @@ -26,17 +26,21 @@ class OdpSegmentManager: """Schedules connections to ODP for audience segmentation and caches the results.""" - def __init__(self, odp_config: OdpConfig, segments_cache: OptimizelySegmentsCache, - zaius_manager: ZaiusGraphQLApiManager, - logger: Optional[optimizely_logger.Logger] = None) -> None: + def __init__( + self, + odp_config: OdpConfig, + segments_cache: OptimizelySegmentsCache, + zaius_manager: ZaiusGraphQLApiManager, + logger: Optional[optimizely_logger.Logger] = None + ) -> None: self.odp_config = odp_config self.segments_cache = segments_cache self.zaius_manager = zaius_manager self.logger = logger or optimizely_logger.NoOpLogger() - def fetch_qualified_segments(self, user_key: str, user_value: str, options: list[str]) -> \ - Optional[list[str]]: + def fetch_qualified_segments(self, user_key: str, user_value: str, options: list[str] + ) -> Optional[list[str]]: """ Args: user_key: The key for identifying the id type. @@ -64,7 +68,7 @@ def fetch_qualified_segments(self, user_key: str, user_value: str, options: list reset_cache = OptimizelyOdpOption.RESET_CACHE in options if reset_cache: - self._reset() + self.reset() if not ignore_cache and not reset_cache: segments = self.segments_cache.lookup(cache_key) @@ -83,7 +87,7 @@ def fetch_qualified_segments(self, user_key: str, user_value: str, options: list return segments - def _reset(self) -> None: + def reset(self) -> None: self.segments_cache.reset() def make_cache_key(self, user_key: str, user_value: str) -> str: diff --git a/tests/test_odp_event_manager.py b/tests/test_odp_event_manager.py index ffbab40d..ea90ada5 100644 --- a/tests/test_odp_event_manager.py +++ b/tests/test_odp_event_manager.py @@ -411,6 +411,7 @@ def test_odp_event_manager_events_before_odp_ready(self, *args): event_manager.send_event(**self.events[1]) odp_config.update(self.api_key, self.api_host, []) + event_manager.update_config() event_manager.event_queue.join() event_manager.send_event(**self.events[0]) @@ -423,6 +424,7 @@ def test_odp_event_manager_events_before_odp_ready(self, *args): mock_logger.debug.assert_has_calls([ mock.call('ODP event queue: cannot send before the datafile has loaded.'), mock.call('ODP event queue: cannot send before the datafile has loaded.'), + mock.call('ODP event queue: received update config signal.'), mock.call('ODP event queue: adding event.'), mock.call('ODP event queue: adding event.'), mock.call('ODP event queue: received flush signal.'), @@ -442,6 +444,7 @@ def test_odp_event_manager_events_before_odp_disabled(self, *args): event_manager.send_event(**self.events[1]) odp_config.update(None, None, []) + event_manager.update_config() event_manager.event_queue.join() event_manager.send_event(**self.events[0]) @@ -453,6 +456,7 @@ def test_odp_event_manager_events_before_odp_disabled(self, *args): mock_logger.debug.assert_has_calls([ mock.call('ODP event queue: cannot send before the datafile has loaded.'), mock.call('ODP event queue: cannot send before the datafile has loaded.'), + mock.call('ODP event queue: received update config signal.'), mock.call(Errors.ODP_NOT_INTEGRATED), mock.call(Errors.ODP_NOT_INTEGRATED) ]) @@ -496,20 +500,25 @@ def test_odp_event_manager_disabled_after_events_in_queue(self, *args): odp_config = OdpConfig(self.api_key, self.api_host) event_manager = OdpEventManager(odp_config, mock_logger) - event_manager.batch_size = 2 + event_manager.batch_size = 3 with mock.patch('optimizely.odp.odp_event_manager.OdpEventManager.is_running', True): event_manager.send_event(**self.events[0]) event_manager.send_event(**self.events[1]) - - with mock.patch.object(event_manager.zaius_manager, 'send_odp_events') as mock_send: odp_config.update(None, None, []) + event_manager.update_config() + + with mock.patch.object( + event_manager.zaius_manager, 'send_odp_events', new_callable=CopyingMock, return_value=False + ) as mock_send: event_manager.start() + event_manager.send_event(**self.events[0]) event_manager.send_event(**self.events[1]) + event_manager.send_event(**self.events[0]) event_manager.event_queue.join() self.assertEqual(len(event_manager._current_batch), 0) mock_logger.debug.assert_any_call(Errors.ODP_NOT_INTEGRATED) mock_logger.error.assert_not_called() - mock_send.assert_not_called() + mock_send.assert_called_once_with(self.api_key, self.api_host, self.processed_events) event_manager.stop() diff --git a/tests/test_odp_manager.py b/tests/test_odp_manager.py new file mode 100644 index 00000000..d60d40c9 --- /dev/null +++ b/tests/test_odp_manager.py @@ -0,0 +1,402 @@ +# Copyright 2022, 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 unittest import mock + +from optimizely import exceptions as optimizely_exception +from optimizely import version +from optimizely.helpers.enums import Errors +from optimizely.odp.lru_cache import OptimizelySegmentsCache, LRUCache +from optimizely.odp.odp_config import OdpConfig +from optimizely.odp.odp_event_manager import OdpEventManager +from optimizely.odp.odp_manager import OdpManager +from optimizely.odp.odp_segment_manager import OdpSegmentManager +from optimizely.odp.zaius_graphql_api_manager import ZaiusGraphQLApiManager +from optimizely.odp.zaius_rest_api_manager import ZaiusRestApiManager +from tests import base + + +class OdpManagerTest(base.BaseTest): + + def test_configurations_disable_odp(self): + mock_logger = mock.MagicMock() + manager = OdpManager(True, OptimizelySegmentsCache, logger=mock_logger) + + mock_logger.info.assert_called_once_with('ODP is disabled.') + manager.update_odp_config('valid', 'host', []) + self.assertIsNone(manager.odp_config.get_api_key()) + self.assertIsNone(manager.odp_config.get_api_host()) + + manager.fetch_qualified_segments('user1', []) + mock_logger.error.assert_called_once_with(Errors.ODP_NOT_ENABLED) + + # these call should be dropped gracefully with None + manager.identify_user('user1') + + self.assertRaisesRegex(optimizely_exception.OdpNotEnabled, Errors.ODP_NOT_ENABLED, + manager.send_event, 't1', 'a1', {}, {}) + + self.assertIsNone(manager.event_manager) + self.assertIsNone(manager.segment_manager) + + def test_fetch_qualified_segments(self): + mock_logger = mock.MagicMock() + segment_manager = OdpSegmentManager(OdpConfig(), OptimizelySegmentsCache, + ZaiusGraphQLApiManager(mock_logger), mock_logger) + + manager = OdpManager(False, OptimizelySegmentsCache, segment_manager, logger=mock_logger) + + with mock.patch.object(segment_manager, 'fetch_qualified_segments') as mock_fetch_qualif_segments: + manager.fetch_qualified_segments('user1', []) + + mock_logger.debug.assert_not_called() + mock_logger.error.assert_not_called() + mock_fetch_qualif_segments.assert_called_once_with('fs_user_id', 'user1', []) + + with mock.patch.object(segment_manager, 'fetch_qualified_segments') as mock_fetch_qualif_segments: + manager.fetch_qualified_segments('user1', ['IGNORE_CACHE']) + + mock_logger.debug.assert_not_called() + mock_logger.error.assert_not_called() + mock_fetch_qualif_segments.assert_called_once_with('fs_user_id', 'user1', ['IGNORE_CACHE']) + + def test_fetch_qualified_segments__disabled(self): + mock_logger = mock.MagicMock() + segment_manager = OdpSegmentManager(OdpConfig(), OptimizelySegmentsCache, + ZaiusGraphQLApiManager(mock_logger), mock_logger) + + manager = OdpManager(True, OptimizelySegmentsCache, segment_manager, logger=mock_logger) + + with mock.patch.object(segment_manager, 'fetch_qualified_segments') as mock_fetch_qualif_segments: + manager.fetch_qualified_segments('user1', []) + mock_logger.error.assert_called_once_with(Errors.ODP_NOT_ENABLED) + mock_fetch_qualif_segments.assert_not_called() + + def test_fetch_qualified_segments__segment_mgr_is_none(self): + """ + When segment manager is None, then fetching segment + should take place using the default segment manager. + """ + mock_logger = mock.MagicMock() + manager = OdpManager(False, LRUCache(10, 20), logger=mock_logger) + manager.update_odp_config('api_key', 'api_host', []) + + with mock.patch.object(manager.segment_manager, 'fetch_qualified_segments') as mock_fetch_qualif_segments: + manager.fetch_qualified_segments('user1', []) + + mock_logger.error.assert_not_called() + mock_fetch_qualif_segments.assert_called_once_with('fs_user_id', 'user1', []) + + def test_fetch_qualified_segments__seg_cache_and_seg_mgr_are_none(self): + """ + When segment cache and segment manager are None, then fetching segment + should take place using the default managers. + """ + mock_logger = mock.MagicMock() + manager = OdpManager(False, mock_logger) + manager.update_odp_config('api_key', 'api_host', []) + + with mock.patch.object(manager.segment_manager, 'fetch_qualified_segments') as mock_fetch_qualif_segments: + manager.fetch_qualified_segments('user1', []) + + mock_logger.debug.assert_not_called() + mock_logger.error.assert_not_called() + mock_fetch_qualif_segments.assert_called_once_with('fs_user_id', 'user1', []) + + def test_identify_user_datafile_not_ready(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger) + + manager = OdpManager(False, OptimizelySegmentsCache, event_manager=event_manager, logger=mock_logger) + + with mock.patch.object(event_manager, 'identify_user') as mock_identify_user: + manager.identify_user('user1') + + mock_identify_user.assert_called_once_with('user1') + mock_logger.error.assert_not_called() + + def test_identify_user_odp_integrated(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, LRUCache(10, 20), event_manager=event_manager, logger=mock_logger) + manager.update_odp_config('key1', 'host1', []) + + with mock.patch.object(event_manager, 'dispatch') as mock_dispatch_event: + manager.identify_user('user1') + + mock_dispatch_event.assert_called_once_with({ + 'type': 'fullstack', + 'action': 'identified', + 'identifiers': {'fs_user_id': 'user1'}, + 'data': { + 'idempotence_id': mock.ANY, + 'data_source_type': 'sdk', + 'data_source': 'python-sdk', + 'data_source_version': version.__version__ + }}) + mock_logger.error.assert_not_called() + + def test_identify_user_odp_not_integrated(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, OptimizelySegmentsCache, event_manager=event_manager, logger=mock_logger) + manager.update_odp_config(None, None, []) + + with mock.patch.object(event_manager, 'dispatch') as mock_dispatch_event: + manager.identify_user('user1') + + mock_dispatch_event.assert_not_called() + mock_logger.error.assert_not_called() + mock_logger.debug.assert_any_call('Odp config was not changed.') + mock_logger.debug.assert_any_call('ODP identify event is not dispatched (ODP not integrated).') + + def test_identify_user_odp_disabled(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, OptimizelySegmentsCache, event_manager=event_manager, logger=mock_logger) + manager.enabled = False + + with mock.patch.object(event_manager, 'identify_user') as mock_identify_user: + manager.identify_user('user1') + + mock_identify_user.assert_not_called() + mock_logger.error.assert_not_called() + mock_logger.debug.assert_called_with('ODP identify event is not dispatched (ODP disabled).') + + def test_send_event_datafile_not_ready(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, OptimizelySegmentsCache, event_manager=event_manager, logger=mock_logger) + + with mock.patch.object(event_manager, 'dispatch') as mock_dispatch_event: + manager.send_event('t1', 'a1', {'id-key1': 'id-val-1'}, {'key1': 'val1'}) + + mock_dispatch_event.assert_not_called() + mock_logger.error.assert_not_called() + mock_logger.debug.assert_called_with('ODP event queue: cannot send before the datafile has loaded.') + + def test_send_event_odp_integrated(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, LRUCache(10, 20), event_manager=event_manager, logger=mock_logger) + manager.update_odp_config('key1', 'host1', []) + + with mock.patch.object(event_manager, 'dispatch') as mock_dispatch_event: + manager.send_event('t1', 'a1', {'id-key1': 'id-val-1'}, {'key1': 'val1'}) + + mock_dispatch_event.assert_called_once_with({ + 'type': 't1', + 'action': 'a1', + 'identifiers': {'id-key1': 'id-val-1'}, + 'data': { + 'idempotence_id': mock.ANY, + 'data_source_type': 'sdk', + 'data_source': 'python-sdk', + 'data_source_version': version.__version__, + 'key1': 'val1' + }}) + + def test_send_event_odp_not_integrated(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, OptimizelySegmentsCache, event_manager=event_manager, logger=mock_logger) + manager.update_odp_config(None, None, []) + + with mock.patch.object(event_manager, 'dispatch') as mock_dispatch_event: + self.assertRaisesRegex(optimizely_exception.OdpNotIntegrated, Errors.ODP_NOT_INTEGRATED, + manager.send_event, 't1', 'a1', {'id-key1': 'id-val-1'}, {'key1': 'val1'}) + + mock_dispatch_event.assert_not_called() + mock_logger.debug.assert_any_call('Odp config was not changed.') + mock_logger.error.assert_not_called() + + def test_send_event_odp_disabled(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, OptimizelySegmentsCache, event_manager=event_manager, logger=mock_logger) + manager.enabled = False + + with mock.patch.object(event_manager, 'dispatch') as mock_dispatch_event: + self.assertRaisesRegex(optimizely_exception.OdpNotEnabled, Errors.ODP_NOT_ENABLED, + manager.send_event, 't1', 'a1', {'id-key1': 'id-val-1'}, {'key1': 'val1'}) + + mock_dispatch_event.assert_not_called() + mock_logger.debug.assert_not_called() + mock_logger.error.assert_not_called() + + def test_send_event_odp_disabled__event_manager_not_available(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, OptimizelySegmentsCache, event_manager=event_manager, logger=mock_logger) + manager.event_manager = False + + with mock.patch.object(event_manager, 'dispatch') as mock_dispatch_event: + self.assertRaisesRegex(optimizely_exception.OdpNotEnabled, Errors.ODP_NOT_ENABLED, + manager.send_event, 't1', 'a1', {'id-key1': 'id-val-1'}, {'key1': 'val1'}) + + mock_dispatch_event.assert_not_called() + mock_logger.debug.assert_not_called() + mock_logger.error.assert_not_called() + + def test_send_event_invalid_data(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, LRUCache(10, 20), event_manager=event_manager, logger=mock_logger) + manager.update_odp_config('key1', 'host1', []) + + with mock.patch.object(event_manager, 'dispatch') as mock_dispatch_event: + self.assertRaisesRegex(optimizely_exception.OdpInvalidData, Errors.ODP_INVALID_DATA, + manager.send_event, 't1', 'a1', {'id-key1': 'id-val-1'}, {'invalid-item': {}}) + + mock_dispatch_event.assert_not_called() + mock_logger.error.assert_not_called() + + def test_config_not_changed(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, OptimizelySegmentsCache, event_manager=event_manager, logger=mock_logger) + manager.update_odp_config(None, None, []) + mock_logger.debug.assert_called_with('Odp config was not changed.') + mock_logger.error.assert_not_called() + + def test_update_odp_config__reset_called(self): + # build segment manager + mock_logger = mock.MagicMock() + segment_manager = OdpSegmentManager(OdpConfig(), OptimizelySegmentsCache, + ZaiusGraphQLApiManager(mock_logger), mock_logger) + # build event manager + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + + manager = OdpManager(False, OptimizelySegmentsCache, segment_manager, event_manager, mock_logger) + + with mock.patch.object(segment_manager, 'reset') as mock_reset: + manager.update_odp_config('key1', 'host1', []) + mock_reset.assert_called_once() + mock_reset.reset_mock() + + manager.update_odp_config('key1', 'host1', []) + mock_reset.assert_not_called() + + manager.update_odp_config('key2', 'host1', []) + mock_reset.assert_called_once() + mock_reset.reset_mock() + + manager.update_odp_config('key2', 'host2', []) + mock_reset.assert_called_once() + mock_reset.reset_mock() + + manager.update_odp_config('key2', 'host2', ['a']) + mock_reset.assert_called_once() + mock_reset.reset_mock() + + manager.update_odp_config('key2', 'host2', ['a', 'b']) + mock_reset.assert_called_once() + mock_reset.reset_mock() + + manager.update_odp_config('key2', 'host2', ['c']) + mock_reset.assert_called_once() + mock_reset.reset_mock() + + manager.update_odp_config('key2', 'host2', ['c']) + mock_reset.assert_not_called() + + manager.update_odp_config(None, None, []) + mock_reset.assert_called_once() + mock_logger.error.assert_not_called() + + def test_update_odp_config__update_config_called(self): + """ + Test if event_manager.update_config is called when change + to odp_config is made or not in OdpManager. + """ + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + manager = OdpManager(False, LRUCache(10, 20), event_manager=event_manager, logger=mock_logger) + + with mock.patch.object(event_manager, 'update_config') as mock_update: + first_api_key = manager.odp_config.get_api_key() + manager.update_odp_config('key1', 'host1', []) + second_api_key = manager.odp_config.get_api_key() + + mock_update.assert_called_once() + mock_logger.debug.assert_not_called() + self.assertEqual(first_api_key, None) + self.assertEqual(second_api_key, 'key1') + + with mock.patch.object(event_manager, 'update_config') as mock_update: + first_api_key = manager.odp_config.get_api_key() + manager.update_odp_config('key2', 'host1', []) + second_api_key = manager.odp_config.get_api_key() + + mock_update.assert_called_once() + mock_logger.debug.assert_not_called() + self.assertEqual(first_api_key, 'key1') + self.assertEqual(second_api_key, 'key2') + + with mock.patch.object(event_manager, 'update_config') as mock_update: + first_api_key = manager.odp_config.get_api_key() + manager.update_odp_config('key2', 'host1', []) + second_api_key = manager.odp_config.get_api_key() + + # event_manager.update_config not called when no change to odp_config + mock_update.assert_not_called() + mock_logger.error.assert_not_called() + mock_logger.debug.assert_called_with('Odp config was not changed.') + self.assertEqual(first_api_key, 'key2') + self.assertEqual(second_api_key, 'key2') + + def test_update_odp_config__odp_config_propagated_properly(self): + mock_logger = mock.MagicMock() + event_manager = OdpEventManager(OdpConfig(), mock_logger, ZaiusRestApiManager()) + manager = OdpManager(False, LRUCache(10, 20), event_manager=event_manager, logger=mock_logger) + manager.update_odp_config('key1', 'host1', ['a', 'b']) + + self.assertEqual(manager.segment_manager.odp_config.get_api_key(), 'key1') + self.assertEqual(manager.segment_manager.odp_config.get_api_host(), 'host1') + self.assertEqual(manager.segment_manager.odp_config.get_segments_to_check(), ['a', 'b']) + self.assertEqual(manager.event_manager.odp_config.get_api_key(), 'key1') + self.assertEqual(manager.event_manager.odp_config.get_api_host(), 'host1') + self.assertEqual(manager.event_manager.odp_config.get_segments_to_check(), ['a', 'b']) + + # odp disabled with invalid apiKey (apiKey/apiHost propagated into submanagers) + manager.update_odp_config(None, None, []) + + self.assertEqual(manager.segment_manager.odp_config.get_api_key(), None) + self.assertEqual(manager.segment_manager.odp_config.get_api_host(), None) + self.assertEqual(manager.segment_manager.odp_config.get_segments_to_check(), []) + self.assertEqual(manager.event_manager.odp_config.get_api_key(), None) + self.assertEqual(manager.event_manager.odp_config.get_api_host(), None) + self.assertEqual(manager.event_manager.odp_config.get_segments_to_check(), []) + + manager.update_odp_config(None, None, ['a', 'b']) + self.assertEqual(manager.segment_manager.odp_config.get_segments_to_check(), ['a', 'b']) + self.assertEqual(manager.event_manager.odp_config.get_segments_to_check(), ['a', 'b']) + mock_logger.error.assert_not_called() + + def test_segments_cache_default_settings(self): + manager = OdpManager(False) + segments_cache = manager.segment_manager.segments_cache + self.assertEqual(segments_cache.capacity, 10_000) + self.assertEqual(segments_cache.timeout, 600)