diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index af9b1d227..2928be53e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,6 +14,7 @@ on:
permissions:
security-events: write
+ id-token: write
jobs:
build:
@@ -40,8 +41,17 @@ jobs:
- name: Dotnet Pack
run: pwsh pack.ps1
+ - name: Azure Login with OIDC
+ uses: azure/login@v1
+ with:
+ client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+
- name: Dotnet Test
run: pwsh test.ps1
+ env:
+ AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Publish Test Results
uses: actions/upload-artifact@v4
diff --git a/.gitignore b/.gitignore
index 530af0645..39f97d76a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,9 @@
# Azure Functions localsettings file
local.settings.json
+# Integration test secrets
+appsettings.Secrets.json
+
# User-specific files
*.suo
*.user
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs
index cbfa411aa..13408385c 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs
@@ -426,7 +426,12 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue)
{
var featureFlag = new FeatureFlag();
- var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(settingValue));
+ var reader = new Utf8JsonReader(
+ System.Text.Encoding.UTF8.GetBytes(settingValue),
+ new JsonReaderOptions
+ {
+ AllowTrailingCommas = true
+ });
try
{
diff --git a/tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1 b/tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1
new file mode 100644
index 000000000..c08e434db
--- /dev/null
+++ b/tests/Tests.AzureAppConfiguration/Integration/GetAzureSubscription.ps1
@@ -0,0 +1,47 @@
+#!/usr/bin/env pwsh
+
+# GetAzureSubscription.ps1
+# This script gets the AppConfig - Dev subscription ID and saves it to a JSON file
+
+$outputPath = Join-Path $PSScriptRoot "appsettings.Secrets.json"
+
+Write-Host "Checking for active Azure CLI login"
+
+az account show | Out-Null
+
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "Must be logged in with the Azure CLI to proceed"
+
+ az login
+
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "Azure login failed"
+ return
+ }
+}
+
+az account set --name "AppConfig - Dev"
+
+# Get current subscription from az CLI
+$subscriptionId = az account show --query id -o tsv 2>&1
+
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "Azure CLI command failed with exit code $LASTEXITCODE. Output: $subscriptionId"
+ return
+}
+
+# Check if the output is empty
+if ([string]::IsNullOrWhiteSpace($subscriptionId)) {
+ Write-Host "No active Azure subscription found. Please run 'az login' first."
+
+ exit 1
+}
+
+# If successful, save the subscription ID to a JSON file
+$result = @{
+ SubscriptionId = $subscriptionId.Trim()
+}
+
+$result | ConvertTo-Json | Out-File $outputPath -Encoding utf8
+Write-Host "Subscription information saved to: $outputPath"
+exit 0
diff --git a/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs b/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs
new file mode 100644
index 000000000..b932b3278
--- /dev/null
+++ b/tests/Tests.AzureAppConfiguration/Integration/IntegrationTests.cs
@@ -0,0 +1,2086 @@
+using Azure;
+using Azure.Core;
+using Azure.Core.Pipeline;
+using Azure.Data.AppConfiguration;
+using Azure.Identity;
+using Azure.ResourceManager;
+using Azure.ResourceManager.AppConfiguration;
+using Azure.ResourceManager.AppConfiguration.Models;
+using Azure.ResourceManager.KeyVault;
+using Azure.ResourceManager.Resources;
+using Azure.Security.KeyVault.Secrets;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Configuration.AzureAppConfiguration;
+using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault;
+using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement;
+using Microsoft.FeatureManagement;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Tests.AzureAppConfiguration
+{
+ using Xunit.Abstractions;
+
+ ///
+ /// Integration tests for Azure App Configuration that connect to a real service.
+ /// Uses an existing App Configuration store and Key Vault for testing.
+ /// Requires Azure credentials with appropriate permissions.
+ /// NOTE: Before running these tests locally, execute the GetAzureSubscription.ps1 script to create appsettings.Secrets.json.
+ ///
+ [Trait("Category", "Integration")]
+ [CollectionDefinition(nameof(IntegrationTests), DisableParallelization = true)]
+ public class IntegrationTests : IAsyncLifetime
+ {
+ // Test constants
+ private const string TestKeyPrefix = "IntegrationTest";
+ private const string SubscriptionJsonPath = "appsettings.Secrets.json";
+ private static readonly TimeSpan StaleResourceThreshold = TimeSpan.FromHours(3);
+ private const string KeyVaultReferenceLabel = "KeyVaultRef";
+
+ // Content type constants
+ private const string FeatureFlagContentType = FeatureManagementConstants.ContentType + ";charset=utf-8";
+ private const string JsonContentType = "application/json";
+
+ // Fixed resource names - already existing
+ private const string AppConfigStoreName = "appconfig-dotnetprovider-integrationtest";
+ private const string KeyVaultName = "keyvault-dotnetprovider";
+ private const string ResourceGroupName = "dotnetprovider-integrationtest";
+
+ private readonly DefaultAzureCredential _defaultAzureCredential = new DefaultAzureCredential(
+ new DefaultAzureCredentialOptions
+ {
+ ExcludeSharedTokenCacheCredential = true
+ });
+
+ private class TestContext
+ {
+ public string KeyPrefix { get; set; }
+ public string SentinelKey { get; set; }
+ public string FeatureFlagKey { get; set; }
+ public string KeyVaultReferenceKey { get; set; }
+ public string SecretValue { get; set; }
+ }
+
+ private ConfigurationClient _configClient;
+
+ private SecretClient _secretClient;
+
+ private string _connectionString;
+
+ private Uri _keyVaultEndpoint;
+
+ private readonly ITestOutputHelper _output;
+
+ public IntegrationTests(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ private string GetCurrentSubscriptionId()
+ {
+ string subscriptionIdFromEnv = Environment.GetEnvironmentVariable("AZURE_SUBSCRIPTION_ID");
+
+ if (!string.IsNullOrEmpty(subscriptionIdFromEnv))
+ {
+ return subscriptionIdFromEnv;
+ }
+
+ // Read the JSON file created by the script
+ string jsonPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Integration", SubscriptionJsonPath);
+
+ if (!File.Exists(jsonPath))
+ {
+ throw new InvalidOperationException($"Subscription JSON file not found at {jsonPath}. Run the GetAzureSubscription.ps1 script first.");
+ }
+
+ string jsonContent = File.ReadAllText(jsonPath);
+
+ using JsonDocument doc = JsonDocument.Parse(jsonContent);
+ JsonElement root = doc.RootElement;
+
+ return root.GetProperty("SubscriptionId").GetString();
+ }
+
+ private string GetUniqueKeyPrefix(string testName)
+ {
+ // Use a combination of the test prefix and test method name to ensure uniqueness
+ return $"{TestKeyPrefix}-{testName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
+ }
+
+ private async Task CreateSnapshot(string snapshotName, IEnumerable settingsToInclude, CancellationToken cancellationToken = default)
+ {
+ ConfigurationSnapshot snapshot = new ConfigurationSnapshot(settingsToInclude);
+
+ snapshot.SnapshotComposition = SnapshotComposition.Key;
+
+ CreateSnapshotOperation operation = await _configClient.CreateSnapshotAsync(
+ WaitUntil.Completed,
+ snapshotName,
+ snapshot,
+ cancellationToken);
+
+ return operation.Value.Name;
+ }
+
+ public async Task InitializeAsync()
+ {
+ DefaultAzureCredential credential = _defaultAzureCredential;
+ string subscriptionId = GetCurrentSubscriptionId();
+
+ var armClient = new ArmClient(credential);
+ SubscriptionResource subscription = armClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{subscriptionId}"));
+
+ ResourceGroupResource resourceGroup = await subscription.GetResourceGroups().GetAsync(ResourceGroupName);
+
+ AppConfigurationStoreResource appConfigStore = null;
+
+ try
+ {
+ appConfigStore = await resourceGroup.GetAppConfigurationStores().GetAsync(AppConfigStoreName);
+ }
+ catch (RequestFailedException ex) when (ex.Status == 404)
+ {
+ throw new InvalidOperationException($"App Configuration store '{AppConfigStoreName}' not found in resource group '{ResourceGroupName}'. Please create it before running tests.", ex);
+ }
+
+ AsyncPageable accessKeys = appConfigStore.GetKeysAsync();
+
+ _connectionString = (await accessKeys.FirstAsync()).ConnectionString;
+
+ _configClient = new ConfigurationClient(_connectionString);
+
+ KeyVaultResource keyVault = null;
+
+ // Find and initialize Key Vault - look in the same resource group
+ keyVault = await resourceGroup.GetKeyVaults().GetAsync(KeyVaultName);
+
+ if (keyVault == null)
+ {
+ throw new InvalidOperationException(
+ $"Key Vault '{KeyVaultName}' not found in subscription {subscriptionId}. " +
+ "This resource is required for integration tests. " +
+ "Please create the Key Vault with the appropriate permissions before running tests.");
+ }
+
+ _keyVaultEndpoint = keyVault.Data.Properties.VaultUri;
+
+ _secretClient = new SecretClient(_keyVaultEndpoint, credential);
+
+ _output.WriteLine($"Successfully connected to App Configuration store '{AppConfigStoreName}' and Key Vault '{KeyVaultName}'");
+ }
+
+ private async Task CleanupStaleResources()
+ {
+ _output.WriteLine($"Checking for stale resources older than {StaleResourceThreshold}...");
+
+ var cutoffTime = DateTimeOffset.UtcNow.Subtract(StaleResourceThreshold);
+ var cleanupTasks = new List();
+
+ // Clean up stale configuration settings, snapshots, and Key Vault secrets
+ try
+ {
+ int staleConfigCount = 0;
+ var configSettingsToCleanup = new List();
+
+ AsyncPageable kvSettings = _configClient.GetConfigurationSettingsAsync(new SettingSelector
+ {
+ KeyFilter = TestKeyPrefix + "*"
+ });
+
+ AsyncPageable flagSettings = _configClient.GetConfigurationSettingsAsync(new SettingSelector
+ {
+ KeyFilter = FeatureManagementConstants.FeatureFlagMarker + TestKeyPrefix + "*"
+ });
+
+ await foreach (ConfigurationSetting setting in kvSettings.Concat(flagSettings))
+ {
+ // Check if the setting is older than the threshold
+ if (setting.LastModified < cutoffTime)
+ {
+ configSettingsToCleanup.Add(setting);
+ staleConfigCount++;
+ }
+ }
+
+ foreach (ConfigurationSetting setting in configSettingsToCleanup)
+ {
+ cleanupTasks.Add(_configClient.DeleteConfigurationSettingAsync(setting.Key, setting.Label));
+ }
+
+ int staleSnapshotCount = 0;
+ AsyncPageable snapshots = _configClient.GetSnapshotsAsync(new SnapshotSelector());
+ await foreach (ConfigurationSnapshot snapshot in snapshots)
+ {
+ if (snapshot.Name.StartsWith("snapshot-" + TestKeyPrefix) && snapshot.CreatedOn < cutoffTime)
+ {
+ cleanupTasks.Add(_configClient.ArchiveSnapshotAsync(snapshot.Name));
+ staleSnapshotCount++;
+ }
+ }
+
+ int staleSecretCount = 0;
+ if (_secretClient != null)
+ {
+ AsyncPageable secrets = _secretClient.GetPropertiesOfSecretsAsync();
+ await foreach (SecretProperties secretProperties in secrets)
+ {
+ if (secretProperties.Name.StartsWith(TestKeyPrefix) && secretProperties.CreatedOn.HasValue && secretProperties.CreatedOn.Value < cutoffTime)
+ {
+ cleanupTasks.Add(_secretClient.StartDeleteSecretAsync(secretProperties.Name));
+ staleSecretCount++;
+ }
+ }
+ }
+
+ // Wait for all cleanup tasks to complete
+ await Task.WhenAll(cleanupTasks);
+
+ _output.WriteLine($"Cleaned up {staleConfigCount} stale configuration settings, {staleSnapshotCount} snapshots, and {staleSecretCount} secrets");
+ }
+ catch (RequestFailedException ex)
+ {
+ _output.WriteLine($"Error during stale resource cleanup: {ex.Message}");
+ // Continue execution even if cleanup fails
+ }
+ }
+
+ public async Task DisposeAsync()
+ {
+ await CleanupStaleResources();
+ }
+
+ private TestContext CreateTestContext(string testName)
+ {
+ string keyPrefix = GetUniqueKeyPrefix(testName);
+ string sentinelKey = $"{keyPrefix}:Sentinel";
+ string featureFlagKey = $".appconfig.featureflag/{keyPrefix}Feature";
+ string secretName = $"{keyPrefix}-secret";
+ string secretValue = "SecretValue";
+ string keyVaultReferenceKey = $"{keyPrefix}:KeyVaultRef";
+
+ return new TestContext
+ {
+ KeyPrefix = keyPrefix,
+ SentinelKey = sentinelKey,
+ FeatureFlagKey = featureFlagKey,
+ KeyVaultReferenceKey = keyVaultReferenceKey,
+ SecretValue = secretValue
+ };
+ }
+
+ private async Task SetupKeyValues(TestContext context)
+ {
+ var testSettings = new List
+ {
+ new ConfigurationSetting($"{context.KeyPrefix}:Setting1", "InitialValue1"),
+ new ConfigurationSetting($"{context.KeyPrefix}:Setting2", "InitialValue2"),
+ new ConfigurationSetting(context.SentinelKey, "Initial")
+ };
+
+ foreach (ConfigurationSetting setting in testSettings)
+ {
+ await _configClient.SetConfigurationSettingAsync(setting);
+ }
+ }
+
+ private async Task SetupFeatureFlags(TestContext context)
+ {
+ var featureFlagSetting = ConfigurationModelFactory.ConfigurationSetting(
+ context.FeatureFlagKey,
+ @"{""id"":""" + context.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":false}",
+ contentType: FeatureFlagContentType);
+
+ await _configClient.SetConfigurationSettingAsync(featureFlagSetting);
+ }
+
+ private async Task SetupKeyVaultReferences(TestContext context)
+ {
+ if (_secretClient != null)
+ {
+ await _secretClient.SetSecretAsync(context.KeyPrefix + "-secret", context.SecretValue);
+
+ string keyVaultUri = $"{_keyVaultEndpoint}secrets/{context.KeyPrefix}-secret";
+ string keyVaultRefValue = @$"{{""uri"":""{keyVaultUri}""}}";
+
+ ConfigurationSetting keyVaultRefSetting = ConfigurationModelFactory.ConfigurationSetting(
+ context.KeyVaultReferenceKey,
+ keyVaultRefValue,
+ label: KeyVaultReferenceLabel,
+ contentType: KeyVaultConstants.ContentType);
+
+ await _configClient.SetConfigurationSettingAsync(keyVaultRefSetting);
+ }
+ }
+
+ private async Task SetupTaggedSettings(TestContext context)
+ {
+ // Create configuration settings with various tags
+ var taggedSettings = new List
+ {
+ // Basic environment tags
+ CreateSettingWithTags(
+ $"{context.KeyPrefix}:TaggedSetting1",
+ "Value1",
+ new Dictionary {
+ { "Environment", "Development" },
+ { "App", "TestApp" }
+ }),
+
+ CreateSettingWithTags(
+ $"{context.KeyPrefix}:TaggedSetting2",
+ "Value2",
+ new Dictionary {
+ { "Environment", "Production" },
+ { "App", "TestApp" }
+ }),
+
+ CreateSettingWithTags(
+ $"{context.KeyPrefix}:TaggedSetting3",
+ "Value3",
+ new Dictionary {
+ { "Environment", "Development" },
+ { "Component", "API" }
+ }),
+
+ // Special characters in tags
+ CreateSettingWithTags(
+ $"{context.KeyPrefix}:TaggedSetting4",
+ "Value4",
+ new Dictionary {
+ { "Special:Tag", "Value:With:Colons" },
+ { "Tag@With@At", "Value@With@At" }
+ }),
+
+ // Empty and null tag values
+ CreateSettingWithTags(
+ $"{context.KeyPrefix}:TaggedSetting5",
+ "Value5",
+ new Dictionary {
+ { "EmptyTag", "" },
+ { "NullTag", null }
+ })
+ };
+
+ foreach (ConfigurationSetting setting in taggedSettings)
+ {
+ await _configClient.SetConfigurationSettingAsync(setting);
+ }
+
+ // Create feature flags with tags
+ var taggedFeatureFlags = new List
+ {
+ // Basic environment tags on feature flags
+ CreateFeatureFlagWithTags(
+ $"{context.KeyPrefix}:FeatureDev",
+ true,
+ new Dictionary {
+ { "Environment", "Development" },
+ { "App", "TestApp" }
+ }),
+
+ CreateFeatureFlagWithTags(
+ $"{context.KeyPrefix}:FeatureProd",
+ false,
+ new Dictionary {
+ { "Environment", "Production" },
+ { "App", "TestApp" }
+ }),
+
+ // Feature flags with special character tags
+ CreateFeatureFlagWithTags(
+ $"{context.KeyPrefix}:FeatureSpecial",
+ true,
+ new Dictionary {
+ { "Special:Tag", "Value:With:Colons" }
+ }),
+
+ // Feature flags with empty/null tags
+ CreateFeatureFlagWithTags(
+ $"{context.KeyPrefix}:FeatureEmpty",
+ false,
+ new Dictionary {
+ { "EmptyTag", "" },
+ { "NullTag", null }
+ })
+ };
+
+ foreach (ConfigurationSetting setting in taggedFeatureFlags)
+ {
+ await _configClient.SetConfigurationSettingAsync(setting);
+ }
+ }
+
+ private async Task SetupAllTestData(string testName)
+ {
+ TestContext context = CreateTestContext(testName);
+
+ await SetupKeyValues(context);
+ await SetupFeatureFlags(context);
+ await SetupKeyVaultReferences(context);
+
+ return context;
+ }
+
+ [Fact]
+ public async Task LoadConfiguration_RetrievesValuesFromAppConfiguration()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("BasicConfig");
+ await SetupKeyValues(testContext);
+
+ // Act
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*");
+ })
+ .Build();
+
+ // Assert
+ Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_UpdatesConfiguration_WhenSentinelKeyChanged()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("UpdatesConfig");
+ await SetupKeyValues(testContext);
+ IConfigurationRefresher refresher = null;
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*");
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext.SentinelKey, refreshAll: true)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+
+ refresher = options.GetRefresher();
+ })
+ .Build();
+
+ // Verify initial values
+ Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]);
+
+ // Update values in the store
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1"));
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "Updated"));
+
+ // Wait for cache to expire
+ await Task.Delay(TimeSpan.FromSeconds(2));
+
+ // Act
+ await refresher.RefreshAsync();
+
+ // Assert
+ Assert.Equal("UpdatedValue1", config[$"{testContext.KeyPrefix}:Setting1"]);
+ }
+
+ [Fact]
+ public async Task RegisterAll_RefreshesAllKeys()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("RefreshesAllKeys");
+ await SetupKeyValues(testContext);
+ IConfigurationRefresher refresher = null;
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*");
+
+ // Use RegisterAll to refresh everything when sentinel changes
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.RegisterAll()
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+
+ refresher = options.GetRefresher();
+ })
+ .Build();
+
+ // Verify initial values
+ Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]);
+
+ // Update all values in the store
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1"));
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting2", "UpdatedValue2"));
+
+ // Wait for cache to expire
+ await Task.Delay(TimeSpan.FromSeconds(2));
+
+ // Act
+ await refresher.RefreshAsync();
+
+ // Assert
+ Assert.Equal("UpdatedValue1", config[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("UpdatedValue2", config[$"{testContext.KeyPrefix}:Setting2"]);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_SentinelKeyUnchanged()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("SentinelUnchanged");
+ await SetupKeyValues(testContext);
+ IConfigurationRefresher refresher = null;
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*");
+
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext.SentinelKey)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+
+ refresher = options.GetRefresher();
+ })
+ .Build();
+
+ // Verify initial values
+ Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]);
+
+ // Update data but not sentinel
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1"));
+
+ // Wait for cache to expire
+ await Task.Delay(TimeSpan.FromSeconds(2));
+
+ // Act
+ await refresher.RefreshAsync();
+
+ // Assert
+ Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]); // Should not update
+ }
+
+ [Fact]
+ public async Task RefreshAsync_RefreshesFeatureFlags_WhenConfigured()
+ {
+ TestContext testContext = CreateTestContext("FeatureFlagRefresh");
+ await SetupFeatureFlags(testContext);
+ IConfigurationRefresher refresher = null;
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+
+ // Configure feature flags with the correct ID pattern
+ options.UseFeatureFlags(featureFlagOptions =>
+ {
+ featureFlagOptions.Select(testContext.KeyPrefix + "*");
+ featureFlagOptions.SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+
+ refresher = options.GetRefresher();
+ })
+ .Build();
+
+ // Verify the feature flag is disabled initially
+ Assert.Equal("False", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+
+ // Update the feature flag to enabled=true
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ testContext.FeatureFlagKey,
+ @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}",
+ contentType: FeatureFlagContentType));
+
+ // Wait for cache to expire
+ await Task.Delay(TimeSpan.FromSeconds(2));
+
+ // Act
+ await refresher.RefreshAsync();
+
+ // Assert
+ Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ }
+
+ [Fact]
+ public async Task UseFeatureFlags_WithClientFiltersAndConditions()
+ {
+ TestContext testContext = CreateTestContext("FeatureFlagFilters");
+ await SetupFeatureFlags(testContext);
+
+ // Create a feature flag with complex conditions
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ testContext.FeatureFlagKey,
+ @"{
+ ""id"": """ + testContext.KeyPrefix + @"Feature"",
+ ""description"": ""Test feature with filters"",
+ ""enabled"": true,
+ ""conditions"": {
+ ""client_filters"": [
+ {
+ ""name"": ""Browser"",
+ ""parameters"": {
+ ""AllowedBrowsers"": [""Chrome"", ""Edge""]
+ }
+ },
+ {
+ ""name"": ""TimeWindow"",
+ ""parameters"": {
+ ""Start"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds() + @")\/"",
+ ""End"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds() + @")\/""
+ }
+ }
+ ]
+ }
+ }",
+ contentType: FeatureFlagContentType));
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.UseFeatureFlags(featureFlagOptions =>
+ {
+ featureFlagOptions.Select(testContext.KeyPrefix + "*");
+ });
+ })
+ .Build();
+
+ // Verify feature flag structure is loaded correctly
+ Assert.Equal("Browser", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:0:Name"]);
+ Assert.Equal("Chrome", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:0:Parameters:AllowedBrowsers:0"]);
+ Assert.Equal("Edge", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:0:Parameters:AllowedBrowsers:1"]);
+ Assert.Equal("TimeWindow", config[$"FeatureManagement:{testContext.KeyPrefix}Feature:EnabledFor:1:Name"]);
+ }
+
+ [Fact]
+ public async Task MultipleProviders_LoadAndRefresh()
+ {
+ TestContext testContext1 = CreateTestContext("MultiProviderTest1");
+ await SetupKeyValues(testContext1);
+ TestContext testContext2 = CreateTestContext("MultiProviderTest2");
+ await SetupKeyValues(testContext2);
+ IConfigurationRefresher refresher1 = null;
+ IConfigurationRefresher refresher2 = null;
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext1.KeyPrefix}:*");
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext1.SentinelKey, true)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+
+ refresher1 = options.GetRefresher();
+ })
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext2.KeyPrefix}:*");
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext2.SentinelKey)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+
+ refresher2 = options.GetRefresher();
+ })
+ .Build();
+
+ // Verify initial values
+ Assert.Equal("InitialValue1", config[$"{testContext1.KeyPrefix}:Setting1"]);
+ Assert.Equal("InitialValue1", config[$"{testContext2.KeyPrefix}:Setting1"]);
+
+ // Update values and sentinel keys
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext1.KeyPrefix}:Setting1", "UpdatedValue1"));
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext1.SentinelKey, "Updated"));
+
+ // Wait for cache to expire
+ await Task.Delay(TimeSpan.FromSeconds(2));
+
+ // Refresh only the first provider
+ await refresher1.RefreshAsync();
+
+ // Assert: Only the first provider's values should be updated
+ Assert.Equal("UpdatedValue1", config[$"{testContext1.KeyPrefix}:Setting1"]);
+ Assert.Equal("InitialValue1", config[$"{testContext2.KeyPrefix}:Setting1"]);
+ }
+
+ [Fact]
+ public async Task FeatureFlag_WithVariants()
+ {
+ TestContext testContext = CreateTestContext("FeatureFlagVariants");
+ await SetupFeatureFlags(testContext);
+
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ testContext.FeatureFlagKey,
+ @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}",
+ contentType: FeatureFlagContentType));
+
+ // Create a feature flag with variants
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ testContext.FeatureFlagKey + "WithVariants",
+ @"{
+ ""id"": """ + testContext.KeyPrefix + @"FeatureWithVariants"",
+ ""description"": ""Feature flag with variants"",
+ ""enabled"": true,
+ ""conditions"": { ""client_filters"": [] },
+ ""variants"": [
+ {
+ ""name"": ""LargeSize"",
+ ""configuration_value"": ""800px""
+ },
+ {
+ ""name"": ""MediumSize"",
+ ""configuration_value"": ""600px""
+ },
+ {
+ ""name"": ""SmallSize"",
+ ""configuration_value"": ""400px""
+ }
+ ],
+ ""allocation"": {
+ ""default_when_enabled"": ""MediumSize""
+ }
+ }",
+ contentType: FeatureFlagContentType));
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.UseFeatureFlags(featureFlagOptions =>
+ {
+ featureFlagOptions.Select(testContext.KeyPrefix + "*");
+ });
+ })
+ .Build();
+
+ // Verify variants are loaded correctly
+ Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ Assert.Equal("LargeSize", config[$"feature_management:feature_flags:0:variants:0:name"]);
+ Assert.Equal("800px", config[$"feature_management:feature_flags:0:variants:0:configuration_value"]);
+ Assert.Equal("MediumSize", config[$"feature_management:feature_flags:0:variants:1:name"]);
+ Assert.Equal("600px", config[$"feature_management:feature_flags:0:variants:1:configuration_value"]);
+ Assert.Equal("SmallSize", config[$"feature_management:feature_flags:0:variants:2:name"]);
+ Assert.Equal("400px", config[$"feature_management:feature_flags:0:variants:2:configuration_value"]);
+ Assert.Equal("MediumSize", config[$"feature_management:feature_flags:0:allocation:default_when_enabled"]);
+ }
+
+ [Fact]
+ public async Task JsonContentType_LoadsAndFlattensHierarchicalData()
+ {
+ TestContext testContext = CreateTestContext("JsonContent");
+ await SetupKeyValues(testContext);
+
+ // Create a complex JSON structure
+ string jsonKey = $"{testContext.KeyPrefix}:JsonConfig";
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ jsonKey,
+ @"{
+ ""database"": {
+ ""connection"": {
+ ""string"": ""Server=myserver;Database=mydb;User Id=sa;Password=mypassword;"",
+ ""timeout"": 30
+ },
+ ""retries"": 3,
+ ""enabled"": true
+ },
+ ""logging"": {
+ ""level"": ""Information"",
+ ""providers"": [""Console"", ""Debug"", ""EventLog""]
+ }
+ }",
+ contentType: JsonContentType));
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*");
+ })
+ .Build();
+
+ // Verify JSON was flattened properly
+ Assert.Equal("Server=myserver;Database=mydb;User Id=sa;Password=mypassword;", config[$"{jsonKey}:database:connection:string"]);
+ Assert.Equal("30", config[$"{jsonKey}:database:connection:timeout"]);
+ Assert.Equal("3", config[$"{jsonKey}:database:retries"]);
+ Assert.Equal("True", config[$"{jsonKey}:database:enabled"]);
+ Assert.Equal("Information", config[$"{jsonKey}:logging:level"]);
+ Assert.Equal("Console", config[$"{jsonKey}:logging:providers:0"]);
+ Assert.Equal("Debug", config[$"{jsonKey}:logging:providers:1"]);
+ Assert.Equal("EventLog", config[$"{jsonKey}:logging:providers:2"]);
+ }
+
+ [Fact]
+ public async Task MethodOrderingDoesNotAffectConfiguration()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("MethodOrdering");
+ await SetupKeyValues(testContext);
+ await SetupFeatureFlags(testContext);
+
+ // Add an additional feature flag for testing
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ testContext.FeatureFlagKey + "_Ordering",
+ @"{
+ ""id"": """ + testContext.KeyPrefix + @"FeatureOrdering"",
+ ""description"": ""Test feature for ordering"",
+ ""enabled"": true,
+ ""conditions"": {
+ ""client_filters"": []
+ }
+ }",
+ contentType: FeatureFlagContentType));
+
+ // Add a section-based setting
+ await _configClient.SetConfigurationSettingAsync(
+ new ConfigurationSetting($"{testContext.KeyPrefix}:Section1:Setting1", "SectionValue1"));
+
+ // Create four different configurations with different method orderings
+ var configurations = new List();
+ IConfigurationRefresher refresher1 = null;
+ IConfigurationRefresher refresher2 = null;
+ IConfigurationRefresher refresher3 = null;
+ IConfigurationRefresher refresher4 = null;
+
+ // Configuration 1: Select -> ConfigureRefresh -> UseFeatureFlags
+ var config1 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*");
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext.SentinelKey, true)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+ options.UseFeatureFlags(featureFlagOptions =>
+ {
+ featureFlagOptions.Select(testContext.KeyPrefix + "*");
+ });
+
+ refresher1 = options.GetRefresher();
+ })
+ .Build();
+ configurations.Add(config1);
+
+ // Configuration 2: ConfigureRefresh -> Select -> UseFeatureFlags
+ var config2 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext.SentinelKey, true)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+ options.Select($"{testContext.KeyPrefix}:*");
+ options.UseFeatureFlags(featureFlagOptions =>
+ {
+ featureFlagOptions.Select(testContext.KeyPrefix + "*");
+ });
+
+ refresher2 = options.GetRefresher();
+ })
+ .Build();
+ configurations.Add(config2);
+
+ // Configuration 3: UseFeatureFlags -> Select -> ConfigureRefresh
+ var config3 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.UseFeatureFlags(featureFlagOptions =>
+ {
+ featureFlagOptions.Select(testContext.KeyPrefix + "*");
+ });
+ options.Select($"{testContext.KeyPrefix}:*");
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext.SentinelKey, true)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+
+ refresher3 = options.GetRefresher();
+ })
+ .Build();
+ configurations.Add(config3);
+
+ // Configuration 4: UseFeatureFlags (with Select inside) -> ConfigureRefresh -> Select
+ var config4 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.UseFeatureFlags(featureFlagOptions =>
+ {
+ featureFlagOptions.Select(testContext.KeyPrefix + "*");
+ });
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext.SentinelKey, true)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+ options.Select($"{testContext.KeyPrefix}:*");
+
+ refresher4 = options.GetRefresher();
+ })
+ .Build();
+ configurations.Add(config4);
+
+ // Assert - Initial values should be the same across all configurations
+ foreach (var config in configurations)
+ {
+ // Regular settings
+ Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]);
+ Assert.Equal("SectionValue1", config[$"{testContext.KeyPrefix}:Section1:Setting1"]);
+
+ // Feature flags
+ Assert.Equal("False", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}FeatureOrdering"]);
+ }
+
+ // Update values in the store
+ await _configClient.SetConfigurationSettingAsync(
+ new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1"));
+ await _configClient.SetConfigurationSettingAsync(
+ new ConfigurationSetting($"{testContext.KeyPrefix}:Section1:Setting1", "UpdatedSectionValue1"));
+
+ // Update a feature flag
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ testContext.FeatureFlagKey,
+ @"{
+ ""id"": """ + testContext.KeyPrefix + @"Feature"",
+ ""description"": ""Updated test feature"",
+ ""enabled"": true,
+ ""conditions"": {
+ ""client_filters"": []
+ }
+ }",
+ contentType: FeatureFlagContentType));
+
+ // Update the sentinel key to trigger refresh
+ await _configClient.SetConfigurationSettingAsync(
+ new ConfigurationSetting(testContext.SentinelKey, "Updated"));
+
+ // Wait for cache to expire
+ await Task.Delay(TimeSpan.FromSeconds(2));
+
+ // Refresh all configurations
+ await refresher1.RefreshAsync();
+ await refresher2.RefreshAsync();
+ await refresher3.RefreshAsync();
+ await refresher4.RefreshAsync();
+
+ // Assert - Updated values should be the same across all configurations
+ foreach (var config in configurations)
+ {
+ // Regular settings
+ Assert.Equal("UpdatedValue1", config[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]);
+ Assert.Equal("UpdatedSectionValue1", config[$"{testContext.KeyPrefix}:Section1:Setting1"]);
+
+ // Feature flags - first one should be updated to true
+ Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}FeatureOrdering"]);
+ }
+ }
+
+ [Fact]
+ public async Task RegisterWithRefreshAllAndRegisterAll_BehaveIdentically()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("RefreshEquivalency");
+ await SetupKeyValues(testContext);
+ await SetupFeatureFlags(testContext);
+
+ // Add another feature flag for testing
+ string secondFeatureFlagKey = $".appconfig.featureflag/{testContext.KeyPrefix}Feature2";
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ secondFeatureFlagKey,
+ @"{""id"":""" + testContext.KeyPrefix + @"Feature2"",""description"":""Second test feature"",""enabled"":false}",
+ contentType: FeatureFlagContentType));
+
+ // Create two separate configuration builders with different refresh methods
+ // First configuration uses Register with refreshAll: true
+ IConfigurationRefresher refresher1 = null;
+ var config1 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*");
+ options.UseFeatureFlags(featureFlagOptions =>
+ {
+ featureFlagOptions.Select(testContext.KeyPrefix + "*");
+ });
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext.SentinelKey, refreshAll: true)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+
+ refresher1 = options.GetRefresher();
+ })
+ .Build();
+
+ // Second configuration uses RegisterAll()
+ IConfigurationRefresher refresher2 = null;
+ var config2 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*");
+ options.UseFeatureFlags(featureFlagOptions =>
+ {
+ featureFlagOptions.Select(testContext.KeyPrefix + "*");
+ });
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.RegisterAll()
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+
+ refresher2 = options.GetRefresher();
+ })
+ .Build();
+
+ // Verify initial values for both configurations
+ Assert.Equal("InitialValue1", config1[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("InitialValue2", config1[$"{testContext.KeyPrefix}:Setting2"]);
+ Assert.Equal("False", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ Assert.Equal("False", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]);
+
+ Assert.Equal("InitialValue1", config2[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("InitialValue2", config2[$"{testContext.KeyPrefix}:Setting2"]);
+ Assert.Equal("False", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ Assert.Equal("False", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]);
+
+ // Update all values in the store
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1"));
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting2", "UpdatedValue2"));
+
+ // Update the feature flags
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ testContext.FeatureFlagKey,
+ @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}",
+ contentType: FeatureFlagContentType));
+
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ secondFeatureFlagKey,
+ @"{""id"":""" + testContext.KeyPrefix + @"Feature2"",""description"":""Second test feature"",""enabled"":true}",
+ contentType: FeatureFlagContentType));
+
+ // Update the sentinel key to trigger refresh
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "Updated"));
+
+ // Wait for cache to expire
+ await Task.Delay(TimeSpan.FromSeconds(2));
+
+ // Act - Refresh both configurations
+ await refresher1.RefreshAsync();
+ await refresher2.RefreshAsync();
+
+ // Assert - Both configurations should be updated the same way
+ // For config1 (Register with refreshAll: true)
+ Assert.Equal("UpdatedValue1", config1[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("UpdatedValue2", config1[$"{testContext.KeyPrefix}:Setting2"]);
+ Assert.Equal("True", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ Assert.Equal("True", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]);
+
+ // For config2 (RegisterAll)
+ Assert.Equal("UpdatedValue1", config2[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("UpdatedValue2", config2[$"{testContext.KeyPrefix}:Setting2"]);
+ Assert.Equal("True", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ Assert.Equal("True", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]);
+
+ // Test deleting a key and a feature flag
+ await _configClient.DeleteConfigurationSettingAsync($"{testContext.KeyPrefix}:Setting2");
+ await _configClient.DeleteConfigurationSettingAsync(secondFeatureFlagKey);
+
+ // Update the sentinel key again to trigger refresh
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "UpdatedAgain"));
+
+ // Wait for cache to expire
+ await Task.Delay(TimeSpan.FromSeconds(2));
+
+ // Refresh both configurations again
+ await refresher1.RefreshAsync();
+ await refresher2.RefreshAsync();
+
+ // Both configurations should have removed the deleted key-value and feature flag
+ Assert.Equal("UpdatedValue1", config1[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Null(config1[$"{testContext.KeyPrefix}:Setting2"]);
+ Assert.Equal("True", config1[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ Assert.Null(config1[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]);
+
+ Assert.Equal("UpdatedValue1", config2[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Null(config2[$"{testContext.KeyPrefix}:Setting2"]);
+ Assert.Equal("True", config2[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ Assert.Null(config2[$"FeatureManagement:{testContext.KeyPrefix}Feature2"]);
+ }
+
+ [Fact]
+ public async Task HandlesFailoverOnStartup()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("FailoverStartup");
+ await SetupKeyValues(testContext);
+ IConfigurationRefresher refresher = null;
+
+ string connectionString = _connectionString;
+
+ // Create a connection string that will fail
+ string primaryConnectionString = ConnectionStringUtils.Build(
+ TestHelpers.PrimaryConfigStoreEndpoint,
+ ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.IdSection),
+ ConnectionStringUtils.Parse(connectionString, ConnectionStringUtils.SecretSection));
+ string secondaryConnectionString = connectionString;
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(new List { primaryConnectionString, secondaryConnectionString });
+ options.Select($"{testContext.KeyPrefix}:*");
+
+ // Configure refresh
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext.SentinelKey, refreshAll: true)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+
+ refresher = options.GetRefresher();
+ })
+ .Build();
+
+ // Verify initial values
+ Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]);
+ }
+
+ [Fact]
+ public async Task LoadSnapshot_RetrievesValuesFromSnapshot()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("SnapshotTest");
+ await SetupKeyValues(testContext);
+ string snapshotName = $"snapshot-{testContext.KeyPrefix}";
+
+ // Create a snapshot with the test keys
+ await CreateSnapshot(snapshotName, new List { new ConfigurationSettingsFilter(testContext.KeyPrefix + "*") });
+
+ // Update values after snapshot is taken to verify snapshot has original values
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedAfterSnapshot"));
+
+ // Act - Load configuration from snapshot
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.SelectSnapshot(snapshotName);
+ })
+ .Build();
+
+ // Assert - Should have original values from snapshot, not updated values
+ Assert.Equal("InitialValue1", config[$"{testContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("InitialValue2", config[$"{testContext.KeyPrefix}:Setting2"]);
+
+ // Cleanup - Delete the snapshot
+ await _configClient.ArchiveSnapshotAsync(snapshotName);
+ }
+
+ [Fact]
+ public async Task LoadSnapshot_ThrowsException_WhenSnapshotDoesNotExist()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("NonExistentSnapshotTest");
+ string nonExistentSnapshotName = $"snapshot-does-not-exist-{Guid.NewGuid()}";
+
+ // Act & Assert - Loading a non-existent snapshot should throw
+ var exception = await Assert.ThrowsAsync(() =>
+ {
+ return Task.FromResult(new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.SelectSnapshot(nonExistentSnapshotName);
+ })
+ .Build());
+ });
+
+ // Verify the exception message contains snapshot name
+ Assert.Contains(nonExistentSnapshotName, exception.Message);
+ }
+
+ [Fact]
+ public async Task LoadMultipleSnapshots_MergesConfigurationCorrectly()
+ {
+ // Arrange - Setup test-specific keys for two separate snapshots
+ TestContext testContext1 = CreateTestContext("SnapshotMergeTest1");
+ await SetupKeyValues(testContext1);
+ TestContext testContext2 = CreateTestContext("SnapshotMergeTest2");
+ await SetupKeyValues(testContext2);
+
+ // Create specific values for second snapshot
+ await _configClient.SetConfigurationSettingAsync(
+ new ConfigurationSetting($"{testContext2.KeyPrefix}:UniqueKey", "UniqueValue"));
+
+ string snapshotName1 = $"snapshot-{testContext1.KeyPrefix}";
+ string snapshotName2 = $"snapshot-{testContext2.KeyPrefix}";
+
+ // Create snapshots
+ await CreateSnapshot(snapshotName1, new List { new ConfigurationSettingsFilter(testContext1.KeyPrefix + "*") });
+ await CreateSnapshot(snapshotName2, new List { new ConfigurationSettingsFilter(testContext2.KeyPrefix + "*") });
+
+ try
+ {
+ // Act - Load configuration from both snapshots
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.SelectSnapshot(snapshotName1);
+ options.SelectSnapshot(snapshotName2);
+ })
+ .Build();
+
+ // Assert - Should have values from both snapshots
+ Assert.Equal("InitialValue1", config[$"{testContext1.KeyPrefix}:Setting1"]);
+ Assert.Equal("InitialValue1", config[$"{testContext2.KeyPrefix}:Setting1"]);
+ Assert.Equal("UniqueValue", config[$"{testContext2.KeyPrefix}:UniqueKey"]);
+ }
+ finally
+ {
+ // Cleanup - Delete the snapshots
+ await _configClient.ArchiveSnapshotAsync(snapshotName1);
+ await _configClient.ArchiveSnapshotAsync(snapshotName2);
+ }
+ }
+
+ [Fact]
+ public async Task SnapshotCompositionTypes_AreHandledCorrectly()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("SnapshotCompositionTest");
+ await SetupKeyValues(testContext);
+ string keyOnlySnapshotName = $"snapshot-key-{testContext.KeyPrefix}";
+ string invalidCompositionSnapshotName = $"snapshot-invalid-{testContext.KeyPrefix}";
+
+ // Create a snapshot with the test keys
+ var settingsToInclude = new List
+ {
+ new ConfigurationSettingsFilter($"{testContext.KeyPrefix}:*")
+ };
+
+ ConfigurationSnapshot keyOnlySnapshot = new ConfigurationSnapshot(settingsToInclude);
+
+ keyOnlySnapshot.SnapshotComposition = SnapshotComposition.Key;
+
+ // Create the snapshot
+ await _configClient.CreateSnapshotAsync(WaitUntil.Completed, keyOnlySnapshotName, keyOnlySnapshot);
+
+ ConfigurationSnapshot invalidSnapshot = new ConfigurationSnapshot(settingsToInclude);
+
+ invalidSnapshot.SnapshotComposition = SnapshotComposition.KeyLabel;
+
+ // Create the snapshot
+ await _configClient.CreateSnapshotAsync(WaitUntil.Completed, invalidCompositionSnapshotName, invalidSnapshot);
+
+ try
+ {
+ // Act & Assert - Loading a key-only snapshot should work
+ var config1 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.SelectSnapshot(keyOnlySnapshotName);
+ })
+ .Build();
+
+ Assert.Equal("InitialValue1", config1[$"{testContext.KeyPrefix}:Setting1"]);
+
+ // Act & Assert - Loading a snapshot with invalid composition should throw
+ var exception = await Assert.ThrowsAsync(() =>
+ {
+ return Task.FromResult(new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.SelectSnapshot(invalidCompositionSnapshotName);
+ })
+ .Build());
+ });
+
+ // Verify the exception message mentions composition type
+ Assert.Contains("SnapshotComposition", exception.Message);
+ Assert.Contains("key", exception.Message);
+ Assert.Contains("label", exception.Message);
+ }
+ finally
+ {
+ // Cleanup - Delete the snapshots
+ await _configClient.ArchiveSnapshotAsync(keyOnlySnapshotName);
+ await _configClient.ArchiveSnapshotAsync(invalidCompositionSnapshotName);
+ }
+ }
+
+ [Fact]
+ public async Task SnapshotWithFeatureFlags_LoadsConfigurationCorrectly()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("SnapshotFeatureFlagTest");
+ await SetupFeatureFlags(testContext);
+ string snapshotName = $"snapshot-ff-{testContext.KeyPrefix}";
+
+ // Update the feature flag to be enabled before creating the snapshot
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ testContext.FeatureFlagKey,
+ @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":true}",
+ contentType: FeatureFlagContentType));
+
+ // Create a snapshot with the test keys
+ var settingsToInclude = new List
+ {
+ new ConfigurationSettingsFilter($".appconfig.featureflag/{testContext.KeyPrefix}*")
+ };
+
+ ConfigurationSnapshot snapshot = new ConfigurationSnapshot(settingsToInclude);
+
+ snapshot.SnapshotComposition = SnapshotComposition.Key;
+
+ // Create the snapshot
+ await _configClient.CreateSnapshotAsync(WaitUntil.Completed, snapshotName, snapshot);
+
+ // Update feature flag to disabled after creating snapshot
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ testContext.FeatureFlagKey,
+ @"{""id"":""" + testContext.KeyPrefix + @"Feature"",""description"":""Test feature"",""enabled"":false}",
+ contentType: FeatureFlagContentType));
+
+ try
+ {
+ // Act - Load configuration from snapshot with feature flags
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.UseFeatureFlags();
+ options.SelectSnapshot(snapshotName);
+ })
+ .Build();
+
+ // Assert - Should have feature flag enabled state from snapshot
+ Assert.Equal("True", config[$"FeatureManagement:{testContext.KeyPrefix}Feature"]);
+ }
+ finally
+ {
+ // Cleanup - Delete the snapshot
+ await _configClient.ArchiveSnapshotAsync(snapshotName);
+ }
+ }
+
+ [Fact]
+ public async Task CallOrdering_SnapshotsWithSelectAndFeatureFlags()
+ {
+ // Arrange - Setup test-specific keys for multiple snapshots
+ TestContext mainContext = CreateTestContext("SnapshotOrdering");
+ await SetupKeyValues(mainContext);
+ await SetupFeatureFlags(mainContext);
+
+ TestContext secondContext = CreateTestContext("SnapshotOrdering2");
+ await SetupKeyValues(secondContext);
+ await SetupFeatureFlags(secondContext);
+
+ TestContext thirdContext = CreateTestContext("SnapshotOrdering3");
+ await SetupKeyValues(thirdContext);
+
+ // Create specific values for each snapshot
+ await _configClient.SetConfigurationSettingAsync(
+ new ConfigurationSetting($"{mainContext.KeyPrefix}:UniqueMain", "MainValue"));
+
+ await _configClient.SetConfigurationSettingAsync(
+ new ConfigurationSetting($"{secondContext.KeyPrefix}:UniqueSecond", "SecondValue"));
+
+ await _configClient.SetConfigurationSettingAsync(
+ new ConfigurationSetting($"{thirdContext.KeyPrefix}:UniqueThird", "ThirdValue"));
+
+ // Create additional feature flags
+ string secondFeatureFlagKey = $".appconfig.featureflag/{mainContext.KeyPrefix}Feature2";
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ secondFeatureFlagKey,
+ @"{""id"":""" + mainContext.KeyPrefix + @"Feature2"",""description"":""Second test feature"",""enabled"":true}",
+ contentType: FeatureFlagContentType));
+
+ string thirdFeatureFlagKey = $".appconfig.featureflag/{secondContext.KeyPrefix}Feature";
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ thirdFeatureFlagKey,
+ @"{""id"":""" + secondContext.KeyPrefix + @"Feature"",""description"":""Third test feature"",""enabled"":true}",
+ contentType: FeatureFlagContentType));
+
+ // Create snapshots
+ string snapshot1 = $"snapshot-{mainContext.KeyPrefix}-1";
+ string snapshot2 = $"snapshot-{secondContext.KeyPrefix}-2";
+ string snapshot3 = $"snapshot-{thirdContext.KeyPrefix}-3";
+
+ await CreateSnapshot(snapshot1, new List { new ConfigurationSettingsFilter(mainContext.KeyPrefix + "*") });
+ await CreateSnapshot(snapshot2, new List { new ConfigurationSettingsFilter(secondContext.KeyPrefix + "*") });
+ await CreateSnapshot(snapshot3, new List { new ConfigurationSettingsFilter(thirdContext.KeyPrefix + "*") });
+
+ try
+ {
+ // Test different orderings of SelectSnapshot, Select and UseFeatureFlags
+
+ // Order 1: SelectSnapshot -> Select -> UseFeatureFlags
+ var config1 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.SelectSnapshot(snapshot1);
+ options.Select($"{mainContext.KeyPrefix}:*");
+ options.UseFeatureFlags(ff =>
+ {
+ ff.Select($"{mainContext.KeyPrefix}Feature*");
+ });
+ })
+ .Build();
+
+ // Order 2: Select -> SelectSnapshot -> UseFeatureFlags
+ var config2 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{secondContext.KeyPrefix}:*");
+ options.SelectSnapshot(snapshot2);
+ options.UseFeatureFlags(ff =>
+ {
+ ff.Select($"{secondContext.KeyPrefix}Feature*");
+ });
+ })
+ .Build();
+
+ // Order 3: UseFeatureFlags -> SelectSnapshot -> Select
+ var config3 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.UseFeatureFlags();
+ options.SelectSnapshot(snapshot3);
+ options.Select($"{thirdContext.KeyPrefix}:*");
+ })
+ .Build();
+
+ // Order 4: Multiple snapshots with interleaved operations
+ var config4 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.SelectSnapshot(snapshot1);
+ options.UseFeatureFlags(ff =>
+ {
+ ff.Select($"{mainContext.KeyPrefix}Feature*");
+ });
+ options.SelectSnapshot(snapshot2);
+ options.Select($"{secondContext.KeyPrefix}:*");
+ options.UseFeatureFlags(ff =>
+ {
+ ff.Select($"{secondContext.KeyPrefix}Feature*");
+ });
+ options.SelectSnapshot(snapshot3);
+ })
+ .Build();
+
+ // Verify config1: Should have values from snapshot1 and feature flags from mainContext
+ Assert.Equal("InitialValue1", config1[$"{mainContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("MainValue", config1[$"{mainContext.KeyPrefix}:UniqueMain"]);
+ Assert.Equal("False", config1[$"FeatureManagement:{mainContext.KeyPrefix}Feature"]);
+ Assert.Equal("True", config1[$"FeatureManagement:{mainContext.KeyPrefix}Feature2"]);
+
+ // Verify config2: Should have values from snapshot2 and feature flags from secondContext
+ Assert.Equal("InitialValue1", config2[$"{secondContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("SecondValue", config2[$"{secondContext.KeyPrefix}:UniqueSecond"]);
+ Assert.Equal("True", config2[$"FeatureManagement:{secondContext.KeyPrefix}Feature"]);
+
+ // Verify config3: Should have values from snapshot3 and all feature flags
+ Assert.Equal("InitialValue1", config3[$"{thirdContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("ThirdValue", config3[$"{thirdContext.KeyPrefix}:UniqueThird"]);
+ Assert.Equal("False", config3[$"FeatureManagement:{mainContext.KeyPrefix}Feature"]);
+ Assert.Equal("True", config3[$"FeatureManagement:{secondContext.KeyPrefix}Feature"]);
+
+ // Verify config4: Should have values from all three snapshots
+ Assert.Equal("InitialValue1", config4[$"{mainContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("MainValue", config4[$"{mainContext.KeyPrefix}:UniqueMain"]);
+ Assert.Equal("InitialValue1", config4[$"{secondContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("SecondValue", config4[$"{secondContext.KeyPrefix}:UniqueSecond"]);
+ Assert.Equal("InitialValue1", config4[$"{thirdContext.KeyPrefix}:Setting1"]);
+ Assert.Equal("ThirdValue", config4[$"{thirdContext.KeyPrefix}:UniqueThird"]);
+ Assert.Equal("False", config4[$"FeatureManagement:{mainContext.KeyPrefix}Feature"]);
+ Assert.Equal("True", config4[$"FeatureManagement:{mainContext.KeyPrefix}Feature2"]);
+ Assert.Equal("True", config4[$"FeatureManagement:{secondContext.KeyPrefix}Feature"]);
+ }
+ finally
+ {
+ // Cleanup - Delete the snapshots
+ await _configClient.ArchiveSnapshotAsync(snapshot1);
+ await _configClient.ArchiveSnapshotAsync(snapshot2);
+ await _configClient.ArchiveSnapshotAsync(snapshot3);
+ }
+ }
+
+ [Fact]
+ public async Task KeyVaultReferences_ResolveCorrectly()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("KeyVaultReference");
+ await SetupKeyVaultReferences(testContext);
+
+ // Act - Create configuration with Key Vault support
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*", KeyVaultReferenceLabel);
+ options.ConfigureKeyVault(kv => kv.SetCredential(_defaultAzureCredential));
+ })
+ .Build();
+
+ // Assert - Key Vault reference should be resolved to the secret value
+ Assert.Equal("SecretValue", config[testContext.KeyVaultReferenceKey]);
+ }
+
+ ///
+ /// Tests that Key Vault secrets are properly cached to avoid unnecessary requests.
+ ///
+ [Fact]
+ public async Task KeyVaultReference_UsesCache_DoesNotCallKeyVaultAgain()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("KeyVaultCacheTest");
+ await SetupKeyVaultReferences(testContext);
+
+ // Create a monitoring client to track calls to Key Vault
+ int requestCount = 0;
+ var testSecretClient = new SecretClient(
+ _keyVaultEndpoint,
+ _defaultAzureCredential,
+ new SecretClientOptions
+ {
+ Transport = new HttpPipelineTransportWithRequestCount(() => requestCount++)
+ });
+
+ // Act
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*", KeyVaultReferenceLabel);
+ options.ConfigureKeyVault(kv =>
+ {
+ kv.Register(testSecretClient);
+ });
+ })
+ .Build();
+
+ // First access should resolve from Key Vault
+ string firstValue = config[testContext.KeyVaultReferenceKey];
+ int firstRequestCount = requestCount;
+
+ // Second access should use the cache
+ string secondValue = config[testContext.KeyVaultReferenceKey];
+ int secondRequestCount = requestCount;
+
+ // Assert
+ Assert.Equal(testContext.SecretValue, firstValue);
+ Assert.Equal(testContext.SecretValue, secondValue);
+ Assert.Equal(1, firstRequestCount); // Should make exactly one request
+ Assert.Equal(firstRequestCount, secondRequestCount); // No additional requests for the second access
+ }
+
+ [Fact]
+ public async Task KeyVaultReference_DifferentRefreshIntervals()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("KeyVaultDifferentIntervals");
+ IConfigurationRefresher refresher = null;
+
+ // Create a secret in Key Vault with short refresh interval
+ string secretName1 = $"test-secret1-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
+ string secretValue1 = $"SecretValue1-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
+ await _secretClient.SetSecretAsync(secretName1, secretValue1);
+
+ // Create another secret in Key Vault with long refresh interval
+ string secretName2 = $"test-secret2-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
+ string secretValue2 = $"SecretValue2-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
+ await _secretClient.SetSecretAsync(secretName2, secretValue2);
+
+ // Create Key Vault references in App Configuration
+ string keyVaultUri = _keyVaultEndpoint.ToString().TrimEnd('/');
+ string kvRefKey1 = $"{testContext.KeyPrefix}:KeyVaultRef1";
+ string kvRefKey2 = $"{testContext.KeyPrefix}:KeyVaultRef2";
+
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ kvRefKey1,
+ $@"{{""uri"":""{keyVaultUri}/secrets/{secretName1}""}}",
+ label: KeyVaultReferenceLabel,
+ contentType: KeyVaultConstants.ContentType + "; charset=utf-8"));
+
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ kvRefKey2,
+ $@"{{""uri"":""{keyVaultUri}/secrets/{secretName2}""}}",
+ label: KeyVaultReferenceLabel,
+ contentType: KeyVaultConstants.ContentType + "; charset=utf-8"));
+
+ // Act - Create configuration with different refresh intervals
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*", KeyVaultReferenceLabel);
+ options.ConfigureKeyVault(kv =>
+ {
+ kv.SetCredential(_defaultAzureCredential);
+ // Set different refresh intervals for each secret
+ kv.SetSecretRefreshInterval(kvRefKey1, TimeSpan.FromSeconds(60)); // Short interval
+ kv.SetSecretRefreshInterval(kvRefKey2, TimeSpan.FromDays(1)); // Long interval
+ });
+
+ refresher = options.GetRefresher();
+ })
+ .Build();
+
+ // Verify initial values
+ Assert.Equal(secretValue1, config[kvRefKey1]);
+ Assert.Equal(secretValue2, config[kvRefKey2]);
+
+ // Update both secrets in Key Vault
+ string updatedValue1 = $"UpdatedValue1-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
+ string updatedValue2 = $"UpdatedValue2-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
+
+ await _secretClient.SetSecretAsync(secretName1, updatedValue1);
+ await _secretClient.SetSecretAsync(secretName2, updatedValue2);
+
+ // Wait for the short interval cache to expire
+ await Task.Delay(TimeSpan.FromSeconds(61));
+
+ // Refresh the configuration
+ await refresher.RefreshAsync();
+
+ // Assert - Only the first secret should be refreshed due to having a short interval
+ Assert.Equal(updatedValue1, config[kvRefKey1]); // Updated - short refresh interval
+ Assert.Equal(secretValue2, config[kvRefKey2]); // Not updated - long refresh interval
+ }
+
+ [Fact]
+ public async Task RequestTracing_SetsCorrectCorrelationContextHeader()
+ {
+ // Arrange - Setup test-specific keys
+ TestContext testContext = CreateTestContext("RequestTracing");
+ await SetupFeatureFlags(testContext);
+ await SetupKeyVaultReferences(testContext);
+
+ // Used to trigger FMVer tag in request tracing
+ IFeatureManager featureManager;
+
+ // Create a custom HttpPipeline that can inspect outgoing requests
+ var requestInspector = new RequestInspectionHandler();
+
+ await _configClient.SetConfigurationSettingAsync(
+ ConfigurationModelFactory.ConfigurationSetting(
+ testContext.FeatureFlagKey,
+ @"{
+ ""id"": """ + testContext.KeyPrefix + @"Feature"",
+ ""description"": ""Test feature with filters"",
+ ""enabled"": true,
+ ""variants"": [
+ {
+ ""name"": ""LargeSize"",
+ ""configuration_value"": ""800px""
+ },
+ {
+ ""name"": ""MediumSize"",
+ ""configuration_value"": ""600px""
+ },
+ {
+ ""name"": ""SmallSize"",
+ ""configuration_value"": ""400px""
+ }
+ ],
+ ""conditions"": {
+ ""client_filters"": [
+ {
+ ""name"": ""Browser"",
+ ""parameters"": {
+ ""AllowedBrowsers"": [""Chrome"", ""Edge""]
+ }
+ },
+ {
+ ""name"": ""TimeWindow"",
+ ""parameters"": {
+ ""Start"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds() + @")\/"",
+ ""End"": ""\/Date(" + DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds() + @")\/""
+ }
+ }
+ ]
+ }
+ }",
+ contentType: FeatureFlagContentType));
+
+ IConfigurationRefresher refresher = null;
+
+ using HttpClientTransportWithRequestInspection transportWithRequestInspection = new HttpClientTransportWithRequestInspection(requestInspector);
+
+ var config = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{testContext.KeyPrefix}:*");
+ options.ConfigureClientOptions(clientOptions =>
+ {
+ clientOptions.Transport = transportWithRequestInspection;
+ });
+ options.ConfigureKeyVault(kv => kv.SetCredential(_defaultAzureCredential));
+ options.UseFeatureFlags();
+ options.LoadBalancingEnabled = true;
+ refresher = options.GetRefresher();
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.RegisterAll();
+ refresh.SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+ })
+ .Build();
+
+ // Assert - Verify correlation context headers
+
+ // Basic request should have at least the request type
+ Assert.Contains(RequestTracingConstants.RequestTypeKey, requestInspector.CorrelationContextHeaders.Last());
+ Assert.Contains("Startup", requestInspector.CorrelationContextHeaders.Last());
+ Assert.Contains(RequestTracingConstants.KeyVaultConfiguredTag, requestInspector.CorrelationContextHeaders.Last());
+ Assert.Contains(RequestTracingConstants.LoadBalancingEnabledTag, requestInspector.CorrelationContextHeaders.Last());
+
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting($"{testContext.KeyPrefix}:Setting1", "UpdatedValue1"));
+ await Task.Delay(1500);
+ await refresher.RefreshAsync();
+
+ Assert.Contains("Watch", requestInspector.CorrelationContextHeaders.Last());
+ Assert.Contains(RequestTracingConstants.FeatureFlagFilterTypeKey, requestInspector.CorrelationContextHeaders.Last());
+ Assert.Contains(RequestTracingConstants.TimeWindowFilter, requestInspector.CorrelationContextHeaders.Last());
+ Assert.Contains(RequestTracingConstants.CustomFilter, requestInspector.CorrelationContextHeaders.Last());
+ Assert.Contains(RequestTracingConstants.FeatureFlagMaxVariantsKey, requestInspector.CorrelationContextHeaders.Last());
+ Assert.Contains($"{RequestTracingConstants.FeatureManagementVersionKey}=4.0.0", requestInspector.CorrelationContextHeaders.Last());
+ }
+
+ [Fact]
+ public async Task TagFilters()
+ {
+ TestContext testContext = CreateTestContext("TagFilters");
+ await SetupTaggedSettings(testContext);
+ string keyPrefix = testContext.KeyPrefix;
+
+ // Test case 1: Basic tag filtering with single tag
+ var config1 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development" });
+ options.UseFeatureFlags(ff =>
+ {
+ ff.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development" });
+ });
+ })
+ .Build();
+
+ // Assert - Should only get settings with Environment=Development tag
+ Assert.Equal("Value1", config1[$"{keyPrefix}:TaggedSetting1"]);
+ Assert.Equal("Value3", config1[$"{keyPrefix}:TaggedSetting3"]);
+ Assert.Null(config1[$"{keyPrefix}:TaggedSetting2"]);
+ Assert.Null(config1[$"{keyPrefix}:TaggedSetting4"]);
+ Assert.Null(config1[$"{keyPrefix}:TaggedSetting5"]);
+
+ // Feature flags should be filtered as well
+ Assert.Equal("True", config1[$"FeatureManagement:{keyPrefix}:FeatureDev"]);
+ Assert.Null(config1[$"FeatureManagement:{keyPrefix}:FeatureProd"]);
+ Assert.Null(config1[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]);
+ Assert.Null(config1[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]);
+
+ // Test case 2: Multiple tag filters (AND condition)
+ var config2 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development", "App=TestApp" });
+ options.UseFeatureFlags(ff =>
+ {
+ ff.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development", "App=TestApp" });
+ });
+ })
+ .Build();
+
+ // Assert - Should only get settings with both Environment=Development AND App=TestApp tags
+ Assert.Equal("Value1", config2[$"{keyPrefix}:TaggedSetting1"]);
+ Assert.Null(config2[$"{keyPrefix}:TaggedSetting2"]);
+ Assert.Null(config2[$"{keyPrefix}:TaggedSetting3"]);
+ Assert.Null(config2[$"{keyPrefix}:TaggedSetting4"]);
+ Assert.Null(config2[$"{keyPrefix}:TaggedSetting5"]);
+
+ // Feature flags
+ Assert.Equal("True", config2[$"FeatureManagement:{keyPrefix}:FeatureDev"]);
+ Assert.Null(config2[$"FeatureManagement:{keyPrefix}:FeatureProd"]);
+ Assert.Null(config2[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]);
+ Assert.Null(config2[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]);
+
+ // Test case 3: Special characters in tags
+ var config3 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{keyPrefix}:*", tagFilters: new[] { "Special:Tag=Value:With:Colons" });
+ options.UseFeatureFlags(ff =>
+ {
+ ff.Select($"{keyPrefix}:*", tagFilters: new[] { "Special:Tag=Value:With:Colons" });
+ });
+ })
+ .Build();
+
+ // Assert - Should only get settings with the special character tag
+ Assert.Equal("Value4", config3[$"{keyPrefix}:TaggedSetting4"]);
+ Assert.Null(config3[$"{keyPrefix}:TaggedSetting1"]);
+
+ Assert.Null(config3[$"{keyPrefix}:TaggedSetting2"]);
+ Assert.Null(config3[$"{keyPrefix}:TaggedSetting3"]);
+ Assert.Null(config3[$"{keyPrefix}:TaggedSetting5"]);
+
+ // Feature flags
+ Assert.Equal("True", config3[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]);
+ Assert.Null(config3[$"FeatureManagement:{keyPrefix}:FeatureDev"]);
+ Assert.Null(config3[$"FeatureManagement:{keyPrefix}:FeatureProd"]);
+ Assert.Null(config3[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]);
+
+ // Test case 4: Tag with @ symbol
+ var config4 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{keyPrefix}:*", tagFilters: new[] { "Tag@With@At=Value@With@At" });
+ })
+ .Build();
+
+ // Assert - Should only get settings with the @ symbol tag
+ Assert.Equal("Value4", config4[$"{keyPrefix}:TaggedSetting4"]);
+ Assert.Null(config4[$"{keyPrefix}:TaggedSetting1"]);
+ Assert.Null(config4[$"{keyPrefix}:TaggedSetting2"]);
+ Assert.Null(config4[$"{keyPrefix}:TaggedSetting3"]);
+ Assert.Null(config4[$"{keyPrefix}:TaggedSetting5"]);
+
+ // Test case 5: Empty and null tag values
+ var config5 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{keyPrefix}:*", tagFilters: new[] { "EmptyTag=", $"NullTag={TagValue.Null}" });
+ options.UseFeatureFlags(ff =>
+ {
+ ff.Select($"{keyPrefix}:*", tagFilters: new[] { "EmptyTag=", $"NullTag={TagValue.Null}" });
+ });
+ })
+ .Build();
+
+ // Assert - Should only get settings with both empty and null tag values
+ Assert.Equal("Value5", config5[$"{keyPrefix}:TaggedSetting5"]);
+ Assert.Null(config5[$"{keyPrefix}:TaggedSetting1"]);
+ Assert.Null(config5[$"{keyPrefix}:TaggedSetting2"]);
+ Assert.Null(config5[$"{keyPrefix}:TaggedSetting3"]);
+ Assert.Null(config5[$"{keyPrefix}:TaggedSetting4"]);
+
+ // Feature flags
+ Assert.Equal("False", config5[$"FeatureManagement:{keyPrefix}:FeatureEmpty"]);
+ Assert.Null(config5[$"FeatureManagement:{keyPrefix}:FeatureDev"]);
+ Assert.Null(config5[$"FeatureManagement:{keyPrefix}:FeatureProd"]);
+ Assert.Null(config5[$"FeatureManagement:{keyPrefix}:FeatureSpecial"]);
+
+ // Test case 6: Interaction with refresh functionality
+ IConfigurationRefresher refresher = null;
+ var config9 = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.Connect(_connectionString);
+ options.Select($"{keyPrefix}:*", tagFilters: new[] { "Environment=Development" });
+ options.ConfigureRefresh(refresh =>
+ {
+ refresh.Register(testContext.SentinelKey, refreshAll: true)
+ .SetRefreshInterval(TimeSpan.FromSeconds(1));
+ });
+ refresher = options.GetRefresher();
+ })
+ .Build();
+
+ // Assert initial state
+ Assert.Equal("Value1", config9[$"{keyPrefix}:TaggedSetting1"]);
+ Assert.Equal("Value3", config9[$"{keyPrefix}:TaggedSetting3"]);
+
+ // Update a tagged setting's value
+ await _configClient.SetConfigurationSettingAsync(
+ CreateSettingWithTags(
+ $"{keyPrefix}:TaggedSetting1",
+ "UpdatedValue1",
+ new Dictionary {
+ { "Environment", "Development" },
+ { "App", "TestApp" }
+ }));
+
+ // Add a new setting with Development tag
+ await _configClient.SetConfigurationSettingAsync(
+ CreateSettingWithTags(
+ $"{keyPrefix}:TaggedSetting7",
+ "Value7",
+ new Dictionary {
+ { "Environment", "Development" }
+ }));
+
+ // Update the sentinel key to trigger refresh
+ await _configClient.SetConfigurationSettingAsync(new ConfigurationSetting(testContext.SentinelKey, "Updated"));
+
+ // Wait for cache to expire
+ await Task.Delay(TimeSpan.FromSeconds(2));
+
+ // Refresh the configuration
+ await refresher.RefreshAsync();
+
+ Assert.Equal("UpdatedValue1", config9[$"{keyPrefix}:TaggedSetting1"]);
+ Assert.Equal("Value3", config9[$"{keyPrefix}:TaggedSetting3"]);
+ Assert.Equal("Value7", config9[$"{keyPrefix}:TaggedSetting7"]);
+ Assert.Null(config9[$"{keyPrefix}:TaggedSetting2"]);
+ }
+
+ private class HttpPipelineTransportWithRequestCount : HttpPipelineTransport
+ {
+ private readonly HttpClientTransport _innerTransport = new HttpClientTransport();
+ private readonly Action _onRequest;
+
+ public HttpPipelineTransportWithRequestCount(Action onRequest)
+ {
+ _onRequest = onRequest;
+ }
+
+ public override Request CreateRequest()
+ {
+ return _innerTransport.CreateRequest();
+ }
+
+ public override void Process(HttpMessage message)
+ {
+ _onRequest();
+ _innerTransport.Process(message);
+ }
+
+ public override ValueTask ProcessAsync(HttpMessage message)
+ {
+ _onRequest();
+ return _innerTransport.ProcessAsync(message);
+ }
+ }
+
+ private class HttpClientTransportWithRequestInspection : HttpClientTransport
+ {
+ private readonly RequestInspectionHandler _inspector;
+
+ public HttpClientTransportWithRequestInspection(RequestInspectionHandler inspector)
+ {
+ _inspector = inspector;
+ }
+
+ public override async ValueTask ProcessAsync(HttpMessage message)
+ {
+ _inspector.InspectRequest(message);
+ await base.ProcessAsync(message);
+ }
+ }
+
+ private class RequestInspectionHandler
+ {
+ public List CorrelationContextHeaders { get; } = new List();
+
+ public void InspectRequest(HttpMessage message)
+ {
+ if (message.Request.Headers.TryGetValue(RequestTracingConstants.CorrelationContextHeader, out string header))
+ {
+ CorrelationContextHeaders.Add(header);
+ }
+ }
+ }
+
+ private ConfigurationSetting CreateSettingWithTags(string key, string value, IDictionary tags)
+ {
+ var setting = new ConfigurationSetting(key, value);
+
+ if (tags != null)
+ {
+ foreach (var tag in tags)
+ {
+ setting.Tags.Add(tag.Key, tag.Value);
+ }
+ }
+
+ return setting;
+ }
+
+ private ConfigurationSetting CreateFeatureFlagWithTags(string featureId, bool enabled, IDictionary tags)
+ {
+ string jsonValue = $@"{{
+ ""id"": ""{featureId}"",
+ ""description"": ""Test feature flag with tags"",
+ ""enabled"": {enabled.ToString().ToLowerInvariant()},
+ ""conditions"": {{
+ ""client_filters"": []
+ }}
+ }}";
+
+ var setting = new ConfigurationSetting(
+ key: FeatureManagementConstants.FeatureFlagMarker + featureId,
+ value: jsonValue)
+ {
+ ContentType = FeatureFlagContentType
+ };
+
+ if (tags != null)
+ {
+ foreach (var tag in tags)
+ {
+ setting.Tags.Add(tag.Key, tag.Value);
+ }
+ }
+
+ return setting;
+ }
+ }
+}
diff --git a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj
index 49c58ec60..79f47316e 100644
--- a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj
+++ b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj
@@ -7,12 +7,17 @@
true
..\..\build\AzureAppConfiguration.snk
false
- false
+ true
+
+
+
+
+
@@ -23,7 +28,13 @@
-
+
+
+
+ Always
+
+
+
diff --git a/tests/Tests.AzureAppConfiguration/CallbackMessageHandler.cs b/tests/Tests.AzureAppConfiguration/Unit/CallbackMessageHandler.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/CallbackMessageHandler.cs
rename to tests/Tests.AzureAppConfiguration/Unit/CallbackMessageHandler.cs
diff --git a/tests/Tests.AzureAppConfiguration/ClientOptionsTests.cs b/tests/Tests.AzureAppConfiguration/Unit/ClientOptionsTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/ClientOptionsTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/ClientOptionsTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/ConnectTests.cs b/tests/Tests.AzureAppConfiguration/Unit/ConnectTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/ConnectTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/ConnectTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/FailoverTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/HttpRequestCountPipelinePolicy.cs b/tests/Tests.AzureAppConfiguration/Unit/HttpRequestCountPipelinePolicy.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/HttpRequestCountPipelinePolicy.cs
rename to tests/Tests.AzureAppConfiguration/Unit/HttpRequestCountPipelinePolicy.cs
diff --git a/tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs b/tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/JsonContentTypeTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/JsonContentTypeTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs b/tests/Tests.AzureAppConfiguration/Unit/LoadBalancingTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/LoadBalancingTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/LoggingTests.cs b/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/LoggingTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/MapTests.cs b/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/MapTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/MapTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/MockedConfigurationClientManager.cs b/tests/Tests.AzureAppConfiguration/Unit/MockedConfigurationClientManager.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/MockedConfigurationClientManager.cs
rename to tests/Tests.AzureAppConfiguration/Unit/MockedConfigurationClientManager.cs
diff --git a/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/PushRefreshTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/RefreshTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/TagFiltersTests.cs b/tests/Tests.AzureAppConfiguration/Unit/TagFiltersTests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/TagFiltersTests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/TagFiltersTests.cs
diff --git a/tests/Tests.AzureAppConfiguration/TestHelper.cs b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/TestHelper.cs
rename to tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs
diff --git a/tests/Tests.AzureAppConfiguration/Tests.cs b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs
similarity index 100%
rename from tests/Tests.AzureAppConfiguration/Tests.cs
rename to tests/Tests.AzureAppConfiguration/Unit/Tests.cs