diff --git a/OptimizelySDK.Tests/OdpTests/OdpConfigTest.cs b/OptimizelySDK.Tests/OdpTests/OdpConfigTest.cs new file mode 100644 index 00000000..a948e661 --- /dev/null +++ b/OptimizelySDK.Tests/OdpTests/OdpConfigTest.cs @@ -0,0 +1,118 @@ +/* + * 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 + * + * https://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. + */ + +using NUnit.Framework; +using OptimizelySDK.Odp; +using System.Collections.Generic; + +namespace OptimizelySDK.Tests.OdpTests +{ + [TestFixture] + public class OdpConfigTest + { + private const string API_KEY = "UrAp1k3Y"; + private const string API_HOST = "https://not-real-odp-host.example.com"; + + private static readonly List segmentsToCheck = new List + { + "UPPER-CASE-AUDIENCE", + "lower-case-audience", + }; + + private readonly OdpConfig _goodOdpConfig = + new OdpConfig(API_KEY, API_HOST, segmentsToCheck); + + [Test] + public void ShouldNotEqualWithNullInApiKey() + { + var nullKeyConfig = + new OdpConfig(null, API_HOST, segmentsToCheck); + + Assert.IsFalse(_goodOdpConfig.Equals(nullKeyConfig)); + Assert.IsFalse(nullKeyConfig.Equals(_goodOdpConfig)); + } + + [Test] + public void ShouldNotEqualWithNullInApiHost() + { + var nullHostConfig = + new OdpConfig(API_KEY, null, segmentsToCheck); + + Assert.IsFalse(_goodOdpConfig.Equals(nullHostConfig)); + Assert.IsFalse(nullHostConfig.Equals(_goodOdpConfig)); + } + + [Test] + public void ShouldNotEqualWithNullSegmentsCollection() + { + var nullSegmentsConfig = + new OdpConfig(API_KEY, API_HOST, null); + + Assert.IsFalse(_goodOdpConfig.Equals(nullSegmentsConfig)); + Assert.IsFalse(nullSegmentsConfig.Equals(_goodOdpConfig)); + } + + [Test] + public void ShouldNotEqualWithSegmentsWithNull() + { + var segmentsWithANullValue = + new OdpConfig(API_KEY, API_HOST, new List + { + "good-value", + null, + }); + + Assert.IsFalse(_goodOdpConfig.Equals(segmentsWithANullValue)); + Assert.IsFalse(segmentsWithANullValue.Equals(_goodOdpConfig)); + } + + [Test] + public void ShouldNotEqualIfCaseDifferenceInApiKey() + { + const string CASE_DIFFERENCE_IN_FIRST_LETTER_OF_API_KEY = "urAp1k3Y"; + var apiKeyCaseDifferentConfig = + new OdpConfig(CASE_DIFFERENCE_IN_FIRST_LETTER_OF_API_KEY, API_HOST, + segmentsToCheck); + + Assert.IsFalse(_goodOdpConfig.Equals(apiKeyCaseDifferentConfig)); + Assert.IsFalse(apiKeyCaseDifferentConfig.Equals(_goodOdpConfig)); + } + + [Test] + public void ShouldEqualDespiteCaseDifferenceInApiHost() + { + var apiHostUpperCasedConfig = + new OdpConfig(API_KEY, API_HOST.ToUpper(), segmentsToCheck); + + Assert.IsTrue(_goodOdpConfig.Equals(apiHostUpperCasedConfig)); + Assert.IsTrue(apiHostUpperCasedConfig.Equals(_goodOdpConfig)); + } + + [Test] + public void ShouldEqualDespiteCaseDifferenceInSegments() + { + var wrongCaseSegmentsToCheck = new List + { + "upper-case-audience", + "LOWER-CASE-AUDIENCE", + }; + var wrongCaseConfig = new OdpConfig(API_KEY, API_HOST, wrongCaseSegmentsToCheck); + + Assert.IsTrue(_goodOdpConfig.Equals(wrongCaseConfig)); + Assert.IsTrue(wrongCaseConfig.Equals(_goodOdpConfig)); + } + } +} diff --git a/OptimizelySDK.Tests/OdpTests/OdpEventManagerTests.cs b/OptimizelySDK.Tests/OdpTests/OdpEventManagerTests.cs index b1bef6f0..0fea8a82 100644 --- a/OptimizelySDK.Tests/OdpTests/OdpEventManagerTests.cs +++ b/OptimizelySDK.Tests/OdpTests/OdpEventManagerTests.cs @@ -480,9 +480,6 @@ public void ShouldPrepareCorrectPayloadForIdentifyUser() [Test] public void ShouldApplyUpdatedOdpConfigurationWhenAvailable() { - var apiKeyCollector = new List(); - var apiHostCollector = new List(); - var segmentsToCheckCollector = new List>(); var apiKey = "testing-api-key"; var apiHost = "https://some.other.example.com"; var segmentsToCheck = new List @@ -490,20 +487,15 @@ public void ShouldApplyUpdatedOdpConfigurationWhenAvailable() "empty-cart", "1-item-cart", }; - var mockOdpConfig = new Mock(API_KEY, API_HOST, segmentsToCheck); - mockOdpConfig.Setup(m => m.Update(Capture.In(apiKeyCollector), - Capture.In(apiHostCollector), Capture.In(segmentsToCheckCollector))); var differentOdpConfig = new OdpConfig(apiKey, apiHost, segmentsToCheck); - var eventManager = new OdpEventManager.Builder().WithOdpConfig(mockOdpConfig.Object). + var eventManager = new OdpEventManager.Builder().WithOdpConfig(_odpConfig). WithOdpEventApiManager(_mockApiManager.Object). WithLogger(_mockLogger.Object). Build(); eventManager.UpdateSettings(differentOdpConfig); - Assert.AreEqual(apiKey, apiKeyCollector[0]); - Assert.AreEqual(apiHost, apiHostCollector[0]); - Assert.AreEqual(segmentsToCheck, segmentsToCheckCollector[0]); + Assert.IsFalse(_odpConfig.Equals(eventManager._readOdpConfigForTesting())); } private static OdpEvent MakeEvent(int id) => diff --git a/OptimizelySDK.Tests/OdpTests/OdpManagerTest.cs b/OptimizelySDK.Tests/OdpTests/OdpManagerTest.cs new file mode 100644 index 00000000..2f943a0c --- /dev/null +++ b/OptimizelySDK.Tests/OdpTests/OdpManagerTest.cs @@ -0,0 +1,319 @@ +/* + * 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 + * + * https://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. + */ + +using Moq; +using NUnit.Framework; +using OptimizelySDK.Logger; +using OptimizelySDK.Odp; +using OptimizelySDK.Odp.Entity; +using System.Collections.Generic; +using System.Linq; + +namespace OptimizelySDK.Tests.OdpTests +{ + [TestFixture] + public class OdpManagerTest + { + private const string API_KEY = "JUs7AFak3aP1K3y"; + private const string API_HOST = "https://odp-api.example.com"; + private const string UPDATED_API_KEY = "D1fF3rEn7kEy"; + private const string UPDATED_ODP_ENDPOINT = "https://an-updated-odp-endpoint.example.com"; + private const string TEST_EVENT_TYPE = "event-type"; + private const string TEST_EVENT_ACTION = "event-action"; + private const string VALID_FS_USER_ID = "valid-test-fs-user-id"; + + private readonly List _updatedSegmentsToCheck = new List + { + "updated-segment-1", + "updated-segment-2", + }; + + private readonly Dictionary _testEventIdentifiers = + new Dictionary + { + { + "fs_user_id", "id-key-1" + }, + }; + + private readonly Dictionary _testEventData = new Dictionary + { + { + "key-1", "value-1" + }, + { + "key-2", null + }, + { + "key-3", 3.3 + }, + { + "key-4", true + }, + }; + + private readonly List _emptySegmentsToCheck = new List(0); + + private OdpConfig _odpConfig; + private Mock _mockLogger; + private Mock _mockOdpEventManager; + private Mock _mockSegmentManager; + + [SetUp] + public void Setup() + { + _odpConfig = new OdpConfig(API_KEY, API_HOST, _emptySegmentsToCheck); + _mockLogger = new Mock(); + _mockOdpEventManager = new Mock(); + _mockSegmentManager = new Mock(); + } + + [Test] + public void ShouldStartEventManagerWhenOdpManagerIsInitialized() + { + _mockOdpEventManager.Setup(e => e.Start()); + + _ = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithSegmentManager(_mockSegmentManager.Object). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + _mockOdpEventManager.Verify(e => e.Start(), Times.Once); + } + + [Test] + public void ShouldStopEventManagerWhenCloseIsCalled() + { + _mockOdpEventManager.Setup(e => e.Stop()); + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithSegmentManager(_mockSegmentManager.Object). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + manager.Dispose(); + + _mockOdpEventManager.Verify(e => e.Stop(), Times.Once); + } + + [Test] + public void ShouldUseNewSettingsInEventManagerWhenOdpConfigIsUpdated() + { + var eventManagerParameterCollector = new List(); + _mockOdpEventManager.Setup(e => + e.UpdateSettings(Capture.In(eventManagerParameterCollector))); + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithSegmentManager(_mockSegmentManager.Object). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + var wasUpdated = manager.UpdateSettings(UPDATED_API_KEY, UPDATED_ODP_ENDPOINT, + _updatedSegmentsToCheck); + + Assert.IsTrue(wasUpdated); + var configPassedToOdpEventManager = eventManagerParameterCollector.FirstOrDefault(); + Assert.AreEqual(UPDATED_API_KEY, configPassedToOdpEventManager?.ApiKey); + Assert.AreEqual(UPDATED_ODP_ENDPOINT, configPassedToOdpEventManager.ApiHost); + Assert.AreEqual(_updatedSegmentsToCheck, configPassedToOdpEventManager.SegmentsToCheck); + } + + [Test] + public void ShouldUseNewSettingsInSegmentManagerWhenOdpConfigIsUpdated() + { + var segmentManagerParameterCollector = new List(); + _mockSegmentManager.Setup(s => + s.UpdateSettings(Capture.In(segmentManagerParameterCollector))); + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithSegmentManager(_mockSegmentManager.Object). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + var wasUpdated = manager.UpdateSettings(UPDATED_API_KEY, UPDATED_ODP_ENDPOINT, + _updatedSegmentsToCheck); + + Assert.IsTrue(wasUpdated); + var configPassedToSegmentManager = segmentManagerParameterCollector.FirstOrDefault(); + Assert.AreEqual(UPDATED_API_KEY, configPassedToSegmentManager?.ApiKey); + Assert.AreEqual(UPDATED_ODP_ENDPOINT, configPassedToSegmentManager.ApiHost); + Assert.AreEqual(_updatedSegmentsToCheck, configPassedToSegmentManager.SegmentsToCheck); + } + + [Test] + public void ShouldHandleOdpConfigSettingsNoChange() + { + _mockSegmentManager.Setup(s => s.UpdateSettings(It.IsAny())); + _mockOdpEventManager.Setup(e => e.UpdateSettings(It.IsAny())); + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithSegmentManager(_mockSegmentManager.Object). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + var wasUpdated = manager.UpdateSettings(_odpConfig.ApiKey, _odpConfig.ApiHost, + _odpConfig.SegmentsToCheck); + + Assert.IsFalse(wasUpdated); + _mockSegmentManager.Verify(s => s.UpdateSettings(It.IsAny()), Times.Never); + _mockOdpEventManager.Verify(e => e.UpdateSettings(It.IsAny()), Times.Never); + } + + [Test] + public void ShouldUpdateSettingsWithReset() + { + _mockSegmentManager.Setup(s => + s.UpdateSettings(It.IsAny())); + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithSegmentManager(_mockSegmentManager.Object). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + var wasUpdated = manager.UpdateSettings(UPDATED_API_KEY, UPDATED_ODP_ENDPOINT, + _updatedSegmentsToCheck); + + Assert.IsTrue(wasUpdated); + _mockSegmentManager.Verify(s => s.ResetCache(), Times.Once); + } + + [Test] + public void ShouldDisableOdpThroughConfiguration() + { + _mockOdpEventManager.Setup(e => e.SendEvent(It.IsAny())); + _mockOdpEventManager.Setup(e => e.IsStarted).Returns(true); + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + manager.SendEvent(TEST_EVENT_TYPE, TEST_EVENT_ACTION, _testEventIdentifiers, + _testEventData); + + _mockOdpEventManager.Verify(e => e.SendEvent(It.IsAny()), Times.Once); + _mockLogger.Verify(l => + l.Log(LogLevel.ERROR, "ODP event not dispatched (ODP disabled)."), Times.Never); + + _mockOdpEventManager.ResetCalls(); + _mockLogger.ResetCalls(); + + manager.UpdateSettings(string.Empty, string.Empty, _emptySegmentsToCheck); + + manager.SendEvent(TEST_EVENT_TYPE, TEST_EVENT_ACTION, _testEventIdentifiers, + _testEventData); + manager.Dispose(); + + _mockOdpEventManager.Verify(e => e.SendEvent(It.IsAny()), Times.Never); + _mockLogger.Verify(l => + l.Log(LogLevel.ERROR, "ODP event not dispatched (ODP disabled)."), Times.Once); + } + + [Test] + public void ShouldGetEventManager() + { + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithSegmentManager(_mockSegmentManager.Object). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + Assert.IsNotNull(manager.EventManager); + } + + [Test] + public void ShouldGetSegmentManager() + { + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithSegmentManager(_mockSegmentManager.Object). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + Assert.IsNotNull(manager.SegmentManager); + } + + [Test] + public void ShouldIdentifyUserWhenOdpIsIntegrated() + { + _mockOdpEventManager.Setup(e => e.IdentifyUser(It.IsAny())); + _mockOdpEventManager.Setup(e => e.IsStarted).Returns(true); + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + manager.IdentifyUser(VALID_FS_USER_ID); + manager.Dispose(); + + _mockLogger.Verify(l => l.Log(It.IsAny(), It.IsAny()), Times.Never); + _mockOdpEventManager.Verify(e => e.IdentifyUser(It.IsAny()), Times.Once); + } + + [Test] + public void ShouldNotIdentifyUserWhenOdpDisabled() + { + _mockOdpEventManager.Setup(e => e.IdentifyUser(It.IsAny())); + _mockOdpEventManager.Setup(e => e.IsStarted).Returns(true); + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(false); + + manager.IdentifyUser(VALID_FS_USER_ID); + manager.Dispose(); + + _mockLogger.Verify(l => + l.Log(LogLevel.DEBUG, "ODP identify event not dispatched (ODP disabled).")); + _mockOdpEventManager.Verify(e => e.IdentifyUser(It.IsAny()), Times.Never); + } + + [Test] + public void ShouldSendEventWhenOdpIsIntegrated() + { + _mockOdpEventManager.Setup(e => e.SendEvent(It.IsAny())); + _mockOdpEventManager.Setup(e => e.IsStarted).Returns(true); + var manager = new OdpManager.Builder().WithOdpConfig(_odpConfig). + WithEventManager(_mockOdpEventManager.Object). + WithLogger(_mockLogger.Object). + Build(); + + manager.SendEvent(TEST_EVENT_TYPE, TEST_EVENT_ACTION, _testEventIdentifiers, + _testEventData); + manager.Dispose(); + + _mockOdpEventManager.Verify(e => e.SendEvent(It.IsAny()), Times.Once); + } + + [Test] + public void ShouldNotSendEventOdpNotIntegrated() + { + var odpConfig = new OdpConfig(string.Empty, string.Empty, _emptySegmentsToCheck); + _mockOdpEventManager.Setup(e => e.SendEvent(It.IsAny())); + var manager = new OdpManager.Builder().WithOdpConfig(odpConfig). + WithLogger(_mockLogger.Object). + Build(false); // do not enable + + manager.SendEvent(TEST_EVENT_TYPE, TEST_EVENT_ACTION, _testEventIdentifiers, + _testEventData); + manager.Dispose(); + + _mockLogger.Verify(l => + l.Log(LogLevel.ERROR, "ODP event not dispatched (ODP disabled)."), Times.Once); + _mockOdpEventManager.Verify(e => e.SendEvent(It.IsAny()), Times.Never); + } + } +} diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj index 867c217c..fa50e575 100644 --- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj +++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj @@ -80,6 +80,8 @@ + + diff --git a/OptimizelySDK/Odp/IOdpEventManager.cs b/OptimizelySDK/Odp/IOdpEventManager.cs index dabf7a99..f2cf96fb 100644 --- a/OptimizelySDK/Odp/IOdpEventManager.cs +++ b/OptimizelySDK/Odp/IOdpEventManager.cs @@ -61,5 +61,15 @@ public interface IOdpEventManager /// /// Configuration object containing new values void UpdateSettings(OdpConfig odpConfig); + + /// + /// Indicates the ODP Event Manager has been stopped and disposed + /// + bool Disposed { get; } + + /// + /// Indicates the ODP Event Manager instance is in a running state + /// + bool IsStarted { get; } } } diff --git a/OptimizelySDK/Odp/IOdpManager.cs b/OptimizelySDK/Odp/IOdpManager.cs new file mode 100644 index 00000000..b65d2687 --- /dev/null +++ b/OptimizelySDK/Odp/IOdpManager.cs @@ -0,0 +1,65 @@ +/* + * 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 + * + * https://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. + */ + +using System.Collections.Generic; + +namespace OptimizelySDK.Odp +{ + /// + /// Interface describing orchestration of segment manager, event manager, and ODP config + /// + public interface IOdpManager + { + /// + /// Update the settings being used for ODP configuration and reset/restart dependent processes + /// + /// Public API key from ODP + /// Host portion of the URL of ODP + /// Audience segments to consider + /// True if settings were update otherwise False + bool UpdateSettings(string apiKey, string apiHost, List segmentsToCheck); + + /// + /// Attempts to fetch and return a list of a user's qualified segments. + /// + /// FS User ID + /// Options used during segment cache handling + /// Qualified segments for the user from the cache or the ODP server + string[] FetchQualifiedSegments(string userId, List options); + + /// + /// Send identification event to ODP for a given full-stack User ID + /// + /// User ID to send + void IdentifyUser(string userId); + + /// + /// Add event to queue for sending to ODP + /// + /// Type of event (typically `fullstack` from server-side SDK events) + /// Subcategory of the event type + /// Key-value map of user identifiers + /// Event data in a key-value pair format + void SendEvent(string type, string action, Dictionary identifiers, + Dictionary data + ); + + /// + /// Sends signal to stop Event Manager and clean up ODP Manager use + /// + void Dispose(); + } +} diff --git a/OptimizelySDK/Odp/IOdpSegmentManager.cs b/OptimizelySDK/Odp/IOdpSegmentManager.cs index 0a316e24..3f7b729a 100644 --- a/OptimizelySDK/Odp/IOdpSegmentManager.cs +++ b/OptimizelySDK/Odp/IOdpSegmentManager.cs @@ -31,5 +31,16 @@ public interface IOdpSegmentManager /// An array of OptimizelySegmentOption used to ignore and/or reset the cache. /// Qualified segments for the user from the cache or the ODP server if the cache is empty. List FetchQualifiedSegments(string fsUserId, List options = null); + + /// + /// Update the ODP configuration settings being used by the Segment Manager + /// + /// New ODP Configuration to apply + void UpdateSettings(OdpConfig odpConfig); + + /// + /// Reset/clear the segments cache + /// + void ResetCache(); } } diff --git a/OptimizelySDK/Odp/OdpConfig.cs b/OptimizelySDK/Odp/OdpConfig.cs index 30127033..07dc0925 100644 --- a/OptimizelySDK/Odp/OdpConfig.cs +++ b/OptimizelySDK/Odp/OdpConfig.cs @@ -14,92 +14,39 @@ * limitations under the License. */ +using System; using System.Collections.Generic; +using System.Linq; namespace OptimizelySDK.Odp { - public class OdpConfig + public class OdpConfig : IEquatable { /// /// Public API key for the ODP account from which the audience segments will be fetched (optional). /// - private volatile string _apiKey; - - public string ApiKey - { - get - { - return _apiKey; - } - private set - { - _apiKey = value; - } - } + public string ApiKey { get; private set; } /// /// Host of ODP audience segments API. /// - private volatile string _apiHost; - - public string ApiHost - { - get - { - return _apiHost; - } - private set - { - _apiHost = value; - } - } + public string ApiHost { get; private set; } /// /// All ODP segments used in the current datafile (associated with apiHost/apiKey). /// - private volatile List _segmentsToCheck; + public List SegmentsToCheck { get; private set; } - public List SegmentsToCheck - { - get - { - return _segmentsToCheck; - } - private set - { - _segmentsToCheck = value; - } - } - - public OdpConfig(string apiKey, string apiHost, List segmentsToCheck) + public OdpConfig(string apiKey = null, string apiHost = null, + List segmentsToCheck = null + ) { - ApiKey = apiKey; - ApiHost = apiHost; + ApiKey = apiKey ?? string.Empty; + ApiHost = apiHost ?? string.Empty; SegmentsToCheck = segmentsToCheck ?? new List(0); } - /// - /// Update the ODP configuration details - /// - /// Public API key for the ODP account - /// Host of ODP audience segments API - /// Audience segments - /// true if configuration was updated successfully otherwise false - public virtual bool Update(string apiKey, string apiHost, List segmentsToCheck) - { - if (ApiKey == apiKey && ApiHost == apiHost && SegmentsToCheck == segmentsToCheck) - { - return false; - } - - ApiKey = apiKey; - ApiHost = apiHost; - SegmentsToCheck = segmentsToCheck; - - return true; - } - /// /// Determines if ODP configuration has the minimum amount of information /// @@ -117,5 +64,34 @@ public bool HasSegments() { return SegmentsToCheck?.Count > 0; } + + /// + /// Determine equality between two OdpConfig objects based on case-insensitive value comparisons + /// + /// OdpConfig object to compare current instance against + /// True if equal otherwise False + public bool Equals(OdpConfig otherConfig) + { + // less expensive equality checks first + if (otherConfig == null || + !string.Equals(ApiKey, otherConfig.ApiKey, + StringComparison.Ordinal) || // case-matters + !string.Equals(ApiHost, otherConfig.ApiHost, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (SegmentsToCheck == null || + otherConfig.SegmentsToCheck == null || + SegmentsToCheck.Count != otherConfig.SegmentsToCheck.Count) + { + return false; + } + + return SegmentsToCheck.TrueForAll( + segment => + otherConfig.SegmentsToCheck.Contains(segment, + StringComparer.OrdinalIgnoreCase)); + } } } diff --git a/OptimizelySDK/Odp/OdpEventManager.cs b/OptimizelySDK/Odp/OdpEventManager.cs index 45f208ae..070f4d8b 100644 --- a/OptimizelySDK/Odp/OdpEventManager.cs +++ b/OptimizelySDK/Odp/OdpEventManager.cs @@ -32,7 +32,7 @@ namespace OptimizelySDK.Odp /// public class OdpEventManager : IOdpEventManager, IDisposable { - private OdpConfig _odpConfig; + private volatile OdpConfig _odpConfig; private IOdpEventApiManager _odpEventApiManager; private int _batchSize; private TimeSpan _flushInterval; @@ -349,7 +349,7 @@ public void IdentifyUser(string userId) /// Configuration object containing new values public void UpdateSettings(OdpConfig odpConfig) { - _odpConfig.Update(odpConfig.ApiKey, odpConfig.ApiHost, odpConfig.SegmentsToCheck); + _odpConfig = odpConfig; } /// @@ -518,5 +518,10 @@ public OdpEventManager Build(bool startImmediately = true) return manager; } } + + public OdpConfig _readOdpConfigForTesting() + { + return _odpConfig; + } } } diff --git a/OptimizelySDK/Odp/OdpManager.cs b/OptimizelySDK/Odp/OdpManager.cs new file mode 100644 index 00000000..0a57ca19 --- /dev/null +++ b/OptimizelySDK/Odp/OdpManager.cs @@ -0,0 +1,285 @@ +/* + * 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 + * + * https://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. + */ + +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Logger; +using OptimizelySDK.Odp.Entity; +using System; +using System.Collections.Generic; + +namespace OptimizelySDK.Odp +{ + /// + /// Concrete implementation to orchestrate segment manager, event manager, and ODP config + /// + public class OdpManager : IOdpManager, IDisposable + { + /// + /// Denotes if ODP Manager is meant to be handling ODP communication + /// + private bool _enabled; + + /// + /// Configuration used to communicate with ODP + /// + private volatile OdpConfig _odpConfig; + + /// + /// Manager used to handle audience segment membership + /// + public IOdpSegmentManager SegmentManager { get; private set; } + + /// + /// Manager used to send events to ODP + /// + public IOdpEventManager EventManager { get; private set; } + + /// + /// Logger used to record messages that occur within the ODP client + /// + private ILogger _logger; + + /// + /// Update the settings being used for ODP configuration and reset/restart dependent processes + /// + /// Public API key from ODP + /// Host portion of the URL of ODP + /// Audience segments to consider + /// True if settings were update otherwise False + public bool UpdateSettings(string apiKey, string apiHost, List segmentsToCheck) + { + var newConfig = new OdpConfig(apiKey, apiHost, segmentsToCheck); + if (_odpConfig.Equals(newConfig)) + { + return false; + } + + _odpConfig = newConfig; + + EventManager.UpdateSettings(_odpConfig); + + SegmentManager.ResetCache(); + SegmentManager.UpdateSettings(_odpConfig); + + return true; + } + + /// + /// Attempts to fetch and return a list of a user's qualified segments. + /// + /// FS User ID + /// Options used during segment cache handling + /// Qualified segments for the user from the cache or the ODP server + public string[] FetchQualifiedSegments(string userId, List options) + { + if (SegmentManagerOrConfigNotReady()) + { + _logger.Log(LogLevel.ERROR, Constants.ODP_NOT_ENABLED_MESSAGE); + return null; + } + + return SegmentManager.FetchQualifiedSegments(userId, options).ToArray(); + } + + /// + /// Send identification event to ODP for a given full-stack User ID + /// + /// User ID to send + public void IdentifyUser(string userId) + { + if (EventManagerOrConfigNotReady()) + { + _logger.Log(LogLevel.DEBUG, "ODP identify event not dispatched (ODP disabled)."); + return; + } + + EventManager.IdentifyUser(userId); + } + + /// + /// Add event to queue for sending to ODP + /// + /// Type of event (typically `fullstack` from server-side SDK events) + /// Subcategory of the event type + /// Key-value map of user identifiers + /// Event data in a key-value pair format + public void SendEvent(string type, string action, Dictionary identifiers, + Dictionary data + ) + { + if (EventManagerOrConfigNotReady()) + { + _logger.Log(LogLevel.ERROR, "ODP event not dispatched (ODP disabled)."); + return; + } + + EventManager.SendEvent(new OdpEvent(type, action, identifiers, data)); + } + + /// + /// Sends signal to stop Event Manager and clean up ODP Manager use + /// + public void Dispose() + { + if (EventManager == null || !_enabled) + { + return; + } + + EventManager.Stop(); + } + + /// + /// Builder pattern to create an instances of OdpManager + /// + public class Builder + { + private OdpConfig _odpConfig; + private IOdpEventManager _eventManager; + private IOdpSegmentManager _segmentManager; + private int _cacheSize; + private int _cacheTimeoutSeconds; + private ILogger _logger; + private IErrorHandler _errorHandler; + private ICache> _cache; + + public Builder WithSegmentManager(IOdpSegmentManager segmentManager) + { + _segmentManager = segmentManager; + return this; + } + + public Builder WithEventManager(IOdpEventManager eventManager) + { + _eventManager = eventManager; + return this; + } + + public Builder WithOdpConfig(OdpConfig odpConfig) + { + _odpConfig = odpConfig; + return this; + } + + public Builder WithCacheSize(int cacheSize) + { + _cacheSize = cacheSize; + return this; + } + + public Builder WithCacheTimeout(int seconds) + { + _cacheTimeoutSeconds = seconds; + return this; + } + + public Builder WithLogger(ILogger logger = null) + { + _logger = logger; + return this; + } + + public Builder WithErrorHandler(IErrorHandler errorHandler = null) + { + _errorHandler = errorHandler; + return this; + } + + public Builder WithCacheImplementation(ICache> cache) + { + _cache = cache; + return this; + } + + /// + /// Build OdpManager instance using collected parameters + /// + /// Should mark as enabled upon initialization + /// OdpManager instance + public OdpManager Build(bool asEnabled = true) + { + _logger = _logger ?? new DefaultLogger(); + _errorHandler = _errorHandler ?? new NoOpErrorHandler(); + _odpConfig = _odpConfig ?? new OdpConfig(); + + var manager = new OdpManager + { + _odpConfig = _odpConfig, + _logger = _logger, + _enabled = asEnabled, + }; + + if (!manager._enabled) + { + return manager; + } + + if (_eventManager == null) + { + var eventApiManager = new OdpEventApiManager(_logger, _errorHandler); + + manager.EventManager = new OdpEventManager.Builder(). + WithOdpConfig(_odpConfig). + WithOdpEventApiManager(eventApiManager). + WithLogger(_logger). + WithErrorHandler(_errorHandler). + Build(); + } + else + { + manager.EventManager = _eventManager; + } + + if (_segmentManager == null) + { + var cacheTimeout = TimeSpan.FromSeconds(_cacheTimeoutSeconds <= 0 ? + Constants.DEFAULT_CACHE_SECONDS : + _cacheTimeoutSeconds); + var apiManager = new OdpSegmentApiManager(_logger, _errorHandler); + + manager.SegmentManager = new OdpSegmentManager(_odpConfig, apiManager, + _cacheSize, cacheTimeout, _logger, _cache); + } + else + { + manager.SegmentManager = _segmentManager; + } + + manager.EventManager.Start(); + + return manager; + } + } + + /// + /// Determines if the EventManager is ready to be used + /// + /// True if EventManager can process events otherwise False + private bool EventManagerOrConfigNotReady() + { + return EventManager == null || !_enabled || !_odpConfig.IsReady(); + } + + /// + /// Determines if the SegmentManager is ready to be used + /// + /// True if SegmentManager can fetch audience segments otherwise False + private bool SegmentManagerOrConfigNotReady() + { + return SegmentManager == null || !_enabled || !_odpConfig.IsReady(); + } + } +} diff --git a/OptimizelySDK/Odp/OdpSegmentManager.cs b/OptimizelySDK/Odp/OdpSegmentManager.cs index e6bead9d..c155a755 100644 --- a/OptimizelySDK/Odp/OdpSegmentManager.cs +++ b/OptimizelySDK/Odp/OdpSegmentManager.cs @@ -40,7 +40,7 @@ public class OdpSegmentManager : IOdpSegmentManager /// /// ODP configuration containing the connection parameters /// - private readonly OdpConfig _odpConfig; + private volatile OdpConfig _odpConfig; /// /// Cached segments @@ -142,5 +142,22 @@ private static string GetCacheKey(string userKey, string userValue) { return $"{userKey}-$-{userValue}"; } + + /// + /// Update the ODP configuration settings being used by the Segment Manager + /// + /// New ODP Configuration to apply + public void UpdateSettings(OdpConfig odpConfig) + { + _odpConfig = odpConfig; + } + + /// + /// Reset/clear the segments cache + /// + public void ResetCache() + { + _segmentsCache.Reset(); + } } } diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index c67269d2..b667a2fb 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -101,8 +101,9 @@ + - +