From b0a99c5ed39f5bbec604a16e397ee684a699870e Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Wed, 5 Mar 2025 12:57:01 -0500 Subject: [PATCH 1/4] Ensure that kv collection refresh interval is not used unless collection based refresh of key-values is enabled. Add tests to ensure that minimum refresh interval is respected for key-values and feature flags. --- .../AzureAppConfigurationProvider.cs | 11 +- .../RefreshTests.cs | 218 ++++++++++++++++++ 2 files changed, 227 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index d7d629f8..9d61faca 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -118,6 +118,13 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan if (options.RegisterAllEnabled) { + if (options.KvCollectionRefreshInterval <= TimeSpan.Zero) + { + throw new ArgumentException( + $"{nameof(options.KvCollectionRefreshInterval)} must be greater than zero seconds when using RegisterAll for refresh", + nameof(options)); + } + MinRefreshInterval = TimeSpan.FromTicks(Math.Min(minWatcherRefreshInterval.Ticks, options.KvCollectionRefreshInterval.Ticks)); } else if (hasWatchers) @@ -206,7 +213,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) var utcNow = DateTimeOffset.UtcNow; IEnumerable refreshableIndividualKvWatchers = _options.IndividualKvWatchers.Where(kvWatcher => utcNow >= kvWatcher.NextRefreshTime); IEnumerable refreshableFfWatchers = _options.FeatureFlagWatchers.Where(ffWatcher => utcNow >= ffWatcher.NextRefreshTime); - bool isRefreshDue = utcNow >= _nextCollectionRefreshTime; + bool isRefreshDue = _options.RegisterAllEnabled && utcNow >= _nextCollectionRefreshTime; // Skip refresh if mappedData is loaded, but none of the watchers or adapters are refreshable. if (_mappedData != null && @@ -412,7 +419,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => } } - if (isRefreshDue) + if (_options.RegisterAllEnabled && isRefreshDue) { _nextCollectionRefreshTime = DateTimeOffset.UtcNow.Add(_options.KvCollectionRefreshInterval); } diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index af6dd11d..05625fc5 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -1241,6 +1241,224 @@ public void RefreshTests_ChainedConfigurationProviderUsedAsRootForRefresherProvi } #endif + [Fact] + public async Task RefreshTests_SingleKeyRefreshRespectsMinimumInterval() + { + int serverCallCount = 0; + IConfigurationRefresher refresher = null; + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + Response GetTestKey(string key, string label, CancellationToken cancellationToken) + { + serverCallCount++; + return Response.FromValue(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label), mockResponse.Object); + } + + Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) + { + serverCallCount++; + var newSetting = _kvCollection.FirstOrDefault(s => (s.Key == setting.Key && s.Label == setting.Label)); + var unchanged = (newSetting.Key == setting.Key && newSetting.Label == setting.Label && newSetting.Value == setting.Value); + var response = new MockResponse(unchanged ? 304 : 200); + return Response.FromValue(newSetting, response); + } + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(() => + { + serverCallCount++; + return new MockAsyncPageable(_kvCollection.Select(setting => TestHelpers.CloneSetting(setting)).ToList()); + }); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetTestKey); + + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetIfChanged); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Initial configuration load + Assert.Equal("TestValue1", config["TestKey1"]); + + // Reset counter to only count refresh calls + int initialCallCount = serverCallCount; + + serverCallCount = 0; + + // + // Let cache invalidate + await Task.Delay(TimeSpan.FromSeconds(1)); + + // First refresh call should make a server call since we waited at least the minimum interval + await refresher.RefreshAsync(); + + Assert.Equal(1, serverCallCount); + + // Sub refresh call immediately after should also not make server calls + for (int i = 0; i < 5; i++) + { + await refresher.RefreshAsync(); + } + + Assert.Equal(1, serverCallCount); + + // Modify value to verify it doesn't update + _kvCollection[0] = TestHelpers.ChangeValue(FirstKeyValue, "newValue"); + + // Still not enough time elapsed + await refresher.RefreshAsync(); + + Assert.Equal(1, serverCallCount); + + Assert.Equal("TestValue1", config["TestKey1"]); + + // Wait for the minimum interval to elapse + await Task.Delay(TimeSpan.FromSeconds(1)); + + // Now the refresh should make server calls + for (int i = 0; i < 5; i++) + { + await refresher.RefreshAsync(); + } + + Assert.Equal(2, serverCallCount); + + Assert.Equal("newValue", config["TestKey1"]); + } + + [Fact] + public async Task RefreshTests_FeatureFlagRefreshRespectsConfiguredCacheInterval() + { + int serverCallCount = 0; + IConfigurationRefresher refresher = null; + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + var featureFlags = new List { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "myFeature", + value: @" + { + ""id"": ""MyFeature"", + ""description"": ""The new beta version of our web site."", + ""display_name"": ""Beta Feature"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""SuperUsers"" + } + ] + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) + }; + + MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) + { + serverCallCount++; + + if (selector.KeyFilter.StartsWith(FeatureManagementConstants.FeatureFlagMarker)) + { + return new MockAsyncPageable(featureFlags.Select(ff => TestHelpers.CloneSetting(ff)).ToList()); + } + + return new MockAsyncPageable(new List()); + } + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns((Func)GetTestKeys); + + // Configure with a 5 second cache for feature flags + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.UseFeatureFlags(featureFlagOptions => + { + featureFlagOptions.SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + // Initial configuration load + Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); + + // Reset counter to only count refresh calls + serverCallCount = 0; + + // First refresh call immediately after load should not make server calls as cache hasn't expired + await refresher.RefreshAsync(); + + Assert.Equal(0, serverCallCount); + + // Modify feature flag to verify it doesn't update until cache expires + featureFlags[0] = ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "myFeature", + value: @" + { + ""id"": ""MyFeature"", + ""description"": ""The new beta version of our web site."", + ""display_name"": ""Beta Feature"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""AllUsers"" + } + ] + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c2")); + + // This should not make server calls because feature flag cache hasn't expired + for (int i = 0; i < 5; i++) + { + await refresher.RefreshAsync(); + } + + Assert.Equal(0, serverCallCount); + + Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); + + // Wait for the cache to expire + await Task.Delay(TimeSpan.FromSeconds(1)); + + // Now the refresh should make server calls and update the feature flag, but only one call + for (int i = 0; i < 5; i++) + { + await refresher.RefreshAsync(); + } + + // One call for collection change check, one call to pull the latest values + Assert.Equal(2, serverCallCount); + + Assert.Equal("AllUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); + } + private void optionsInitializer(AzureAppConfigurationOptions options) { options.ConfigureStartupOptions(startupOptions => From 2248baf56eb52b2823d1286251273bee4449d981 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Fri, 7 Mar 2025 11:52:27 -0500 Subject: [PATCH 2/4] Remove duplicated tests. --- .../RefreshTests.cs | 282 ++---------------- .../Tests.AzureAppConfiguration/TestHelper.cs | 13 +- 2 files changed, 42 insertions(+), 253 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index 05625fc5..cb76b2be 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -212,7 +212,7 @@ public async Task RefreshTests_RefreshIsNotSkippedIfCacheIsExpired() _kvCollection[0] = TestHelpers.ChangeValue(FirstKeyValue, "newValue"); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -247,7 +247,7 @@ public async Task RefreshTests_RefreshAllFalseDoesNotUpdateEntireConfiguration() _kvCollection = _kvCollection.Select(kv => TestHelpers.ChangeValue(kv, "newValue")).ToList(); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -284,7 +284,7 @@ public async Task RefreshTests_RefreshAllTrueUpdatesEntireConfiguration() _kvCollection = _kvCollection.Select(kv => TestHelpers.ChangeValue(kv, "newValue")).ToList(); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -356,7 +356,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o keyValueCollection.Remove(keyValueCollection.FirstOrDefault(s => s.Key == "TestKey3" && s.Label == "label")); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -430,7 +430,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o keyValueCollection.Remove(keyValueCollection.FirstOrDefault(s => s.Key == "TestKey3" && s.Label == "label")); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -443,32 +443,34 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } [Fact] - public async void RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() + public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() { var keyValueCollection = new List(_kvCollection); var requestCount = 0; var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); + + // Define delay for async operations + var operationDelay = TimeSpan.FromSeconds(6); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(() => { requestCount++; - Thread.Sleep(6000); - + var copy = new List(); foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); }; - return new MockAsyncPageable(copy); + return new MockAsyncPageable(copy, operationDelay); }); - Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) + async Task> GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) { requestCount++; - Thread.Sleep(6000); + await Task.Delay(operationDelay, cancellationToken); var newSetting = keyValueCollection.FirstOrDefault(s => s.Key == setting.Key && s.Label == setting.Label); var unchanged = (newSetting.Key == setting.Key && newSetting.Label == setting.Label && newSetting.Value == setting.Value); @@ -477,7 +479,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Func>)GetIfChanged); + .Returns((Func>>)GetIfChanged); IConfigurationRefresher refresher = null; @@ -512,7 +514,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o } [Fact] - public void RefreshTests_RefreshAsyncThrowsOnRequestFailedException() + public async Task RefreshTests_RefreshAsyncThrowsOnRequestFailedException() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); @@ -539,7 +541,7 @@ public void RefreshTests_RefreshAsyncThrowsOnRequestFailedException() .Throws(new RequestFailedException("Request failed.")); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); Action action = () => refresher.RefreshAsync().Wait(); Assert.Throws(action); @@ -575,7 +577,7 @@ public async Task RefreshTests_TryRefreshAsyncReturnsFalseOnRequestFailedExcepti .Throws(new RequestFailedException("Request failed.")); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); bool result = await refresher.TryRefreshAsync(); Assert.False(result); @@ -608,7 +610,7 @@ public async Task RefreshTests_TryRefreshAsyncUpdatesConfigurationAndReturnsTrue _kvCollection[0] = TestHelpers.ChangeValue(_kvCollection[0], "newValue"); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); bool result = await refresher.TryRefreshAsync(); Assert.True(result); @@ -651,13 +653,13 @@ public async Task RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFaile FirstKeyValue.Value = "newValue"; // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); // First call to GetConfigurationSettingAsync does not throw Assert.True(await refresher.TryRefreshAsync()); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); // Second call to GetConfigurationSettingAsync throws KeyVaultReferenceException Assert.False(await refresher.TryRefreshAsync()); @@ -704,7 +706,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o _kvCollection[0] = TestHelpers.ChangeValue(_kvCollection[0], "newValue"); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await Assert.ThrowsAsync(async () => await refresher.RefreshAsync() @@ -748,7 +750,7 @@ public async Task RefreshTests_UpdatesAllSettingsIfInitialLoadFails() Assert.Null(configuration["TestKey3"]); // Make sure MinBackoffDuration has ended - Thread.Sleep(100); + await Task.Delay(100); // Act await Assert.ThrowsAsync(async () => @@ -763,7 +765,7 @@ await Assert.ThrowsAsync(async () => Assert.Null(configuration["TestKey3"]); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -825,7 +827,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o keyValueCollection = keyValueCollection.Select(kv => TestHelpers.ChangeValue(kv, "newValue")).ToList(); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); bool firstRefreshResult = await refresher.TryRefreshAsync(); Assert.False(firstRefreshResult); @@ -835,7 +837,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o Assert.Equal("TestValue3", config["TestKey3"]); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); bool secondRefreshResult = await refresher.TryRefreshAsync(); Assert.True(secondRefreshResult); @@ -876,7 +878,7 @@ public async Task RefreshTests_RefreshAllTrueForOverwrittenSentinelUpdatesEntire _kvCollection = _kvCollection.Select(kv => TestHelpers.ChangeValue(kv, "newValue")).ToList(); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -917,7 +919,7 @@ public async Task RefreshTests_RefreshAllFalseForOverwrittenSentinelUpdatesConfi _kvCollection[_kvCollection.IndexOf(refreshRegisteredSetting)] = TestHelpers.ChangeValue(refreshRegisteredSetting, "UpdatedValueForLabel1"); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -959,7 +961,7 @@ public async Task RefreshTests_RefreshRegisteredKvOverwritesSelectedKv() _kvCollection[_kvCollection.IndexOf(refreshAllRegisteredSetting)] = TestHelpers.ChangeValue(refreshAllRegisteredSetting, "UpdatedValueForLabel1"); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -1043,7 +1045,7 @@ public void RefreshTests_RefreshIsCancelled() FirstKeyValue.Value = "newValue1"; // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); using var cancellationSource = new CancellationTokenSource(); cancellationSource.Cancel(); @@ -1087,7 +1089,7 @@ public async Task RefreshTests_SelectedKeysRefreshWithRegisterAll() _kvCollection[2].Value = "newValue3"; // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -1097,7 +1099,7 @@ public async Task RefreshTests_SelectedKeysRefreshWithRegisterAll() _kvCollection.RemoveAt(2); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -1198,7 +1200,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -1209,7 +1211,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) featureFlags.RemoveAt(0); // Wait for the cache to expire - Thread.Sleep(1500); + await Task.Delay(1500); await refresher.RefreshAsync(); @@ -1241,224 +1243,6 @@ public void RefreshTests_ChainedConfigurationProviderUsedAsRootForRefresherProvi } #endif - [Fact] - public async Task RefreshTests_SingleKeyRefreshRespectsMinimumInterval() - { - int serverCallCount = 0; - IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); - var mockClient = new Mock(MockBehavior.Strict); - - Response GetTestKey(string key, string label, CancellationToken cancellationToken) - { - serverCallCount++; - return Response.FromValue(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label), mockResponse.Object); - } - - Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) - { - serverCallCount++; - var newSetting = _kvCollection.FirstOrDefault(s => (s.Key == setting.Key && s.Label == setting.Label)); - var unchanged = (newSetting.Key == setting.Key && newSetting.Label == setting.Label && newSetting.Value == setting.Value); - var response = new MockResponse(unchanged ? 304 : 200); - return Response.FromValue(newSetting, response); - } - - mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(() => - { - serverCallCount++; - return new MockAsyncPageable(_kvCollection.Select(setting => TestHelpers.CloneSetting(setting)).ToList()); - }); - - mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Func>)GetTestKey); - - mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Func>)GetIfChanged); - - var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => - { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.ConfigureRefresh(refreshOptions => - { - refreshOptions.Register("TestKey1", "label") - .SetRefreshInterval(TimeSpan.FromSeconds(1)); - }); - - refresher = options.GetRefresher(); - }) - .Build(); - - // Initial configuration load - Assert.Equal("TestValue1", config["TestKey1"]); - - // Reset counter to only count refresh calls - int initialCallCount = serverCallCount; - - serverCallCount = 0; - - // - // Let cache invalidate - await Task.Delay(TimeSpan.FromSeconds(1)); - - // First refresh call should make a server call since we waited at least the minimum interval - await refresher.RefreshAsync(); - - Assert.Equal(1, serverCallCount); - - // Sub refresh call immediately after should also not make server calls - for (int i = 0; i < 5; i++) - { - await refresher.RefreshAsync(); - } - - Assert.Equal(1, serverCallCount); - - // Modify value to verify it doesn't update - _kvCollection[0] = TestHelpers.ChangeValue(FirstKeyValue, "newValue"); - - // Still not enough time elapsed - await refresher.RefreshAsync(); - - Assert.Equal(1, serverCallCount); - - Assert.Equal("TestValue1", config["TestKey1"]); - - // Wait for the minimum interval to elapse - await Task.Delay(TimeSpan.FromSeconds(1)); - - // Now the refresh should make server calls - for (int i = 0; i < 5; i++) - { - await refresher.RefreshAsync(); - } - - Assert.Equal(2, serverCallCount); - - Assert.Equal("newValue", config["TestKey1"]); - } - - [Fact] - public async Task RefreshTests_FeatureFlagRefreshRespectsConfiguredCacheInterval() - { - int serverCallCount = 0; - IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); - var mockClient = new Mock(MockBehavior.Strict); - - var featureFlags = new List { - ConfigurationModelFactory.ConfigurationSetting( - key: FeatureManagementConstants.FeatureFlagMarker + "myFeature", - value: @" - { - ""id"": ""MyFeature"", - ""description"": ""The new beta version of our web site."", - ""display_name"": ""Beta Feature"", - ""enabled"": true, - ""conditions"": { - ""client_filters"": [ - { - ""name"": ""SuperUsers"" - } - ] - } - } - ", - label: default, - contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", - eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) - }; - - MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) - { - serverCallCount++; - - if (selector.KeyFilter.StartsWith(FeatureManagementConstants.FeatureFlagMarker)) - { - return new MockAsyncPageable(featureFlags.Select(ff => TestHelpers.CloneSetting(ff)).ToList()); - } - - return new MockAsyncPageable(new List()); - } - - mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns((Func)GetTestKeys); - - // Configure with a 5 second cache for feature flags - var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => - { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); - options.UseFeatureFlags(featureFlagOptions => - { - featureFlagOptions.SetRefreshInterval(TimeSpan.FromSeconds(1)); - }); - - refresher = options.GetRefresher(); - }) - .Build(); - - // Initial configuration load - Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); - - // Reset counter to only count refresh calls - serverCallCount = 0; - - // First refresh call immediately after load should not make server calls as cache hasn't expired - await refresher.RefreshAsync(); - - Assert.Equal(0, serverCallCount); - - // Modify feature flag to verify it doesn't update until cache expires - featureFlags[0] = ConfigurationModelFactory.ConfigurationSetting( - key: FeatureManagementConstants.FeatureFlagMarker + "myFeature", - value: @" - { - ""id"": ""MyFeature"", - ""description"": ""The new beta version of our web site."", - ""display_name"": ""Beta Feature"", - ""enabled"": true, - ""conditions"": { - ""client_filters"": [ - { - ""name"": ""AllUsers"" - } - ] - } - } - ", - label: default, - contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", - eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c2")); - - // This should not make server calls because feature flag cache hasn't expired - for (int i = 0; i < 5; i++) - { - await refresher.RefreshAsync(); - } - - Assert.Equal(0, serverCallCount); - - Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); - - // Wait for the cache to expire - await Task.Delay(TimeSpan.FromSeconds(1)); - - // Now the refresh should make server calls and update the feature flag, but only one call - for (int i = 0; i < 5; i++) - { - await refresher.RefreshAsync(); - } - - // One call for collection change check, one call to pull the latest values - Assert.Equal(2, serverCallCount); - - Assert.Equal("AllUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); - } - private void optionsInitializer(AzureAppConfigurationOptions options) { options.ConfigureStartupOptions(startupOptions => diff --git a/tests/Tests.AzureAppConfiguration/TestHelper.cs b/tests/Tests.AzureAppConfiguration/TestHelper.cs index ca929efc..ad79bb6d 100644 --- a/tests/Tests.AzureAppConfiguration/TestHelper.cs +++ b/tests/Tests.AzureAppConfiguration/TestHelper.cs @@ -164,8 +164,9 @@ class MockAsyncPageable : AsyncPageable { private readonly List _collection = new List(); private int _status; + private readonly TimeSpan? _delay; - public MockAsyncPageable(List collection) + public MockAsyncPageable(List collection, TimeSpan? delay = null) { foreach (ConfigurationSetting setting in collection) { @@ -177,6 +178,7 @@ public MockAsyncPageable(List collection) } _status = 200; + _delay = delay; } public void UpdateCollection(List newCollection) @@ -207,10 +209,13 @@ public void UpdateCollection(List newCollection) } } -#pragma warning disable 1998 - public async override IAsyncEnumerable> AsPages(string continuationToken = null, int? pageSizeHint = null) -#pragma warning restore 1998 + public override async IAsyncEnumerable> AsPages(string continuationToken = null, int? pageSizeHint = null) { + if (_delay.HasValue) + { + await Task.Delay(_delay.Value); + } + yield return Page.FromValues(_collection, null, new MockResponse(_status)); } } From 71c1ff479a035dfa814fc24fdc54fa6d1b2ac514 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Fri, 7 Mar 2025 12:11:26 -0500 Subject: [PATCH 3/4] fix. --- tests/Tests.AzureAppConfiguration/RefreshTests.cs | 3 +-- tests/Tests.AzureAppConfiguration/TestHelper.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index cb76b2be..b498e3f9 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -457,7 +457,6 @@ public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() .Returns(() => { requestCount++; - var copy = new List(); foreach (var setting in keyValueCollection) { @@ -1022,7 +1021,7 @@ public void RefreshTests_ConfigureRefreshThrowsOnNoRegistration() } [Fact] - public void RefreshTests_RefreshIsCancelled() + public async Task RefreshTests_RefreshIsCancelled() { IConfigurationRefresher refresher = null; var mockClient = GetMockConfigurationClient(); diff --git a/tests/Tests.AzureAppConfiguration/TestHelper.cs b/tests/Tests.AzureAppConfiguration/TestHelper.cs index ad79bb6d..9fd3f388 100644 --- a/tests/Tests.AzureAppConfiguration/TestHelper.cs +++ b/tests/Tests.AzureAppConfiguration/TestHelper.cs @@ -215,7 +215,7 @@ public override async IAsyncEnumerable> AsPages(strin { await Task.Delay(_delay.Value); } - + yield return Page.FromValues(_collection, null, new MockResponse(_status)); } } From 946eb55e9ae7896c945018681a9edc4464f98c32 Mon Sep 17 00:00:00 2001 From: Jimmy Campbell Date: Fri, 7 Mar 2025 12:23:02 -0500 Subject: [PATCH 4/4] Fix formatting. --- tests/Tests.AzureAppConfiguration/RefreshTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index b498e3f9..e64b5184 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -449,7 +449,7 @@ public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() var requestCount = 0; var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); - + // Define delay for async operations var operationDelay = TimeSpan.FromSeconds(6);