Skip to content

Commit

Permalink
feat: Added ODPSegmentManager (#321)
Browse files Browse the repository at this point in the history
* WIP Initial SegmentManager commit

* WIP Initial commit fixes

* Finish OdpSegmentManager & interface

* WIP unit tests starts

* Unit tests & Segment Manager edits

to satisfy the unit tests.

* Fix merge issues; Add unit test

* Lint fixes

* Remove re-added IOdpConfig.cs

* Add internal doc

* PR code review revisions

* Update unit test

* Update OptimizelySDK/Odp/OdpSegmentManager.cs

Co-authored-by: Jae Kim <[email protected]>

* Pull request code revisions

* Remove time complexity looping/Linq

* Small refactor

* Use OrderedDictionary

Co-authored-by: Jae Kim <[email protected]>
  • Loading branch information
mikechu-optimizely and jaeopt authored Nov 23, 2022
1 parent 3eb53bd commit 3cd6745
Show file tree
Hide file tree
Showing 9 changed files with 472 additions and 51 deletions.
228 changes: 228 additions & 0 deletions OptimizelySDK.Tests/OdpTests/OdpSegmentManagerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/*
* 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.AudienceConditions;
using OptimizelySDK.ErrorHandler;
using OptimizelySDK.Logger;
using OptimizelySDK.Odp;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;

namespace OptimizelySDK.Tests.OdpTests
{
[TestFixture]
public class OdpSegmentManagerTest
{
private const string API_KEY = "S0m3Ap1KEy4U";
private const string API_HOST = "https://odp-host.example.com";
private const string FS_USER_ID = "some_valid_user_id";

private static readonly string expectedCacheKey = $"fs_user_id-$-{FS_USER_ID}";

private static readonly List<string> segmentsToCheck = new List<string>
{
"segment1",
"segment2",
};

private OdpConfig _odpConfig;
private Mock<IOdpSegmentApiManager> _mockApiManager;
private Mock<ILogger> _mockLogger;
private Mock<ICache<List<string>>> _mockCache;

[SetUp]
public void Setup()
{
_odpConfig = new OdpConfig(API_KEY, API_HOST, segmentsToCheck);

_mockApiManager = new Mock<IOdpSegmentApiManager>();

_mockLogger = new Mock<ILogger>();
_mockLogger.Setup(i => i.Log(It.IsAny<LogLevel>(), It.IsAny<string>()));

_mockCache = new Mock<ICache<List<string>>>();
}

[Test]
public void ShouldFetchSegmentsOnCacheMiss()
{
var keyCollector = new List<string>();
_mockCache.Setup(c => c.Lookup(Capture.In(keyCollector))).
Returns(default(List<string>));
_mockApiManager.Setup(a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>())).
Returns(segmentsToCheck.ToArray());
var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object,
Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object);

var segments = manager.FetchQualifiedSegments(FS_USER_ID);

var cacheKey = keyCollector.FirstOrDefault();
Assert.AreEqual(expectedCacheKey, cacheKey);
_mockCache.Verify(c => c.Reset(), Times.Never);
_mockCache.Verify(c => c.Lookup(cacheKey), Times.Once);
_mockLogger.Verify(l =>
l.Log(LogLevel.DEBUG, "ODP Cache Miss. Making a call to ODP Server."), Times.Once);
_mockApiManager.Verify(
a => a.FetchSegments(
API_KEY,
API_HOST,
OdpUserKeyType.FS_USER_ID,
FS_USER_ID,
_odpConfig.SegmentsToCheck), Times.Once);
_mockCache.Verify(c => c.Save(cacheKey, It.IsAny<List<string>>()), Times.Once);
Assert.AreEqual(segmentsToCheck, segments);
}

[Test]
public void ShouldFetchSegmentsSuccessOnCacheHit()
{
var keyCollector = new List<string>();
_mockCache.Setup(c => c.Lookup(Capture.In(keyCollector))).Returns(segmentsToCheck);
_mockApiManager.Setup(a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()));
var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object,
Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object);

var segments = manager.FetchQualifiedSegments(FS_USER_ID);

var cacheKey = keyCollector.FirstOrDefault();
Assert.AreEqual(expectedCacheKey, cacheKey);
_mockCache.Verify(c => c.Reset(), Times.Never);
_mockCache.Verify(c => c.Lookup(cacheKey), Times.Once);
_mockLogger.Verify(l =>
l.Log(LogLevel.DEBUG, "ODP Cache Hit. Returning segments from Cache."), Times.Once);
_mockApiManager.Verify(
a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()),
Times.Never);
_mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny<List<string>>()), Times.Never);
Assert.AreEqual(segmentsToCheck, segments);
}

[Test]
public void ShouldHandleFetchSegmentsWithError()
{
// OdpSegmentApiManager.FetchSegments() return null on any error
_mockApiManager.Setup(a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>())).
Returns(null as string[]);
var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object,
Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object);

var segments = manager.FetchQualifiedSegments(FS_USER_ID);

_mockCache.Verify(c => c.Reset(), Times.Never);
_mockCache.Verify(c => c.Lookup(expectedCacheKey), Times.Once);
_mockApiManager.Verify(
a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()),
Times.Once);
_mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny<List<string>>()), Times.Never);
Assert.IsNull(segments);
}

[Test]
public void ShouldLogAndReturnAnEmptySetWhenNoSegmentsToCheck()
{
var odpConfig = new OdpConfig(API_KEY, API_HOST, new List<string>(0));
var manager = new OdpSegmentManager(odpConfig, _mockApiManager.Object,
Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object);

var segments = manager.FetchQualifiedSegments(FS_USER_ID);

Assert.IsTrue(segments.Count == 0);
_mockLogger.Verify(
l => l.Log(LogLevel.DEBUG,
"No Segments are used in the project, Not Fetching segments. Returning empty list."),
Times.Once);
}

[Test]
public void ShouldLogAndReturnNullWhenOdpConfigNotReady()
{
var mockOdpConfig = new Mock<OdpConfig>(API_KEY, API_HOST, new List<string>(0));
mockOdpConfig.Setup(o => o.IsReady()).Returns(false);
var manager = new OdpSegmentManager(mockOdpConfig.Object, _mockApiManager.Object,
Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object);

var segments = manager.FetchQualifiedSegments(FS_USER_ID);

Assert.IsNull(segments);
_mockLogger.Verify(
l => l.Log(LogLevel.WARN, Constants.ODP_NOT_INTEGRATED_MESSAGE),
Times.Once);
}

[Test]
public void ShouldIgnoreCache()
{
var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object,
Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object);

manager.FetchQualifiedSegments(FS_USER_ID, new List<OdpSegmentOption>
{
OdpSegmentOption.IgnoreCache,
});

_mockCache.Verify(c => c.Reset(), Times.Never);
_mockCache.Verify(c => c.Lookup(It.IsAny<string>()), Times.Never);
_mockApiManager.Verify(
a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()),
Times.Once);
_mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny<List<string>>()), Times.Never);
}

[Test]
public void ShouldResetCache()
{
var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object,
Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object);

manager.FetchQualifiedSegments(FS_USER_ID, new List<OdpSegmentOption>
{
OdpSegmentOption.ResetCache,
});

_mockCache.Verify(c => c.Reset(), Times.Once);
_mockCache.Verify(c => c.Lookup(It.IsAny<string>()), Times.Once);
_mockApiManager.Verify(
a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()),
Times.Once);
_mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny<List<string>>()), Times.Once);
}

[Test]
public void ShouldMakeValidCacheKey()
{
var keyCollector = new List<string>();
_mockCache.Setup(c => c.Lookup(Capture.In(keyCollector)));
var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object,
Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object);

manager.FetchQualifiedSegments(FS_USER_ID);

var cacheKey = keyCollector.FirstOrDefault();
Assert.AreEqual(expectedCacheKey, cacheKey);
}
}
}
1 change: 1 addition & 0 deletions OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
<Compile Include="OdpTests\LruCacheTest.cs" />
<Compile Include="OdpTests\OdpEventManagerTests.cs" />
<Compile Include="OdpTests\OdpEventApiManagerTest.cs" />
<Compile Include="OdpTests\OdpSegmentManagerTest.cs" />
<Compile Include="OptimizelyConfigTests\OptimizelyConfigTest.cs" />
<Compile Include="OptimizelyDecisions\OptimizelyDecisionTest.cs" />
<Compile Include="OptimizelyJSONTest.cs" />
Expand Down
10 changes: 10 additions & 0 deletions OptimizelySDK/Odp/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,15 @@ public static class Constants
/// Default amount of time to wait for ODP response
/// </summary>
public static readonly TimeSpan DEFAULT_TIMEOUT_INTERVAL = TimeSpan.FromSeconds(10);

/// <summary>
/// Default maximum number of elements to cache
/// </summary>
public const int DEFAULT_MAX_CACHE_SIZE = 10000;

/// <summary>
/// Default number of seconds to cache
/// </summary>
public const int DEFAULT_CACHE_SECONDS = 600;
}
}
16 changes: 14 additions & 2 deletions OptimizelySDK/Odp/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,23 @@

namespace OptimizelySDK.Odp
{
/// <summary>
/// Type of ODP key used for fetching segments & sending events
/// </summary>
public enum OdpUserKeyType
{
// ReSharper disable InconsistentNaming
// ODP expects these names; .ToString() used
VUID = 0,
// ODP expects these names in UPPERCASE; .ToString() used
VUID = 0, // kept for SDK consistency and awareness
FS_USER_ID = 1,
}

/// <summary>
/// Options used during segment cache handling
/// </summary>
public enum OdpSegmentOption
{
IgnoreCache = 0,
ResetCache = 1,
}
}
35 changes: 35 additions & 0 deletions OptimizelySDK/Odp/IOdpSegmentManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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
{
/// <summary>
/// Interface to schedule connections to ODP for audience segmentation and caches the results.
/// </summary>
public interface IOdpSegmentManager
{
/// <summary>
/// Attempts to fetch and return a list of a user's qualified segments from the local segments cache.
/// If no cached data exists for the target user, this fetches and caches data from the ODP server instead.
/// </summary>
/// <param name="fsUserId">The FS User ID identifying the user</param>
/// <param name="options">An array of OptimizelySegmentOption used to ignore and/or reset the cache.</param>
/// <returns>Qualified segments for the user from the cache or the ODP server if the cache is empty.</returns>
List<string> FetchQualifiedSegments(string fsUserId, List<OdpSegmentOption> options = null);
}
}
Loading

0 comments on commit 3cd6745

Please sign in to comment.