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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ private static string ValidBody(string variationId = "v1")
public void FetchDecisionReturnsSuccessNoRetry()
{
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v1")));
var client = new DefaultCmabClient(http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler());
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler());
var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");

Assert.AreEqual("v1", result);
Expand All @@ -106,7 +106,7 @@ public void FetchDecisionReturnsSuccessNoRetry()
public void FetchDecisionHttpExceptionNoRetry()
{
var http = MakeClientExceptionSequence(new HttpRequestException("boom"));
var client = new DefaultCmabClient(http, retryConfig: null);
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);

Assert.Throws<CmabFetchException>(() =>
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
Expand All @@ -116,7 +116,7 @@ public void FetchDecisionHttpExceptionNoRetry()
public void FetchDecisionNon2xxNoRetry()
{
var http = MakeClient(new ResponseStep(HttpStatusCode.InternalServerError, null));
var client = new DefaultCmabClient(http, retryConfig: null);
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);

Assert.Throws<CmabFetchException>(() =>
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
Expand All @@ -126,7 +126,7 @@ public void FetchDecisionNon2xxNoRetry()
public void FetchDecisionInvalidJsonNoRetry()
{
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "not json"));
var client = new DefaultCmabClient(http, retryConfig: null);
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);

Assert.Throws<CmabInvalidResponseException>(() =>
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
Expand All @@ -136,7 +136,7 @@ public void FetchDecisionInvalidJsonNoRetry()
public void FetchDecisionInvalidStructureNoRetry()
{
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "{\"predictions\":[]}"));
var client = new DefaultCmabClient(http, retryConfig: null);
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);

Assert.Throws<CmabInvalidResponseException>(() =>
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
Expand All @@ -147,7 +147,7 @@ public void FetchDecisionSuccessWithRetryFirstTry()
{
var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v2")));
var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
var client = new DefaultCmabClient(http, retry);
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry);
var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");

Assert.AreEqual("v2", result);
Expand All @@ -162,7 +162,7 @@ public void FetchDecisionSuccessWithRetryThirdTry()
new ResponseStep(HttpStatusCode.OK, ValidBody("v3"))
);
var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
var client = new DefaultCmabClient(http, retry);
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry);
var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");

Assert.AreEqual("v3", result);
Expand All @@ -177,10 +177,40 @@ public void FetchDecisionExhaustsAllRetries()
new ResponseStep(HttpStatusCode.InternalServerError, null)
);
var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
var client = new DefaultCmabClient(http, retry);
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry);

Assert.Throws<CmabFetchException>(() =>
client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
}

[Test]
public void FetchDecision_CustomEndpoint_CallsCorrectUrl()
{
var customEndpoint = "https://custom.example.com/api/{0}";
string capturedUrl = null;

var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Returns((HttpRequestMessage req, CancellationToken _) =>
{
capturedUrl = req.RequestUri.ToString();
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ValidBody("variation123"))
};
return Task.FromResult(response);
});

var http = new HttpClient(handler.Object);
var client = new DefaultCmabClient(customEndpoint, http, retryConfig: null);
var result = client.FetchDecision("rule-456", "user-1", null, "uuid-1");

Assert.AreEqual("variation123", result);
Assert.AreEqual("https://custom.example.com/api/rule-456", capturedUrl,
"Should call custom endpoint with rule ID formatted into template");
}
}
}
14 changes: 7 additions & 7 deletions OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ public void ConstructorWithoutConfigUsesDefaultCacheSettings()
{
var cache = new LruCache<CmabCacheEntry>(CmabConstants.DEFAULT_CACHE_SIZE,
CmabConstants.DEFAULT_CACHE_TTL, _logger);
var client = new DefaultCmabClient(null,
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
var service = new DefaultCmabService(cache, client, _logger);
var internalCache = GetInternalCache(service) as LruCache<CmabCacheEntry>;
Expand All @@ -397,7 +397,7 @@ public void ConstructorWithoutConfigUsesDefaultCacheSettings()
public void ConstructorAppliesCustomCacheSize()
{
var cache = new LruCache<CmabCacheEntry>(42, CmabConstants.DEFAULT_CACHE_TTL, _logger);
var client = new DefaultCmabClient(null,
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
var service = new DefaultCmabService(cache, client, _logger);
var internalCache = GetInternalCache(service) as LruCache<CmabCacheEntry>;
Expand All @@ -413,7 +413,7 @@ public void ConstructorAppliesCustomCacheTtl()
var expectedTtl = TimeSpan.FromMinutes(3);
var cache = new LruCache<CmabCacheEntry>(CmabConstants.DEFAULT_CACHE_SIZE, expectedTtl,
_logger);
var client = new DefaultCmabClient(null,
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
var service = new DefaultCmabService(cache, client, _logger);
var internalCache = GetInternalCache(service) as LruCache<CmabCacheEntry>;
Expand All @@ -428,7 +428,7 @@ public void ConstructorAppliesCustomCacheSizeAndTtl()
{
var expectedTtl = TimeSpan.FromSeconds(90);
var cache = new LruCache<CmabCacheEntry>(5, expectedTtl, _logger);
var client = new DefaultCmabClient(null,
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
var service = new DefaultCmabService(cache, client, _logger);
var internalCache = GetInternalCache(service) as LruCache<CmabCacheEntry>;
Expand All @@ -442,7 +442,7 @@ public void ConstructorAppliesCustomCacheSizeAndTtl()
public void ConstructorUsesProvidedCustomCacheInstance()
{
var customCache = new LruCache<CmabCacheEntry>(3, TimeSpan.FromSeconds(5), _logger);
var client = new DefaultCmabClient(null,
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
var service = new DefaultCmabService(customCache, client, _logger);
var cache = GetInternalCache(service);
Expand All @@ -455,7 +455,7 @@ public void ConstructorUsesProvidedCustomCacheInstance()
public void ConstructorAcceptsAnyICacheImplementation()
{
var fakeCache = new FakeCache();
var client = new DefaultCmabClient(null,
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
var service = new DefaultCmabService(fakeCache, client, _logger);
var cache = GetInternalCache(service);
Expand All @@ -470,7 +470,7 @@ public void ConstructorCreatesDefaultClientWhenNoneProvided()
{
var cache = new LruCache<CmabCacheEntry>(CmabConstants.DEFAULT_CACHE_SIZE,
CmabConstants.DEFAULT_CACHE_TTL, _logger);
var client = new DefaultCmabClient(null,
var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, null,
new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
var service = new DefaultCmabService(cache, client, _logger);
var internalClient = GetInternalClient(service);
Expand Down
72 changes: 72 additions & 0 deletions OptimizelySDK.Tests/OptimizelyTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
using Moq;
using NUnit.Framework;
using OptimizelySDK.Bucketing;
using OptimizelySDK.Cmab;
using OptimizelySDK.Config;
using OptimizelySDK.Entity;
using OptimizelySDK.ErrorHandler;
Expand Down Expand Up @@ -6268,5 +6269,76 @@ public void TestConstructedOptimizelyWithDatafileShouldHaveOdpEnabledByDefault()
}

#endregion

#region Test Optimizely & CMAB

[Test]
public void TestInitializeCmabServiceWithCustomEndpointPropagatesCorrectly()
{
var customEndpoint = "https://custom.example.com/predict/{0}";
var cmabConfig = new CmabConfig().SetPredictionEndpointTemplate(customEndpoint);
var configManager = new Mock<ProjectConfigManager>();
var datafileConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object, ErrorHandlerMock.Object);
configManager.Setup(cm => cm.GetConfig()).Returns(datafileConfig);

var optimizely = new Optimizely(
configManager: configManager.Object,
notificationCenter: null,
eventDispatcher: EventDispatcherMock.Object,
logger: LoggerMock.Object,
errorHandler: ErrorHandlerMock.Object,
userProfileService: null,
eventProcessor: null,
defaultDecideOptions: null,
odpManager: null,
cmabConfig: cmabConfig
);

var decisionService = optimizely.GetType()
.GetField("DecisionService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(optimizely);
var cmabService = decisionService?.GetType()
.GetField("CmabService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(decisionService);
var client = cmabService?.GetType()
.GetField("_cmabClient", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(cmabService);
var actualEndpoint = client?.GetType()
.GetField("_predictionEndpointTemplate", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(client) as string;

Assert.AreEqual(customEndpoint, actualEndpoint, "Custom endpoint should propagate to CMAB client");
}

[Test]
public void TestInitializeCmabServiceWithoutCustomEndpointUsesDefault()
{
var configManager = new Mock<ProjectConfigManager>();
var datafileConfig = DatafileProjectConfig.Create(TestData.Datafile, LoggerMock.Object, ErrorHandlerMock.Object);
configManager.Setup(cm => cm.GetConfig()).Returns(datafileConfig);

var optimizely = new Optimizely(
configManager: configManager.Object,
notificationCenter: null,
eventDispatcher: EventDispatcherMock.Object,
logger: LoggerMock.Object,
errorHandler: ErrorHandlerMock.Object,
userProfileService: null,
eventProcessor: null,
defaultDecideOptions: null,
odpManager: null,
cmabConfig: null
);

var decisionService = optimizely.GetType()
.GetField("DecisionService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(optimizely);
var cmabService = decisionService?.GetType()
.GetField("CmabService", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(decisionService);
var client = cmabService?.GetType()
.GetField("_cmabClient", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(cmabService);
var actualEndpoint = client?.GetType()
.GetField("_predictionEndpointTemplate", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(client) as string;

Assert.AreEqual(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE,
actualEndpoint, "Should use default endpoint when no config provided");
}

#endregion
}
}
20 changes: 18 additions & 2 deletions OptimizelySDK/Cmab/CmabConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ public class CmabConfig
/// </summary>
public ICacheWithRemove<CmabCacheEntry> Cache { get; private set; }

/// <summary>
/// Gets or sets the prediction endpoint URL template for CMAB requests.
/// </summary>
public string PredictionEndpointTemplate { get; private set; } = CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE;

/// <summary>
/// Sets the maximum number of entries in the CMAB cache.
/// </summary>
Expand All @@ -58,7 +63,7 @@ public CmabConfig SetCacheSize(int cacheSize)
/// Sets the time-to-live for CMAB cache entries.
/// </summary>
/// <param name="cacheTtl">Time-to-live for cache entries.</param>
/// <returns>This CmabConfig instance for method chaining.</returns>
/// <returns>CmabConfig instance</returns>
public CmabConfig SetCacheTtl(TimeSpan cacheTtl)
{
CacheTtl = cacheTtl;
Expand All @@ -70,11 +75,22 @@ public CmabConfig SetCacheTtl(TimeSpan cacheTtl)
/// When set, CacheSize and CacheTtl will be ignored.
/// </summary>
/// <param name="cache">Custom cache implementation for CMAB decisions.</param>
/// <returns>This CmabConfig instance for method chaining.</returns>
/// <returns>CmabConfig Instance</returns>
public CmabConfig SetCache(ICacheWithRemove<CmabCacheEntry> cache)
{
Cache = cache ?? throw new ArgumentNullException(nameof(cache));
return this;
}

/// <summary>
/// Sets the prediction endpoint URL template for CMAB requests.
/// </summary>
/// <param name="template">The URL template</param>
/// <returns>CmabConfig Instance</returns>
public CmabConfig SetPredictionEndpointTemplate(string template)
{
PredictionEndpointTemplate = template;
return this;
}
}
}
2 changes: 1 addition & 1 deletion OptimizelySDK/Cmab/CmabConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ namespace OptimizelySDK.Cmab
{
internal static class CmabConstants
{
public const string PREDICTION_URL = "https://prediction.cmab.optimizely.com/predict";
public const string DEFAULT_PREDICTION_URL_TEMPLATE = "https://prediction.cmab.optimizely.com/predict/{0}";
public const int DEFAULT_CACHE_SIZE = 10_000;
public const string CONTENT_TYPE = "application/json";

Expand Down
5 changes: 4 additions & 1 deletion OptimizelySDK/Cmab/DefaultCmabClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,16 @@ public class DefaultCmabClient : ICmabClient
private readonly CmabRetryConfig _retryConfig;
private readonly ILogger _logger;
private readonly IErrorHandler _errorHandler;
private readonly string _predictionEndpointTemplate;

public DefaultCmabClient(
string predictionEndpointTemplate,
HttpClient httpClient = null,
CmabRetryConfig retryConfig = null,
ILogger logger = null,
IErrorHandler errorHandler = null)
{
_predictionEndpointTemplate = predictionEndpointTemplate;
_httpClient = httpClient ?? new HttpClient();
_retryConfig = retryConfig;
_logger = logger ?? new NoOpLogger();
Expand All @@ -58,7 +61,7 @@ private async Task<string> FetchDecisionAsync(
string cmabUuid,
TimeSpan? timeout = null)
{
var url = $"{CmabConstants.PREDICTION_URL}/{ruleId}";
var url = string.Format(_predictionEndpointTemplate, ruleId);
var body = BuildRequestBody(ruleId, userId, attributes, cmabUuid);
var perAttemptTimeout = timeout ?? CmabConstants.MAX_TIMEOUT;

Expand Down
2 changes: 1 addition & 1 deletion OptimizelySDK/Optimizely.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
try
{
#if USE_ODP
InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService,

Check warning on line 149 in OptimizelySDK/Optimizely.cs

View workflow job for this annotation

GitHub Actions / Build Standard 2.0

'Optimizely.InitializeComponents(IEventDispatcher, ILogger, IErrorHandler, UserProfileService, NotificationCenter, EventProcessor, OptimizelyDecideOption[], IOdpManager, ICmabService, CmabConfig)' is obsolete

Check warning on line 149 in OptimizelySDK/Optimizely.cs

View workflow job for this annotation

GitHub Actions / Build Standard 2.0

'Optimizely.InitializeComponents(IEventDispatcher, ILogger, IErrorHandler, UserProfileService, NotificationCenter, EventProcessor, OptimizelyDecideOption[], IOdpManager, ICmabService, CmabConfig)' is obsolete
null, eventProcessor, defaultDecideOptions, odpManager);
#else
InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService,
Expand Down Expand Up @@ -218,7 +218,7 @@
ProjectConfigManager = configManager;

#if USE_ODP && USE_CMAB
InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService,

Check warning on line 221 in OptimizelySDK/Optimizely.cs

View workflow job for this annotation

GitHub Actions / Build Standard 2.0

'Optimizely.InitializeComponents(IEventDispatcher, ILogger, IErrorHandler, UserProfileService, NotificationCenter, EventProcessor, OptimizelyDecideOption[], IOdpManager, ICmabService, CmabConfig)' is obsolete

Check warning on line 221 in OptimizelySDK/Optimizely.cs

View workflow job for this annotation

GitHub Actions / Build Standard 2.0

'Optimizely.InitializeComponents(IEventDispatcher, ILogger, IErrorHandler, UserProfileService, NotificationCenter, EventProcessor, OptimizelyDecideOption[], IOdpManager, ICmabService, CmabConfig)' is obsolete
notificationCenter, eventProcessor, defaultDecideOptions, odpManager, null, cmabConfig);
#elif USE_ODP
InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService,
Expand Down Expand Up @@ -299,7 +299,7 @@

var cmabRetryConfig = new CmabRetryConfig(CmabConstants.CMAB_MAX_RETRIES,
CmabConstants.CMAB_INITIAL_BACKOFF);
var cmabClient = new DefaultCmabClient(null, cmabRetryConfig, Logger);
var cmabClient = new DefaultCmabClient(config.PredictionEndpointTemplate, null, cmabRetryConfig, Logger, null);

cmabService = new DefaultCmabService(cache, cmabClient, Logger);

Expand Down
Loading