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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
* "DownloadKeys" for fetching RC values from server
* "GetValues" for accessing all RC values
* "GetValue" for accessing the given RC value
* "EnrollIntoABTestsForKeys" for enrolling the user into A/B tests for the given keys
* "ExitABTestsForKeys" for removing the user from A/B tests for the given keys (no keys exits all)
* Added configuration option "DisableAutoEnrollInABTesting" to stop automatically opting users into A/B tests during Remote Config download (auto opt-in is enabled by default).
* Added configuration option "EnableRemoteConfigAutomaticTriggers" to automatically download remote config values:
* After initialization finishes.
* RemoteConfig consent is given.
Expand Down
162 changes: 162 additions & 0 deletions countlyCommon/TestingRelated/ModuleRemoteConfigTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Web;
using CountlySDK;
using CountlySDK.CountlyCommon;
using CountlySDK.CountlyCommon.Entities;
using CountlySDK.Entities;
using Newtonsoft.Json;
using Xunit;
Expand Down Expand Up @@ -132,6 +135,165 @@ public void DownloadKeys_Invalid()
}
}

[Fact]
/// <summary>
/// By default (auto opt-in enabled), a Remote Config download request includes oi=1.
/// </summary>
public void DownloadKeys_AutoEnrollDefault_SendsOptIn()
{
string body = null;
MockHttpServer server = new MockHttpServer((b) => {
if (b.Contains("method=rc")) { body = b; return "{}"; }
return null;
});
CountlyConfig cc = TestHelper.GetConfig();
cc.serverUrl = server.Url;

Countly.Instance.Init(cc).Wait();
Countly.Instance.RemoteConfig().DownloadKeys().Wait();

Assert.Contains("oi=1", body);
server.Dispose();
}

[Fact]
/// <summary>
/// When auto opt-in is disabled via config, the Remote Config download request omits oi.
/// </summary>
public void DownloadKeys_AutoEnrollDisabled_OmitsOptIn()
{
string body = null;
MockHttpServer server = new MockHttpServer((b) => {
if (b.Contains("method=rc")) { body = b; return "{}"; }
return null;
});
CountlyConfig cc = TestHelper.GetConfig();
cc.serverUrl = server.Url;
cc.DisableAutoEnrollInABTesting();

Countly.Instance.Init(cc).Wait();
Countly.Instance.RemoteConfig().DownloadKeys().Wait();

Assert.DoesNotContain("oi=1", body);
server.Dispose();
}

[Fact]
/// <summary>
/// Enrolling into A/B tests queues a method=ab request carrying the JSON-encoded keys.
/// </summary>
public void EnrollIntoABTestsForKeys_QueuesAbRequest()
{
MockHttpServer server = new MockHttpServer();
CountlyConfig cc = TestHelper.GetConfig();
cc.serverUrl = server.Url;

Countly.Instance.Init(cc).Wait();
Countly.Instance.deferUpload = true;
Countly.Instance.StoredRequests.Clear();

Countly.Instance.RemoteConfig().EnrollIntoABTestsForKeys(new List<string> { "a", "b" }).Wait();

Assert.Single(Countly.Instance.StoredRequests);
StoredRequest sr = Countly.Instance.StoredRequests.Dequeue();
NameValueCollection q = HttpUtility.ParseQueryString(sr.Request);
Assert.Equal("ab", q.Get("method"));
Assert.Equal("[\"a\",\"b\"]", q.Get("keys"));
server.Dispose();
}

[Fact]
/// <summary>
/// Enrolling with null or empty keys is a no-op (nothing is queued).
/// </summary>
public void EnrollIntoABTestsForKeys_NoKeys_QueuesNothing()
{
MockHttpServer server = new MockHttpServer();
CountlyConfig cc = TestHelper.GetConfig();
cc.serverUrl = server.Url;

Countly.Instance.Init(cc).Wait();
Countly.Instance.deferUpload = true;
Countly.Instance.StoredRequests.Clear();

Countly.Instance.RemoteConfig().EnrollIntoABTestsForKeys(null).Wait();
Countly.Instance.RemoteConfig().EnrollIntoABTestsForKeys(new List<string>()).Wait();

Assert.Empty(Countly.Instance.StoredRequests);
server.Dispose();
}

[Fact]
/// <summary>
/// Exiting A/B tests with keys queues a method=ab_opt_out request carrying the JSON keys.
/// </summary>
public void ExitABTestsForKeys_WithKeys_QueuesOptOutRequest()
{
MockHttpServer server = new MockHttpServer();
CountlyConfig cc = TestHelper.GetConfig();
cc.serverUrl = server.Url;

Countly.Instance.Init(cc).Wait();
Countly.Instance.deferUpload = true;
Countly.Instance.StoredRequests.Clear();

Countly.Instance.RemoteConfig().ExitABTestsForKeys(new List<string> { "a", "b" }).Wait();

Assert.Single(Countly.Instance.StoredRequests);
StoredRequest sr = Countly.Instance.StoredRequests.Dequeue();
NameValueCollection q = HttpUtility.ParseQueryString(sr.Request);
Assert.Equal("ab_opt_out", q.Get("method"));
Assert.Equal("[\"a\",\"b\"]", q.Get("keys"));
server.Dispose();
}

[Fact]
/// <summary>
/// Exiting with no keys (exit from ALL tests) queues method=ab_opt_out with no keys param.
/// </summary>
public void ExitABTestsForKeys_NoKeys_QueuesOptOutWithoutKeys()
{
MockHttpServer server = new MockHttpServer();
CountlyConfig cc = TestHelper.GetConfig();
cc.serverUrl = server.Url;

Countly.Instance.Init(cc).Wait();
Countly.Instance.deferUpload = true;
Countly.Instance.StoredRequests.Clear();

Countly.Instance.RemoteConfig().ExitABTestsForKeys().Wait();

Assert.Single(Countly.Instance.StoredRequests);
StoredRequest sr = Countly.Instance.StoredRequests.Dequeue();
NameValueCollection q = HttpUtility.ParseQueryString(sr.Request);
Assert.Equal("ab_opt_out", q.Get("method"));
Assert.Null(q.Get("keys"));
server.Dispose();
}

[Fact]
/// <summary>
/// When consent is required but Remote Config consent is not granted, enroll/exit are
/// gated by the RemoteConfig() accessor (returns a mock) and queue nothing.
/// </summary>
public void EnrollExit_ConsentRequiredNotGranted_QueuesNothing()
{
MockHttpServer server = new MockHttpServer();
CountlyConfig cc = TestHelper.GetConfig();
cc.serverUrl = server.Url;
cc.consentRequired = true;

Countly.Instance.Init(cc).Wait();
Countly.Instance.deferUpload = true;
Countly.Instance.StoredRequests.Clear();

Countly.Instance.RemoteConfig().EnrollIntoABTestsForKeys(new List<string> { "a" }).Wait();
Countly.Instance.RemoteConfig().ExitABTestsForKeys(new List<string> { "a" }).Wait();

Assert.Empty(Countly.Instance.StoredRequests);
server.Dispose();
}

private void RemoteConfigDownloadFlow(Action<CountlyConfig> configSetter, Action runnable = null, bool expectDownload = true, int calledTimesExpected = 1)
{
IDictionary<string, object> expectedRcValues = new Dictionary<string, object>() {
Expand Down
2 changes: 1 addition & 1 deletion countlyCommon/countlyCommon/CountlyBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@
ModuleBackendMode backendMode = moduleBackendMode;
if (backendMode != null) {
backendMode.OnTimer();
Upload();

Check warning on line 235 in countlyCommon/countlyCommon/CountlyBase.cs

View workflow job for this annotation

GitHub Actions / Feedback + UI unit tests (net8.0-windows)

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
}
return;
}
Expand Down Expand Up @@ -1543,7 +1543,7 @@
}
if (keptRequests.Count != originalCount) {
StoredRequests = keptRequests;
SaveStoredRequests();

Check warning on line 1546 in countlyCommon/countlyCommon/CountlyBase.cs

View workflow job for this annotation

GitHub Actions / Feedback + UI unit tests (net8.0-windows)

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
}
}
}
Expand Down Expand Up @@ -1586,7 +1586,7 @@

StoredRequests.Enqueue(sr);
if (!Configuration.backendMode) {
SaveStoredRequests();

Check warning on line 1589 in countlyCommon/countlyCommon/CountlyBase.cs

View workflow job for this annotation

GitHub Actions / Feedback + UI unit tests (net8.0-windows)

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
} else {
UtilityHelper.CountlyLogging("[CountlyBase] AddRequest, Backend mode enabled, request storage disabled");
}
Expand Down Expand Up @@ -1672,7 +1672,7 @@
await SetConsentInternal(config.givenConsent, ConsentChangedAction.Initialization);
}

moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl);
moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl, Configuration.enableABTestingAutoEnroll);
moduleFeedback = new ModuleFeedback(requestHelper, ServerUrl);
moduleContent = new ModuleContent(requestHelper, ServerUrl);

Expand Down
18 changes: 18 additions & 0 deletions countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ public int ContentZoneTimerInterval {
internal bool autoSendUserDetails = true;
internal bool remoteConfigAutomaticDownloadTriggers = false;

// <summary>
/// A/B testing auto opt-in during Remote Config fetch (adds oi=1 to the rc request).
/// Enabled by default; disable with DisableAutoEnrollInABTesting().
/// </summary>
internal bool enableABTestingAutoEnroll = true;

// <summary>
/// Developer-provided SDK Behavior Settings (Server Config) JSON, applied as a
/// precedence layer below server-fetched settings. May be a full {v,t,c} envelope
Expand Down Expand Up @@ -349,6 +355,18 @@ public CountlyConfigBase DisableHealthCheck()
return this;
}

/// <summary>
/// Disables automatically opting the user into A/B tests when Remote Config values are
/// downloaded. Auto opt-in is enabled by default; call this to enroll manually instead
/// (via RemoteConfig().EnrollIntoABTestsForKeys).
/// </summary>
/// <returns>Config for call chaining</returns>
public CountlyConfigBase DisableAutoEnrollInABTesting()
{
enableABTestingAutoEnroll = false;
return this;
}

/// <summary>
/// Sets a listener that receives every SDK log message and its level. Fires regardless
/// of whether console logging is enabled. A throwing listener cannot break the SDK.
Expand Down
54 changes: 52 additions & 2 deletions countlyCommon/countlyCommon/Modules/ModuleRemoteConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ internal class ModuleRemoteConfig : RemoteConfig
private readonly string ServerUrl;
private IDictionary<string, RCData> rcValues = new Dictionary<string, RCData>();
private object RCLock = new object();
private bool AutoEnrollEnabled = true;
private bool AutoEnrollEnabled;

public ModuleRemoteConfig(RequestHelper requestHelper, string serverUrl)
public ModuleRemoteConfig(RequestHelper requestHelper, string serverUrl, bool autoEnroll)
{
this.requestHelper = requestHelper;
ServerUrl = serverUrl;
AutoEnrollEnabled = autoEnroll;
}

public async Task DownloadKeys(List<string> keysToInclude = null, List<string> keysToOmit = null)
Expand Down Expand Up @@ -92,6 +93,31 @@ public RCData GetValue(string key)
GetValues().TryGetValue(key, out RCData value);
return value;
}

public async Task EnrollIntoABTestsForKeys(List<string> keys)
{
if (keys == null || keys.Count == 0) {
UtilityHelper.CountlyLogging("[ModuleRemoteConfig] EnrollIntoABTestsForKeys, no keys provided, ignoring call", LogLevel.WARNING);
return;
}

IDictionary<string, object> abParams = new Dictionary<string, object> {
{ "method", "ab" },
{ "keys", JsonConvert.SerializeObject(keys) }
};
await Countly.Instance.AddRequest(await requestHelper.BuildRequest(abParams));
}

public async Task ExitABTestsForKeys(List<string> keys = null)
{
IDictionary<string, object> abParams = new Dictionary<string, object> {
{ "method", "ab_opt_out" }
};
if (keys != null && keys.Count > 0) {
abParams.Add("keys", JsonConvert.SerializeObject(keys));
}
await Countly.Instance.AddRequest(await requestHelper.BuildRequest(abParams));
}
}

internal class MockRemoteConfig : RemoteConfig
Expand All @@ -114,6 +140,14 @@ IDictionary<string, RCData> RemoteConfig.GetValues()
{
return new Dictionary<string, RCData>();
}

async Task RemoteConfig.EnrollIntoABTestsForKeys(List<string> keys)
{
}

async Task RemoteConfig.ExitABTestsForKeys(List<string> keys)
{
}
}

/// <summary>
Expand Down Expand Up @@ -173,5 +207,21 @@ public interface RemoteConfig
/// otherwise, <c>null</c>.
/// </returns>
RCData GetValue(string key);

/// <summary>
/// Enrolls the user into A/B tests for the given Remote Config keys (method=ab).
/// A null or empty list is ignored.
/// </summary>
/// <param name="keys">The Remote Config keys to enroll into.</param>
/// <returns>A task that represents the asynchronous enroll operation.</returns>
Task EnrollIntoABTestsForKeys(List<string> keys);

/// <summary>
/// Exits the user from A/B tests for the given keys (method=ab_opt_out).
/// Passing null or an empty list exits the user from ALL tests.
/// </summary>
/// <param name="keys">The keys to exit; null or empty exits all tests.</param>
/// <returns>A task that represents the asynchronous exit operation.</returns>
Task ExitABTestsForKeys(List<string> keys = null);
}
}
Loading