diff --git a/src/ConfigCat.Client.Tests/ConfigCacheTests.cs b/src/ConfigCat.Client.Tests/ConfigCacheTests.cs index 5e959285..bd215f23 100644 --- a/src/ConfigCat.Client.Tests/ConfigCacheTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigCacheTests.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using ConfigCat.Client.Cache; +using ConfigCat.Client.Tests.Fakes; using ConfigCat.Client.Tests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -70,20 +71,20 @@ public async Task ConfigCache_Override_ManualPoll_Works() }); configCacheMock.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Never); + configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Once); var actual = await client.GetValueAsync("stringDefaultCat", "N/A"); Assert.AreEqual("N/A", actual); configCacheMock.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Once); + configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); await client.ForceRefreshAsync(); actual = await client.GetValueAsync("stringDefaultCat", "N/A"); Assert.AreEqual("Cat", actual); configCacheMock.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); - configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Exactly(3)); + configCacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Exactly(4)); } [TestMethod] @@ -246,36 +247,4 @@ public void CachePayloadSerialization_ShouldBePlatformIndependent(string configJ Assert.AreEqual(expectedPayload, ProjectConfig.Serialize(pc)); } - - private sealed class FakeExternalCache : IConfigCatCache - { - public volatile string? CachedValue = null; - - public string? Get(string key) => this.CachedValue; - - public Task GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(Get(key)); - - public void Set(string key, string value) => this.CachedValue = value; - - public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) - { - Set(key, value); - return Task.FromResult(0); - } - } - - private sealed class FaultyFakeExternalCache : IConfigCatCache - { - public string? Get(string key) => throw new ApplicationException("Operation failed :("); - - public Task GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(Get(key)); - - public void Set(string key, string value) => throw new ApplicationException("Operation failed :("); - - public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) - { - Set(key, value); - return Task.FromResult(0); - } - } } diff --git a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs index 57d7a0b7..d860edc9 100644 --- a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -12,6 +13,8 @@ using ConfigCat.Client.ConfigService; using ConfigCat.Client.Configuration; using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Override; +using ConfigCat.Client.Tests.Fakes; using ConfigCat.Client.Tests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -98,6 +101,387 @@ public void CreateAnInstance_WithSdkKey_ShouldCreateAnInstance() using var _ = ConfigCatClient.Get("hsdrTr4sxbHdSgdhHRZds346hdgsS2vfsgf/GsdrTr4sxbHdSgdhHRZds346hdOPsSgvfsgf"); } + [TestMethod] + public async Task Initialization_AutoPoll_ConfigChangedInEveryFetch_ShouldFireConfigChangedEveryPollingIteration() + { + var pollInterval = TimeSpan.FromSeconds(1); + + var fetchCounter = 0; + var configChangedEventCount = 0; + + var hooks = new Hooks(); + hooks.ConfigChanged += delegate { configChangedEventCount++; }; + + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: cfg => FetchResult.Success(ConfigHelper.FromString("{}", httpETag: $"\"{(++fetchCounter).ToString(CultureInfo.InvariantCulture)}\"", timeStamp: ProjectConfig.GenerateTimeStamp())), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new AutoPollConfigService(PollingModes.AutoPoll(pollInterval), this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + hooks, out _, out _ + ); + + await Task.Delay(TimeSpan.FromMilliseconds(2.5 * pollInterval.TotalMilliseconds)); + + Assert.AreEqual(3, configChangedEventCount); + } + + [TestMethod] + public async Task Initialization_AutoPoll_ConfigNotChanged_ShouldFireConfigChangedOnlyOnce() + { + var pollInterval = TimeSpan.FromSeconds(1); + + var fetchCounter = 0; + var configChangedEventCount = 0; + + var hooks = new Hooks(); + hooks.ConfigChanged += delegate { configChangedEventCount++; }; + + var pc = ConfigHelper.FromString("{}", httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: cfg => fetchCounter++ > 0 + ? FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())) + : FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new AutoPollConfigService(PollingModes.AutoPoll(pollInterval), this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + hooks, out _, out _ + ); + + await Task.Delay(TimeSpan.FromMilliseconds(2.5 * pollInterval.TotalMilliseconds)); + + Assert.AreEqual(1, configChangedEventCount); + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task Initialization_AutoPoll_WithMaxInitWaitTime_GetValueShouldWait(bool isAsync) + { + var maxInitWaitTime = TimeSpan.FromSeconds(2); + var delay = TimeSpan.FromMilliseconds(maxInitWaitTime.TotalMilliseconds / 4); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + var sw = new Stopwatch(); + sw.Start(); + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: cfg => + { + Thread.Sleep(delay); + return FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + onFetchAsync: async (cfg, _) => + { + await Task.Delay(delay); + return FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + configServiceFactory: (fetcher, cacheParams, loggerWrapper, _) => new AutoPollConfigService(PollingModes.AutoPoll(maxInitWaitTime: maxInitWaitTime), this.fetcherMock.Object, cacheParams, loggerWrapper), + evaluatorFactory: null, configCacheFactory: null, overrideDataSourceFactory: null, hooks: null, out _ + ); + + var actualValue = isAsync + ? await client.GetValueAsync("boolDefaultTrue", false) + : client.GetValue("boolDefaultTrue", false); + + sw.Stop(); + + Assert.IsTrue(sw.Elapsed >= delay - TimeSpan.FromMilliseconds(10), $"Elapsed time: {sw.Elapsed}"); + Assert.IsTrue(sw.Elapsed <= delay + TimeSpan.FromMilliseconds(150), $"Elapsed time: {sw.Elapsed}"); // 150ms for tolerance + Assert.IsTrue(actualValue); + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task Initialization_AutoPoll_WithMaxInitWaitTime_GetValueShouldWaitForMaxInitWaitTimeOnlyAndReturnDefaultValue(bool isAsync) + { + var maxInitWaitTime = TimeSpan.FromSeconds(1); + var delay = TimeSpan.FromMilliseconds(maxInitWaitTime.TotalMilliseconds * 4); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + var sw = new Stopwatch(); + sw.Start(); + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: cfg => + { + Thread.Sleep(delay); + return FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + onFetchAsync: async (cfg, _) => + { + await Task.Delay(delay); + return FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + configServiceFactory: (fetcher, cacheParams, loggerWrapper, _) => new AutoPollConfigService(PollingModes.AutoPoll(maxInitWaitTime: maxInitWaitTime), this.fetcherMock.Object, cacheParams, loggerWrapper), + evaluatorFactory: null, configCacheFactory: null, overrideDataSourceFactory: null, hooks: null, out _ + ); + + var actualValue = isAsync + ? await client.GetValueAsync("boolDefaultTrue", false) + : client.GetValue("boolDefaultTrue", false); + + sw.Stop(); + + Assert.IsTrue(sw.Elapsed >= maxInitWaitTime - TimeSpan.FromMilliseconds(10), $"Elapsed time: {sw.Elapsed}"); + Assert.IsTrue(sw.Elapsed <= maxInitWaitTime + TimeSpan.FromMilliseconds(150), $"Elapsed time: {sw.Elapsed}"); // 150ms for tolerance + + Assert.IsFalse(actualValue); + } + + [TestMethod] + public async Task WaitForReadyAsync_AutoPoll_ShouldWait() + { + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + var hooks = new Hooks(); + + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: cfg => FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new AutoPollConfigService(PollingModes.AutoPoll(), this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + hooks, out _, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + Assert.AreEqual(ClientCacheState.HasUpToDateFlagData, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_AutoPoll_ShouldWaitForMaxInitWaitTime() + { + var maxInitWaitTime = TimeSpan.FromSeconds(1); + var delay = TimeSpan.FromMilliseconds(maxInitWaitTime.TotalMilliseconds * 4); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + var hooks = new Hooks(); + + var sw = new Stopwatch(); + sw.Start(); + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: cfg => + { + Thread.Sleep(delay); + return FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + onFetchAsync: async (cfg, _) => + { + await Task.Delay(delay); + return FetchResult.Success(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new AutoPollConfigService(PollingModes.AutoPoll(maxInitWaitTime: maxInitWaitTime), this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + evaluatorFactory: null, configCacheFactory: null, overrideDataSourceFactory: null, hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + sw.Stop(); + + Assert.IsTrue(sw.Elapsed >= maxInitWaitTime - TimeSpan.FromMilliseconds(10), $"Elapsed time: {sw.Elapsed}"); + Assert.IsTrue(sw.Elapsed <= maxInitWaitTime + TimeSpan.FromMilliseconds(100), $"Elapsed time: {sw.Elapsed}"); // 100ms for tolerance + + Assert.AreEqual(ClientCacheState.NoFlagData, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsFalse(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsTrue(evaluationDetails.IsDefaultValue); + Assert.IsFalse(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_AutoPoll_ShouldWaitForMaxInitWaitTimeAndReturnCached() + { + var maxInitWaitTime = TimeSpan.FromSeconds(1); + var pollInterval = TimeSpan.FromSeconds(5); + var delay = TimeSpan.FromMilliseconds(maxInitWaitTime.TotalMilliseconds * 4); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp() - pollInterval - TimeSpan.FromSeconds(1)); + + const string cacheKey = "1"; + var externalCache = new FakeExternalCache(); + externalCache.Set(cacheKey, ProjectConfig.Serialize(pc)); + + var hooks = new Hooks(); + + var sw = new Stopwatch(); + sw.Start(); + using var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, + onFetch: cfg => + { + Thread.Sleep(delay); + return FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + onFetchAsync: async (cfg, _) => + { + await Task.Delay(delay); + return FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())); + }, + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new AutoPollConfigService(PollingModes.AutoPoll(pollInterval, maxInitWaitTime), this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + evaluatorFactory: null, + configCacheFactory: logger => new ExternalConfigCache(externalCache, logger), + overrideDataSourceFactory: null, hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + sw.Stop(); + + Assert.IsTrue(sw.Elapsed >= maxInitWaitTime - TimeSpan.FromMilliseconds(10), $"Elapsed time: {sw.Elapsed}"); + Assert.IsTrue(sw.Elapsed <= maxInitWaitTime + TimeSpan.FromMilliseconds(100), $"Elapsed time: {sw.Elapsed}"); // 100ms for tolerance + + Assert.AreEqual(ClientCacheState.HasCachedFlagDataOnly, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_LazyLoad_ReturnCached_UpToDate() + { + var cacheTimeToLive = TimeSpan.FromSeconds(2); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + const string cacheKey = "1"; + var externalCache = new FakeExternalCache(); + externalCache.Set(cacheKey, ProjectConfig.Serialize(pc)); + + var hooks = new Hooks(); + + using var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, + onFetch: cfg => FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())), + onFetchAsync: (cfg, _) => Task.FromResult(FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp()))), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new LazyLoadConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper, cacheTimeToLive, hooks: hooks), + evaluatorFactory: null, + configCacheFactory: logger => new ExternalConfigCache(externalCache, logger), + overrideDataSourceFactory: null, hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + Assert.AreEqual(ClientCacheState.HasUpToDateFlagData, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_LazyLoad_ReturnCached_Expired() + { + var cacheTimeToLive = TimeSpan.FromSeconds(2); + + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp() - cacheTimeToLive - TimeSpan.FromSeconds(1)); + + const string cacheKey = "1"; + var externalCache = new FakeExternalCache(); + externalCache.Set(cacheKey, ProjectConfig.Serialize(pc)); + + var hooks = new Hooks(); + + using var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, + onFetch: cfg => FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())), + onFetchAsync: (cfg, _) => Task.FromResult(FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp()))), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new LazyLoadConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper, cacheTimeToLive, hooks: hooks), + evaluatorFactory: null, + configCacheFactory: logger => new ExternalConfigCache(externalCache, logger), + overrideDataSourceFactory: null, hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + Assert.AreEqual(ClientCacheState.HasCachedFlagDataOnly, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_ManualPoll_ReturnCached() + { + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + var pc = ConfigHelper.FromFile(configJsonFilePath, httpETag: $"\0\"", timeStamp: ProjectConfig.GenerateTimeStamp()); + + const string cacheKey = "1"; + var externalCache = new FakeExternalCache(); + externalCache.Set(cacheKey, ProjectConfig.Serialize(pc)); + + var hooks = new Hooks(); + + using var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, + onFetch: cfg => FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp())), + onFetchAsync: (cfg, _) => Task.FromResult(FetchResult.NotModified(pc.With(ProjectConfig.GenerateTimeStamp()))), + configServiceFactory: (fetcher, cacheParams, loggerWrapper, hooks) => new ManualPollConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks), + evaluatorFactory: null, + configCacheFactory: logger => new ExternalConfigCache(externalCache, logger), + overrideDataSourceFactory: null, hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + Assert.AreEqual(ClientCacheState.HasCachedFlagDataOnly, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + + [TestMethod] + public async Task WaitForReadyAsync_LocalOnlyFlagOverride() + { + var configJsonFilePath = Path.Combine("data", "sample_v5.json"); + + var hooks = new Hooks(); + + using var client = CreateClientWithMockedFetcher("1", this.loggerMock, this.fetcherMock, + onFetch: delegate { throw new InvalidOperationException(); }, + onFetchAsync: delegate { throw new InvalidOperationException(); }, + configServiceFactory: (_, _, loggerWrapper, hooks) => new NullConfigService(loggerWrapper, hooks: hooks), + evaluatorFactory: null, configCacheFactory: null, + overrideDataSourceFactory: logger => Tuple.Create(OverrideBehaviour.LocalOnly, (IOverrideDataSource)new LocalFileDataSource(configJsonFilePath, autoReload: false, logger)), + hooks, out _ + ); + + var cacheState = await client.WaitForReadyAsync(); + + Assert.AreEqual(ClientCacheState.HasLocalOverrideFlagDataOnly, cacheState); + + var snapshot = client.Snapshot(); + Assert.IsTrue(snapshot.GetValue("boolDefaultTrue", false)); + + var evaluationDetails = snapshot.GetValueDetails("boolDefaultTrue", false); + Assert.IsFalse(evaluationDetails.IsDefaultValue); + Assert.IsTrue(evaluationDetails.Value); + } + [TestMethod] public void GetValue_ConfigServiceThrowException_ShouldReturnDefaultValue() { @@ -153,7 +537,7 @@ public void GetValue_EvaluateServiceThrowException_ShouldReturnDefaultValue() .Setup(m => m.Evaluate(It.IsAny(), It.IsAny(), defaultValue, null)) .Throws(); - var client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); + var client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, hooks: new Hooks()); var flagEvaluatedEvents = new List(); client.FlagEvaluated += (s, e) => flagEvaluatedEvents.Add(e); @@ -182,7 +566,7 @@ public async Task GetValueAsync_EvaluateServiceThrowException_ShouldReturnDefaul .Setup(m => m.Evaluate(It.IsAny(), It.IsAny(), defaultValue, null)) .Throws(); - var client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); + var client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, hooks: new Hooks()); var flagEvaluatedEvents = new List(); client.FlagEvaluated += (s, e) => flagEvaluatedEvents.Add(e); @@ -468,7 +852,7 @@ public async Task GetValueDetails_EvaluateServiceThrowException_ShouldReturnDefa var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, onFetch: _ => FetchResult.Success(ConfigHelper.FromFile(configJsonFilePath, httpETag: "12345", timeStamp)), - configServiceFactory: (fetcher, cacheParams, loggerWrapper) => + configServiceFactory: (fetcher, cacheParams, loggerWrapper, _) => { return new ManualPollConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper); }, @@ -526,12 +910,11 @@ public async Task GetAllValueDetails_ShouldReturnCorrectEvaluationDetails(bool i var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, onFetch: _ => FetchResult.Success(ConfigHelper.FromFile(configJsonFilePath, httpETag: "12345", timeStamp)), - configServiceFactory: (fetcher, cacheParams, loggerWrapper) => + configServiceFactory: (fetcher, cacheParams, loggerWrapper, _) => { return new ManualPollConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper); }, - evaluatorFactory: loggerWrapper => new RolloutEvaluator(loggerWrapper), new Hooks(), - out var configService, out _); + evaluatorFactory: null, new Hooks(), out var configService, out _); if (isAsync) { @@ -596,7 +979,7 @@ public async Task GetAllValueDetails_DeserializeFailed_ShouldReturnWithEmptyArra this.configServiceMock.Setup(m => m.GetConfigAsync(It.IsAny())).ReturnsAsync(ProjectConfig.Empty); var o = new SettingsWithPreferences(); - using IConfigCatClient client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); + using IConfigCatClient client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, hooks: new Hooks()); var flagEvaluatedEvents = new List(); client.FlagEvaluated += (s, e) => flagEvaluatedEvents.Add(e); @@ -626,7 +1009,7 @@ public async Task GetAllValueDetails_ConfigServiceThrowException_ShouldReturnEmp .Setup(m => m.GetConfigAsync(It.IsAny())) .Throws(); - using IConfigCatClient client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, new Hooks()); + using IConfigCatClient client = new ConfigCatClient(this.configServiceMock.Object, this.loggerMock.Object, this.evaluatorMock.Object, hooks: new Hooks()); var flagEvaluatedEvents = new List(); client.FlagEvaluated += (s, e) => flagEvaluatedEvents.Add(e); @@ -663,7 +1046,7 @@ public async Task GetAllValueDetails_EvaluateServiceThrowException_ShouldReturnD var client = CreateClientWithMockedFetcher(cacheKey, this.loggerMock, this.fetcherMock, onFetch: _ => FetchResult.Success(ConfigHelper.FromFile(configJsonFilePath, httpETag: "12345", timeStamp)), - configServiceFactory: (fetcher, cacheParams, loggerWrapper) => + configServiceFactory: (fetcher, cacheParams, loggerWrapper, _) => { return new ManualPollConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper); }, @@ -1343,47 +1726,6 @@ static void CreateClients(out int instanceCount) Assert.AreEqual(0, instanceCount2); } - private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, - Mock loggerMock, - Mock fetcherMock, - Func onFetch, - Func configServiceFactory, - out IConfigService configService, - out ConfigCache configCache) - { - return CreateClientWithMockedFetcher(cacheKey, loggerMock, fetcherMock, onFetch, configServiceFactory, - evaluatorFactory: loggerWrapper => new RolloutEvaluator(loggerWrapper), hooks: null, - out configService, out configCache); - } - - private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, - Mock loggerMock, - Mock fetcherMock, - Func onFetch, - Func configServiceFactory, - Func evaluatorFactory, - Hooks? hooks, - out IConfigService configService, - out ConfigCache configCache) - { - fetcherMock.Setup(m => m.Fetch(It.IsAny())).Returns(onFetch); - fetcherMock.Setup(m => m.FetchAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ProjectConfig pc, CancellationToken _) => onFetch(pc)); - - var loggerWrapper = loggerMock.Object.AsWrapper(); - - configCache = new InMemoryConfigCache(); - - var cacheParams = new CacheParameters(configCache, cacheKey); - - configService = configServiceFactory(fetcherMock.Object, cacheParams, loggerWrapper); - return new ConfigCatClient(configService, loggerMock.Object, evaluatorFactory(loggerWrapper), hooks); - } - - private static int ParseETagAsInt32(string? etag) - { - return int.TryParse(etag, NumberStyles.None, CultureInfo.InvariantCulture, out var value) ? value : 0; - } - [DataRow(nameof(AutoPoll))] [DataRow(nameof(LazyLoad))] [DataRow(nameof(ManualPoll))] @@ -1674,7 +2016,10 @@ public async Task Hooks_MockedClientRaisesEvents() var configService = new ManualPollConfigService(this.fetcherMock.Object, cacheParams, loggerWrapper, hooks: hooks); // 1. Client gets created - var client = new ConfigCatClient(configService, this.loggerMock.Object, new RolloutEvaluator(loggerWrapper), hooks); + var client = new ConfigCatClient(configService, this.loggerMock.Object, new RolloutEvaluator(loggerWrapper), hooks: hooks); + + var cacheState = await client.WaitForReadyAsync(); + Assert.AreEqual(ClientCacheState.NoFlagData, cacheState); Assert.AreEqual(1, clientReadyEventCount); Assert.AreEqual(0, configChangedEvents.Count); @@ -1775,6 +2120,9 @@ void Unsubscribe(IProvidesHooks hooks) Subscribe(client); } + var cacheState = await client.WaitForReadyAsync(); + Assert.AreEqual(ClientCacheState.NoFlagData, cacheState); + Assert.AreEqual(subscribeViaOptions ? 2 : 0, clientReadyCallCount); Assert.AreEqual(0, configChangedEvents.Count); Assert.AreEqual(0, flagEvaluatedEvents.Count); @@ -1828,6 +2176,83 @@ void Unsubscribe(IProvidesHooks hooks) Assert.AreEqual(2, errorEvents.Count); } + private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, + Mock loggerMock, + Mock fetcherMock, + Func onFetch, + Func configServiceFactory, + out IConfigService configService, + out ConfigCache configCache) + { + return CreateClientWithMockedFetcher(cacheKey, loggerMock, fetcherMock, onFetch, + configServiceFactory: (fetcher, cacheParams, logger, hooks) => configServiceFactory(fetcher, cacheParams, logger), + hooks: null, out configService, out configCache); + } + + private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, + Mock loggerMock, + Mock fetcherMock, + Func onFetch, + Func configServiceFactory, + Hooks? hooks, + out IConfigService configService, + out ConfigCache configCache) + { + return CreateClientWithMockedFetcher(cacheKey, loggerMock, fetcherMock, onFetch, configServiceFactory, + evaluatorFactory: null, hooks, out configService, out configCache); + } + + private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, + Mock loggerMock, + Mock fetcherMock, + Func onFetch, + Func configServiceFactory, + Func? evaluatorFactory, + Hooks? hooks, + out IConfigService configService, + out ConfigCache configCache) + { + var configCacheLocal = configCache = new InMemoryConfigCache(); + + return CreateClientWithMockedFetcher(cacheKey, loggerMock, fetcherMock, + onFetch, onFetchAsync: (pc, _) => Task.FromResult(onFetch(pc)), + configServiceFactory, evaluatorFactory, + configCacheFactory: _ => configCacheLocal, + overrideDataSourceFactory: null, hooks, out configService); + } + + private static IConfigCatClient CreateClientWithMockedFetcher(string cacheKey, + Mock loggerMock, + Mock fetcherMock, + Func onFetch, + Func> onFetchAsync, + Func configServiceFactory, + Func? evaluatorFactory, + Func? configCacheFactory, + Func>? overrideDataSourceFactory, + Hooks? hooks, + out IConfigService configService) + { + fetcherMock.Setup(m => m.Fetch(It.IsAny())).Returns(onFetch); + fetcherMock.Setup(m => m.FetchAsync(It.IsAny(), It.IsAny())).Returns((ProjectConfig pc, CancellationToken ct) => onFetchAsync(pc, ct)); + + var loggerWrapper = loggerMock.Object.AsWrapper(); + + var configCache = configCacheFactory is not null ? configCacheFactory(loggerWrapper) : new InMemoryConfigCache(); + var cacheParams = new CacheParameters(configCache, cacheKey); + + var evaluator = evaluatorFactory is not null ? evaluatorFactory(loggerWrapper) : new RolloutEvaluator(loggerWrapper); + var overrideDataSource = overrideDataSourceFactory?.Invoke(loggerWrapper); + + configService = configServiceFactory(fetcherMock.Object, cacheParams, loggerWrapper, hooks); + return new ConfigCatClient(configService, loggerMock.Object, evaluator, overrideDataSource?.Item1, overrideDataSource?.Item2, hooks); + } + + private static int ParseETagAsInt32(string? etag) + { + return int.TryParse(etag, NumberStyles.None, CultureInfo.InvariantCulture, out var value) ? value : 0; + } + internal class FakeConfigService : ConfigServiceBase, IConfigService { public byte DisposeCount { get; private set; } diff --git a/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs b/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs index 4c0a5db0..621dee5f 100644 --- a/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs +++ b/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Threading.Tasks; using ConfigCat.Client.Evaluation; -using ConfigCat.Client.Tests.Helpers; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace ConfigCat.Client.Tests; diff --git a/src/ConfigCat.Client.Tests/ConfigServiceTests.cs b/src/ConfigCat.Client.Tests/ConfigServiceTests.cs index 693cdc9b..f77bd3e1 100644 --- a/src/ConfigCat.Client.Tests/ConfigServiceTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigServiceTests.cs @@ -122,19 +122,19 @@ public async Task LazyLoadConfigService_RefreshConfigAsync_ShouldNotInvokeCacheG this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc) - .Callback(() => Assert.AreEqual(1, callOrder++)) + .Callback(() => Assert.IsTrue(callOrder++ is 1 or 2)) .Verifiable(); this.fetcherMock .Setup(m => m.FetchAsync(cachedPc, It.IsAny())) .ReturnsAsync(FetchResult.Success(fetchedPc)) - .Callback(() => Assert.AreEqual(2, callOrder++)) + .Callback(() => Assert.AreEqual(3, callOrder++)) .Verifiable(); this.cacheMock .Setup(m => m.SetAsync(It.IsAny(), fetchedPc, It.IsAny())) .Returns(default(ValueTask)) - .Callback(() => Assert.AreEqual(3, callOrder)) + .Callback(() => Assert.AreEqual(4, callOrder)) .Verifiable(); using var service = new LazyLoadConfigService( @@ -228,6 +228,8 @@ public async Task AutoPollConfigService_GetConfigAsync_WithoutTimerWithCachedCon this.loggerMock.Object.AsWrapper(), startTimer: false); + this.cacheMock.Invocations.Clear(); + // Act await service.GetConfigAsync(); @@ -251,6 +253,8 @@ public async Task AutoPollConfigService_GetConfigAsync_WithTimer_ShouldInvokeFet var wd = new ManualResetEventSlim(false); + this.cacheMock.SetupGet(m => m.LocalCachedConfig).Returns(cachedPc); + this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc); @@ -295,6 +299,8 @@ public async Task AutoPollConfigService_RefreshConfigAsync_ShouldOnceInvokeCache var cachedPc = CreateUpToDatePc(timeStamp, pollInterval); var fetchedPc = CreateFreshPc(timeStamp); + this.cacheMock.SetupGet(m => m.LocalCachedConfig).Returns(cachedPc); + this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc); @@ -314,6 +320,8 @@ public async Task AutoPollConfigService_RefreshConfigAsync_ShouldOnceInvokeCache this.loggerMock.Object.AsWrapper(), startTimer: false); + this.cacheMock.Invocations.Clear(); + // Act await service.RefreshConfigAsync(); @@ -337,6 +345,8 @@ public async Task AutoPollConfigService_Dispose_ShouldStopTimer() long counter = 0; long e1, e2; + this.cacheMock.SetupGet(m => m.LocalCachedConfig).Returns(cachedPc); + this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc); @@ -378,6 +388,8 @@ public async Task AutoPollConfigService_WithoutTimer_InvokeDispose_ShouldDispose long counter = -1; long e1; + this.cacheMock.SetupGet(m => m.LocalCachedConfig).Returns(cachedPc); + this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc); @@ -426,6 +438,8 @@ public async Task ManualPollConfigService_GetConfigAsync_ShouldInvokeCacheGet() this.loggerMock.Object.AsWrapper(), hooks: hooks); + this.cacheMock.Invocations.Clear(); + // Act var projectConfig = await service.GetConfigAsync(); @@ -460,16 +474,16 @@ public async Task ManualPollConfigService_RefreshConfigAsync_ShouldInvokeCacheGe this.cacheMock .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(cachedPc) - .Callback(() => Assert.AreEqual(1, callOrder++)); + .Callback(() => Assert.IsTrue(callOrder++ is 1 or 2)); this.fetcherMock .Setup(m => m.FetchAsync(cachedPc, It.IsAny())) .ReturnsAsync(FetchResult.Success(fetchedPc)) - .Callback(() => Assert.AreEqual(2, callOrder++)); + .Callback(() => Assert.AreEqual(3, callOrder++)); this.cacheMock .Setup(m => m.SetAsync(It.IsAny(), fetchedPc, It.IsAny())) - .Callback(() => Assert.AreEqual(3, callOrder++)) + .Callback(() => Assert.AreEqual(4, callOrder++)) .Returns(default(ValueTask)); using var service = new ManualPollConfigService( @@ -483,7 +497,7 @@ public async Task ManualPollConfigService_RefreshConfigAsync_ShouldInvokeCacheGe // Assert - this.cacheMock.Verify(m => m.GetAsync(It.IsAny(), It.IsAny()), Times.Once); + this.cacheMock.Verify(m => m.GetAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); this.fetcherMock.Verify(m => m.FetchAsync(It.IsAny(), It.IsAny()), Times.Once); this.cacheMock.Verify(m => m.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); @@ -901,4 +915,113 @@ public async Task LazyLoadConfigService_GetConfig_FetchesConfigWhenCachedConfigI Assert.AreEqual(1, Volatile.Read(ref clientReadyEventCount)); } + + [DataTestMethod] + [DataRow(nameof(PollingModes.AutoPoll))] + [DataRow(nameof(PollingModes.LazyLoad))] + [DataRow(nameof(PollingModes.ManualPoll))] + public async Task GetInMemoryConfig_ImmediatelyReturnsEmptyConfig_WhenExternalCacheIsTrulyAsynchronous(string pollingMode) + { + // Arrange + + var pollInterval = TimeSpan.FromSeconds(30); + var timeStamp = ProjectConfig.GenerateTimeStamp(); + var cachedPc = CreateUpToDatePc(timeStamp, pollInterval); + var cachedPcSerialized = ProjectConfig.Serialize(cachedPc); + + var delay = TimeSpan.FromSeconds(1); + + var externalCache = new Mock(); + externalCache + .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) + .Returns(async (string _, CancellationToken _) => { await Task.Delay(delay); return cachedPcSerialized; }); + + var logger = this.loggerMock.Object.AsWrapper(); + + var service = CreateConfigService(pollingMode, pollInterval, maxInitWaitTime: TimeSpan.Zero, cacheTimeToLive: pollInterval, + new CacheParameters(new ExternalConfigCache(externalCache.Object, logger), cacheKey: null!), + this.fetcherMock.Object, logger); + + using var _ = service as IDisposable; + + // Act + + var inMemoryConfig = service.GetInMemoryConfig(); + + await Task.Delay(delay + delay); + + var inMemoryConfig2 = service.GetInMemoryConfig(); + + // Assert + + Assert.IsTrue(inMemoryConfig.IsEmpty); + + Assert.IsFalse(inMemoryConfig2.IsEmpty); + Assert.AreEqual(cachedPcSerialized, ProjectConfig.Serialize(inMemoryConfig2)); + } + + [DataTestMethod] + [DataRow(nameof(PollingModes.AutoPoll))] + [DataRow(nameof(PollingModes.LazyLoad))] + [DataRow(nameof(PollingModes.ManualPoll))] + public void GetInMemoryConfig_ImmediatelyReturnsCachedConfig_WhenExternalCacheIsNotTrulyAsynchronous(string pollingMode) + { + // Arrange + + var pollInterval = TimeSpan.FromSeconds(30); + var timeStamp = ProjectConfig.GenerateTimeStamp(); + var cachedPc = CreateUpToDatePc(timeStamp, pollInterval); + var cachedPcSerialized = ProjectConfig.Serialize(cachedPc); + + var externalCache = new Mock(); + externalCache + .Setup(m => m.GetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(cachedPcSerialized); + + var logger = this.loggerMock.Object.AsWrapper(); + + ProjectConfig.Serialize(cachedPc); + + var service = CreateConfigService(pollingMode, pollInterval, maxInitWaitTime: TimeSpan.Zero, cacheTimeToLive: pollInterval, + new CacheParameters(new ExternalConfigCache(externalCache.Object, logger), cacheKey: null!), + this.fetcherMock.Object, logger); + + using var _ = service as IDisposable; + + // Act + + var inMemoryConfig = service.GetInMemoryConfig(); + + // Assert + + Assert.IsFalse(inMemoryConfig.IsEmpty); + Assert.AreEqual(cachedPcSerialized, ProjectConfig.Serialize(inMemoryConfig)); + } + + private static IConfigService CreateConfigService(string pollingMode, TimeSpan pollInterval, TimeSpan maxInitWaitTime, TimeSpan cacheTimeToLive, CacheParameters cacheParams, + IConfigFetcher configFetcher, LoggerWrapper logger) + { + return pollingMode switch + { + nameof(PollingModes.AutoPoll) => new AutoPollConfigService( + PollingModes.AutoPoll(pollInterval, maxInitWaitTime), + configFetcher, + cacheParams, + logger, + startTimer: false), + + nameof(PollingModes.LazyLoad) => new LazyLoadConfigService( + configFetcher, + cacheParams, + logger, + cacheTimeToLive), + + nameof(PollingModes.ManualPoll) => new ManualPollConfigService( + configFetcher, + cacheParams, + logger), + + _ => throw new InvalidOperationException(), + }; + } } diff --git a/src/ConfigCat.Client.Tests/Fakes/FakeExternalCache.cs b/src/ConfigCat.Client.Tests/Fakes/FakeExternalCache.cs new file mode 100644 index 00000000..e05d173e --- /dev/null +++ b/src/ConfigCat.Client.Tests/Fakes/FakeExternalCache.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ConfigCat.Client.Tests.Fakes; + +internal sealed class FakeExternalCache : IConfigCatCache +{ + public volatile string? CachedValue = null; + + public string? Get(string key) => this.CachedValue; + + public Task GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(Get(key)); + + public void Set(string key, string value) => this.CachedValue = value; + + public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) + { + Set(key, value); + return Task.FromResult(0); + } +} + +internal sealed class FaultyFakeExternalCache : IConfigCatCache +{ + public string? Get(string key) => throw new ApplicationException("Operation failed :("); + + public Task GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(Get(key)); + + public void Set(string key, string value) => throw new ApplicationException("Operation failed :("); + + public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) + { + Set(key, value); + return Task.FromResult(0); + } +} diff --git a/src/ConfigCatClient/ConfigCatClient.cs b/src/ConfigCatClient/ConfigCatClient.cs index eb93d134..c7378e7f 100644 --- a/src/ConfigCatClient/ConfigCatClient.cs +++ b/src/ConfigCatClient/ConfigCatClient.cs @@ -93,7 +93,8 @@ internal ConfigCatClient(string sdkKey, ConfigCatClientOptions options) } // For test purposes only - internal ConfigCatClient(IConfigService configService, IConfigCatLogger logger, IRolloutEvaluator evaluator, Hooks? hooks = null) + internal ConfigCatClient(IConfigService configService, IConfigCatLogger logger, IRolloutEvaluator evaluator, + OverrideBehaviour? overrideBehaviour = null, IOverrideDataSource? overrideDataSource = null, Hooks? hooks = null) { this.hooks = hooks ?? NullHooks.Instance; this.hooks.SetSender(this); @@ -102,6 +103,9 @@ internal ConfigCatClient(IConfigService configService, IConfigCatLogger logger, this.evaluationServices = new EvaluationServices(evaluator, hooksWrapper, new LoggerWrapper(logger, hooks)); this.configService = configService; + + this.overrideBehaviour = overrideBehaviour; + this.overrideDataSource = overrideDataSource; } ///