audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -0,0 +1,254 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace StellaOps.FeatureFlags.Tests;
[Trait("Category", "Unit")]
public class CompositeFeatureFlagServiceTests : IDisposable
{
private readonly Mock<IFeatureFlagProvider> _primaryProvider;
private readonly Mock<IFeatureFlagProvider> _secondaryProvider;
private readonly CompositeFeatureFlagService _sut;
public CompositeFeatureFlagServiceTests()
{
_primaryProvider = new Mock<IFeatureFlagProvider>();
_primaryProvider.Setup(p => p.Name).Returns("Primary");
_primaryProvider.Setup(p => p.Priority).Returns(10);
_primaryProvider.Setup(p => p.SupportsWatch).Returns(false);
_secondaryProvider = new Mock<IFeatureFlagProvider>();
_secondaryProvider.Setup(p => p.Name).Returns("Secondary");
_secondaryProvider.Setup(p => p.Priority).Returns(20);
_secondaryProvider.Setup(p => p.SupportsWatch).Returns(false);
var options = Options.Create(new FeatureFlagOptions
{
EnableCaching = false,
EnableLogging = false
});
_sut = new CompositeFeatureFlagService(
[_primaryProvider.Object, _secondaryProvider.Object],
options,
NullLogger<CompositeFeatureFlagService>.Instance);
}
public void Dispose()
{
_sut.Dispose();
}
[Fact]
public async Task IsEnabledAsync_ReturnsTrueWhenFlagEnabled()
{
// Arrange
_primaryProvider
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FeatureFlagResult("test-flag", true, null, "Test reason", "Primary"));
// Act
var result = await _sut.IsEnabledAsync("test-flag");
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task IsEnabledAsync_ReturnsFalseWhenFlagDisabled()
{
// Arrange
_primaryProvider
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FeatureFlagResult("test-flag", false, null, "Test reason", "Primary"));
// Act
var result = await _sut.IsEnabledAsync("test-flag");
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task IsEnabledAsync_ReturnsDefaultWhenFlagNotFound()
{
// Arrange
_primaryProvider
.Setup(p => p.TryGetFlagAsync("unknown-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FeatureFlagResult?)null);
_secondaryProvider
.Setup(p => p.TryGetFlagAsync("unknown-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FeatureFlagResult?)null);
// Act
var result = await _sut.IsEnabledAsync("unknown-flag");
// Assert
result.Should().BeFalse(); // Default is false
}
[Fact]
public async Task EvaluateAsync_ReturnsResultFromHighestPriorityProvider()
{
// Arrange
_primaryProvider
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FeatureFlagResult("test-flag", true, "variant-a", "Primary reason", "Primary"));
_secondaryProvider
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FeatureFlagResult("test-flag", false, "variant-b", "Secondary reason", "Secondary"));
// Act
var result = await _sut.EvaluateAsync("test-flag");
// Assert
result.Enabled.Should().BeTrue();
result.Source.Should().Be("Primary");
result.Variant.Should().Be("variant-a");
}
[Fact]
public async Task EvaluateAsync_FallsBackToSecondaryProviderWhenPrimaryReturnsNull()
{
// Arrange
_primaryProvider
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FeatureFlagResult?)null);
_secondaryProvider
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FeatureFlagResult("test-flag", true, null, "Secondary reason", "Secondary"));
// Act
var result = await _sut.EvaluateAsync("test-flag");
// Assert
result.Enabled.Should().BeTrue();
result.Source.Should().Be("Secondary");
}
[Fact]
public async Task EvaluateAsync_PassesContextToProvider()
{
// Arrange
var context = new FeatureFlagEvaluationContext(
UserId: "user-123",
TenantId: "tenant-456",
Environment: "production",
Attributes: new Dictionary<string, object?> { { "role", "admin" } });
FeatureFlagEvaluationContext? capturedContext = null;
_primaryProvider
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.Callback<string, FeatureFlagEvaluationContext, CancellationToken>((_, ctx, _) => capturedContext = ctx)
.ReturnsAsync(new FeatureFlagResult("test-flag", true, null, "Test", "Primary"));
// Act
await _sut.EvaluateAsync("test-flag", context);
// Assert
capturedContext.Should().NotBeNull();
capturedContext!.UserId.Should().Be("user-123");
capturedContext.TenantId.Should().Be("tenant-456");
capturedContext.Environment.Should().Be("production");
}
[Fact]
public async Task EvaluateAsync_HandlesProviderExceptionGracefully()
{
// Arrange
_primaryProvider
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Provider error"));
_secondaryProvider
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FeatureFlagResult("test-flag", true, null, "Fallback", "Secondary"));
// Act
var result = await _sut.EvaluateAsync("test-flag");
// Assert
result.Enabled.Should().BeTrue();
result.Source.Should().Be("Secondary");
}
[Fact]
public async Task GetVariantAsync_ReturnsVariantValue()
{
// Arrange
_primaryProvider
.Setup(p => p.TryGetFlagAsync("variant-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FeatureFlagResult("variant-flag", true, "blue", "Test", "Primary"));
// Act
var result = await _sut.GetVariantAsync("variant-flag", "default");
// Assert
result.Should().Be("blue");
}
[Fact]
public async Task GetVariantAsync_ReturnsDefaultWhenNoVariant()
{
// Arrange
_primaryProvider
.Setup(p => p.TryGetFlagAsync("simple-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FeatureFlagResult("simple-flag", true, null, "Test", "Primary"));
// Act
var result = await _sut.GetVariantAsync("simple-flag", "fallback");
// Assert
result.Should().Be("fallback");
}
[Fact]
public async Task ListFlagsAsync_AggregatesFlagsFromAllProviders()
{
// Arrange
_primaryProvider
.Setup(p => p.ListFlagsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([
new FeatureFlagDefinition("flag-a", "Description A", true, true),
new FeatureFlagDefinition("flag-b", "Description B", false, false)
]);
_secondaryProvider
.Setup(p => p.ListFlagsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([
new FeatureFlagDefinition("flag-b", "Override B", true, true),
new FeatureFlagDefinition("flag-c", "Description C", true, true)
]);
// Act
var result = await _sut.ListFlagsAsync();
// Assert
result.Should().HaveCount(3);
result.Should().Contain(f => f.Key == "flag-a");
result.Should().Contain(f => f.Key == "flag-b" && f.DefaultValue == false); // Primary wins
result.Should().Contain(f => f.Key == "flag-c");
}
[Fact]
public async Task ListFlagsAsync_ReturnsOrderedByKey()
{
// Arrange
_primaryProvider
.Setup(p => p.ListFlagsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([
new FeatureFlagDefinition("zebra", null, true, true),
new FeatureFlagDefinition("alpha", null, true, true)
]);
_secondaryProvider
.Setup(p => p.ListFlagsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
// Act
var result = await _sut.ListFlagsAsync();
// Assert
result.Select(f => f.Key).Should().BeInAscendingOrder();
}
}

View File

@@ -0,0 +1,196 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using StellaOps.FeatureFlags.Providers;
using Xunit;
namespace StellaOps.FeatureFlags.Tests;
[Trait("Category", "Unit")]
public class ConfigurationFeatureFlagProviderTests : IDisposable
{
private readonly ConfigurationFeatureFlagProvider _sut;
private readonly IConfigurationRoot _configuration;
public ConfigurationFeatureFlagProviderTests()
{
var configData = new Dictionary<string, string?>
{
{ "FeatureFlags:SimpleFlag", "true" },
{ "FeatureFlags:DisabledFlag", "false" },
{ "FeatureFlags:ComplexFlag:Enabled", "true" },
{ "FeatureFlags:ComplexFlag:Variant", "blue" },
{ "FeatureFlags:ComplexFlag:Description", "A complex feature flag" },
{ "FeatureFlags:VariantOnlyFlag:Enabled", "false" },
{ "FeatureFlags:VariantOnlyFlag:Variant", "control" },
{ "CustomSection:MyFlag", "true" }
};
_configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.Build();
_sut = new ConfigurationFeatureFlagProvider(_configuration);
}
public void Dispose()
{
_sut.Dispose();
}
[Fact]
public void Name_ReturnsConfiguration()
{
_sut.Name.Should().Be("Configuration");
}
[Fact]
public void Priority_ReturnsDefaultValue()
{
_sut.Priority.Should().Be(50);
}
[Fact]
public void SupportsWatch_ReturnsTrue()
{
_sut.SupportsWatch.Should().BeTrue();
}
[Fact]
public async Task TryGetFlagAsync_ReturnsTrueForEnabledSimpleFlag()
{
// Act
var result = await _sut.TryGetFlagAsync("SimpleFlag", FeatureFlagEvaluationContext.Empty);
// Assert
result.Should().NotBeNull();
result!.Enabled.Should().BeTrue();
result.Key.Should().Be("SimpleFlag");
}
[Fact]
public async Task TryGetFlagAsync_ReturnsFalseForDisabledSimpleFlag()
{
// Act
var result = await _sut.TryGetFlagAsync("DisabledFlag", FeatureFlagEvaluationContext.Empty);
// Assert
result.Should().NotBeNull();
result!.Enabled.Should().BeFalse();
}
[Fact]
public async Task TryGetFlagAsync_ReturnsNullForUnknownFlag()
{
// Act
var result = await _sut.TryGetFlagAsync("UnknownFlag", FeatureFlagEvaluationContext.Empty);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task TryGetFlagAsync_ParsesComplexFlagWithVariant()
{
// Act
var result = await _sut.TryGetFlagAsync("ComplexFlag", FeatureFlagEvaluationContext.Empty);
// Assert
result.Should().NotBeNull();
result!.Enabled.Should().BeTrue();
result.Variant.Should().Be("blue");
}
[Fact]
public async Task TryGetFlagAsync_ParsesComplexFlagWithEnabledFalse()
{
// Act
var result = await _sut.TryGetFlagAsync("VariantOnlyFlag", FeatureFlagEvaluationContext.Empty);
// Assert
result.Should().NotBeNull();
result!.Enabled.Should().BeFalse();
result.Variant.Should().Be("control");
}
[Fact]
public async Task ListFlagsAsync_ReturnsAllDefinedFlags()
{
// Act
var result = await _sut.ListFlagsAsync();
// Assert
result.Should().HaveCount(4);
result.Select(f => f.Key).Should().Contain([
"SimpleFlag", "DisabledFlag", "ComplexFlag", "VariantOnlyFlag"
]);
}
[Fact]
public async Task ListFlagsAsync_ParsesDefaultValuesCorrectly()
{
// Act
var result = await _sut.ListFlagsAsync();
// Assert
var simpleFlag = result.Single(f => f.Key == "SimpleFlag");
simpleFlag.DefaultValue.Should().BeTrue();
var disabledFlag = result.Single(f => f.Key == "DisabledFlag");
disabledFlag.DefaultValue.Should().BeFalse();
var complexFlag = result.Single(f => f.Key == "ComplexFlag");
complexFlag.DefaultValue.Should().BeTrue();
complexFlag.Description.Should().Be("A complex feature flag");
}
[Fact]
public void Constructor_WithCustomSection_ReadsFromThatSection()
{
// Arrange
using var provider = new ConfigurationFeatureFlagProvider(_configuration, "CustomSection");
// Act & Assert
var result = provider.TryGetFlagAsync("MyFlag", FeatureFlagEvaluationContext.Empty).Result;
result.Should().NotBeNull();
result!.Enabled.Should().BeTrue();
}
[Fact]
public void Constructor_WithCustomPriority_SetsCorrectPriority()
{
// Arrange
using var provider = new ConfigurationFeatureFlagProvider(_configuration, priority: 25);
// Assert
provider.Priority.Should().Be(25);
}
[Theory]
[InlineData("TRUE", true)]
[InlineData("True", true)]
[InlineData("true", true)]
[InlineData("FALSE", false)]
[InlineData("False", false)]
[InlineData("false", false)]
public async Task TryGetFlagAsync_HandlesCaseInsensitiveBooleanValues(string configValue, bool expected)
{
// Arrange
var configData = new Dictionary<string, string?>
{
{ "FeatureFlags:CaseFlag", configValue }
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.Build();
using var provider = new ConfigurationFeatureFlagProvider(configuration);
// Act
var result = await provider.TryGetFlagAsync("CaseFlag", FeatureFlagEvaluationContext.Empty);
// Assert
result.Should().NotBeNull();
result!.Enabled.Should().Be(expected);
}
}

View File

@@ -0,0 +1,200 @@
using FluentAssertions;
using Xunit;
namespace StellaOps.FeatureFlags.Tests;
[Trait("Category", "Unit")]
public class FeatureFlagModelsTests
{
[Fact]
public void FeatureFlagResult_CanBeCreated()
{
// Act
var result = new FeatureFlagResult(
Key: "test-flag",
Enabled: true,
Variant: "blue",
Reason: "Test reason",
Source: "TestProvider");
// Assert
result.Key.Should().Be("test-flag");
result.Enabled.Should().BeTrue();
result.Variant.Should().Be("blue");
result.Reason.Should().Be("Test reason");
result.Source.Should().Be("TestProvider");
}
[Fact]
public void FeatureFlagResult_WithNullOptionalValues()
{
// Act
var result = new FeatureFlagResult(
Key: "simple-flag",
Enabled: false,
Variant: null,
Reason: null,
Source: "TestProvider");
// Assert
result.Variant.Should().BeNull();
result.Reason.Should().BeNull();
}
[Fact]
public void FeatureFlagEvaluationContext_Empty_HasNullValues()
{
// Act
var context = FeatureFlagEvaluationContext.Empty;
// Assert
context.UserId.Should().BeNull();
context.TenantId.Should().BeNull();
context.Environment.Should().BeNull();
context.Attributes.Should().BeNull();
}
[Fact]
public void FeatureFlagEvaluationContext_CanBeCreatedWithAllValues()
{
// Arrange
var attributes = new Dictionary<string, object?>
{
{ "role", "admin" },
{ "subscription", "premium" }
};
// Act
var context = new FeatureFlagEvaluationContext(
UserId: "user-123",
TenantId: "tenant-456",
Environment: "production",
Attributes: attributes);
// Assert
context.UserId.Should().Be("user-123");
context.TenantId.Should().Be("tenant-456");
context.Environment.Should().Be("production");
context.Attributes.Should().HaveCount(2);
context.Attributes!["role"].Should().Be("admin");
}
[Fact]
public void FeatureFlagDefinition_CanBeCreatedWithRequiredValues()
{
// Act
var definition = new FeatureFlagDefinition(
Key: "my-feature",
Description: "My feature description",
DefaultValue: true,
Enabled: false);
// Assert
definition.Key.Should().Be("my-feature");
definition.Description.Should().Be("My feature description");
definition.DefaultValue.Should().BeTrue();
definition.Enabled.Should().BeFalse();
definition.Tags.Should().BeNull();
}
[Fact]
public void FeatureFlagDefinition_CanBeCreatedWithTags()
{
// Arrange
var tags = new List<string> { "team-a", "critical" };
// Act
var definition = new FeatureFlagDefinition(
Key: "feature",
Description: null,
DefaultValue: false,
Enabled: true,
Tags: tags);
// Assert
definition.Tags.Should().NotBeNull();
definition.Tags.Should().Contain("team-a");
definition.Tags.Should().Contain("critical");
}
[Fact]
public void FeatureFlagChangedEvent_CanBeCreated()
{
// Arrange
var timestamp = DateTimeOffset.UtcNow;
// Act
var evt = new FeatureFlagChangedEvent(
Key: "toggle-flag",
OldValue: false,
NewValue: true,
Source: "ConfigProvider",
Timestamp: timestamp);
// Assert
evt.Key.Should().Be("toggle-flag");
evt.OldValue.Should().BeFalse();
evt.NewValue.Should().BeTrue();
evt.Source.Should().Be("ConfigProvider");
evt.Timestamp.Should().Be(timestamp);
}
[Fact]
public void FeatureFlagOptions_HasCorrectDefaults()
{
// Act
var options = new FeatureFlagOptions();
// Assert
options.DefaultValue.Should().BeFalse();
options.EnableCaching.Should().BeTrue();
options.CacheDuration.Should().Be(TimeSpan.FromSeconds(30));
options.EnableLogging.Should().BeTrue();
options.EnableMetrics.Should().BeTrue();
}
[Fact]
public void FeatureFlagOptions_CanBeModified()
{
// Act
var options = new FeatureFlagOptions
{
DefaultValue = true,
EnableCaching = false,
CacheDuration = TimeSpan.FromHours(1),
EnableLogging = false
};
// Assert
options.DefaultValue.Should().BeTrue();
options.EnableCaching.Should().BeFalse();
options.CacheDuration.Should().Be(TimeSpan.FromHours(1));
options.EnableLogging.Should().BeFalse();
}
[Fact]
public void FeatureFlagEvaluationContext_RecordEquality()
{
// Arrange
var context1 = new FeatureFlagEvaluationContext("user", "tenant", "env", null);
var context2 = new FeatureFlagEvaluationContext("user", "tenant", "env", null);
var context3 = new FeatureFlagEvaluationContext("other", "tenant", "env", null);
// Assert - Records have value equality
context1.Should().Be(context2);
context1.Should().NotBe(context3);
}
[Fact]
public void FeatureFlagResult_RecordEquality()
{
// Arrange
var result1 = new FeatureFlagResult("key", true, null, "reason", "source");
var result2 = new FeatureFlagResult("key", true, null, "reason", "source");
var result3 = new FeatureFlagResult("key", false, null, "reason", "source");
// Assert
result1.Should().Be(result2);
result1.Should().NotBe(result3);
}
}

View File

@@ -0,0 +1,230 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace StellaOps.FeatureFlags.Tests;
[Trait("Category", "Unit")]
public class FeatureFlagServiceCollectionExtensionsTests
{
[Fact]
public void AddFeatureFlags_RegistersFeatureFlagService()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddFeatureFlags();
var provider = services.BuildServiceProvider();
// Assert
var service = provider.GetService<IFeatureFlagService>();
service.Should().NotBeNull();
service.Should().BeOfType<CompositeFeatureFlagService>();
}
[Fact]
public void AddFeatureFlags_WithOptions_ConfiguresOptions()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddFeatureFlags(options =>
{
options.EnableCaching = true;
options.CacheDuration = TimeSpan.FromMinutes(5);
options.DefaultValue = true;
});
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<FeatureFlagOptions>>();
// Assert
options.Value.EnableCaching.Should().BeTrue();
options.Value.CacheDuration.Should().Be(TimeSpan.FromMinutes(5));
options.Value.DefaultValue.Should().BeTrue();
}
[Fact]
public void AddConfigurationFeatureFlags_RegistersConfigurationProvider()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
var configData = new Dictionary<string, string?>
{
{ "FeatureFlags:TestFlag", "true" }
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddFeatureFlags();
services.AddConfigurationFeatureFlags();
// Act
var provider = services.BuildServiceProvider();
var featureFlagProviders = provider.GetServices<IFeatureFlagProvider>().ToList();
// Assert
featureFlagProviders.Should().ContainSingle();
featureFlagProviders[0].Name.Should().Be("Configuration");
}
[Fact]
public void AddConfigurationFeatureFlags_WithCustomSectionName_UsesCorrectSection()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
var configData = new Dictionary<string, string?>
{
{ "CustomFlags:MyFlag", "true" }
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddFeatureFlags();
services.AddConfigurationFeatureFlags(sectionName: "CustomFlags");
// Act
var provider = services.BuildServiceProvider();
var featureFlagService = provider.GetRequiredService<IFeatureFlagService>();
var result = featureFlagService.IsEnabledAsync("MyFlag").Result;
// Assert
result.Should().BeTrue();
}
[Fact]
public void AddInMemoryFeatureFlags_RegistersInMemoryProvider()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
var flags = new Dictionary<string, bool>
{
{ "InMemoryFlag", true }
};
services.AddFeatureFlags();
services.AddInMemoryFeatureFlags(flags);
// Act
var provider = services.BuildServiceProvider();
var featureFlagService = provider.GetRequiredService<IFeatureFlagService>();
var result = featureFlagService.IsEnabledAsync("InMemoryFlag").Result;
// Assert
result.Should().BeTrue();
}
[Fact]
public void AddInMemoryFeatureFlags_WithPriority_SetsCorrectPriority()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
services.AddFeatureFlags();
services.AddInMemoryFeatureFlags(new Dictionary<string, bool> { { "Flag", true } }, priority: 5);
// Act
var provider = services.BuildServiceProvider();
var featureFlagProviders = provider.GetServices<IFeatureFlagProvider>().ToList();
// Assert
featureFlagProviders.Single().Priority.Should().Be(5);
}
[Fact]
public void AddFeatureFlagProvider_RegistersCustomProvider()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
services.AddFeatureFlags();
services.AddFeatureFlagProvider<TestFeatureFlagProvider>();
// Act
var provider = services.BuildServiceProvider();
var featureFlagProviders = provider.GetServices<IFeatureFlagProvider>().ToList();
// Assert
featureFlagProviders.Should().ContainSingle();
featureFlagProviders[0].Should().BeOfType<TestFeatureFlagProvider>();
}
[Fact]
public void AddFeatureFlagProvider_WithFactory_RegistersCustomProvider()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
services.AddFeatureFlags();
services.AddFeatureFlagProvider(_ => new TestFeatureFlagProvider());
// Act
var provider = services.BuildServiceProvider();
var featureFlagProviders = provider.GetServices<IFeatureFlagProvider>().ToList();
// Assert
featureFlagProviders.Should().ContainSingle();
featureFlagProviders[0].Should().BeOfType<TestFeatureFlagProvider>();
}
[Fact]
public void MultipleProviders_AreRegisteredInPriorityOrder()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
var configData = new Dictionary<string, string?>
{
{ "FeatureFlags:SharedFlag", "false" }
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddFeatureFlags();
services.AddConfigurationFeatureFlags(priority: 50);
services.AddInMemoryFeatureFlags(new Dictionary<string, bool> { { "SharedFlag", true } }, priority: 10);
// Act
var provider = services.BuildServiceProvider();
var featureFlagService = provider.GetRequiredService<IFeatureFlagService>();
var result = featureFlagService.IsEnabledAsync("SharedFlag").Result;
// Assert - InMemory has lower priority number (higher precedence), so it wins
result.Should().BeTrue();
}
private class TestFeatureFlagProvider : FeatureFlagProviderBase
{
public override string Name => "Test";
public override Task<FeatureFlagResult?> TryGetFlagAsync(
string flagKey,
FeatureFlagEvaluationContext context,
CancellationToken ct = default)
{
return Task.FromResult<FeatureFlagResult?>(
new FeatureFlagResult(flagKey, true, null, "Test", Name));
}
}
}

View File

@@ -0,0 +1,243 @@
using FluentAssertions;
using Xunit;
namespace StellaOps.FeatureFlags.Tests;
[Trait("Category", "Unit")]
public class InMemoryFeatureFlagProviderTests
{
[Fact]
public void Name_ReturnsInMemory()
{
// Arrange
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
// Assert
provider.Name.Should().Be("InMemory");
}
[Fact]
public void Priority_ReturnsConfiguredValue()
{
// Arrange
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>(), priority: 5);
// Assert
provider.Priority.Should().Be(5);
}
[Fact]
public async Task TryGetFlagAsync_ReturnsTrueForEnabledFlag()
{
// Arrange
var flags = new Dictionary<string, bool>
{
{ "enabled-flag", true }
};
var provider = new InMemoryFeatureFlagProvider(flags);
// Act
var result = await provider.TryGetFlagAsync("enabled-flag", FeatureFlagEvaluationContext.Empty);
// Assert
result.Should().NotBeNull();
result!.Enabled.Should().BeTrue();
result.Key.Should().Be("enabled-flag");
}
[Fact]
public async Task TryGetFlagAsync_ReturnsFalseForDisabledFlag()
{
// Arrange
var flags = new Dictionary<string, bool>
{
{ "disabled-flag", false }
};
var provider = new InMemoryFeatureFlagProvider(flags);
// Act
var result = await provider.TryGetFlagAsync("disabled-flag", FeatureFlagEvaluationContext.Empty);
// Assert
result.Should().NotBeNull();
result!.Enabled.Should().BeFalse();
}
[Fact]
public async Task TryGetFlagAsync_ReturnsNullForUnknownFlag()
{
// Arrange
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
// Act
var result = await provider.TryGetFlagAsync("unknown", FeatureFlagEvaluationContext.Empty);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task TryGetFlagAsync_IsCaseInsensitive()
{
// Arrange
var flags = new Dictionary<string, bool>
{
{ "MyFlag", true }
};
var provider = new InMemoryFeatureFlagProvider(flags);
// Act
var result = await provider.TryGetFlagAsync("myflag", FeatureFlagEvaluationContext.Empty);
// Assert
result.Should().NotBeNull();
result!.Enabled.Should().BeTrue();
}
[Fact]
public void SetFlag_AddsNewFlag()
{
// Arrange
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
// Act
provider.SetFlag("new-flag", true);
var result = provider.TryGetFlagAsync("new-flag", FeatureFlagEvaluationContext.Empty).Result;
// Assert
result.Should().NotBeNull();
result!.Enabled.Should().BeTrue();
}
[Fact]
public void SetFlag_UpdatesExistingFlag()
{
// Arrange
var flags = new Dictionary<string, bool>
{
{ "toggle-flag", false }
};
var provider = new InMemoryFeatureFlagProvider(flags);
// Act
provider.SetFlag("toggle-flag", true);
var result = provider.TryGetFlagAsync("toggle-flag", FeatureFlagEvaluationContext.Empty).Result;
// Assert
result.Should().NotBeNull();
result!.Enabled.Should().BeTrue();
}
[Fact]
public void SetFlag_WithVariant_SetsVariantValue()
{
// Arrange
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
// Act
provider.SetFlag("variant-flag", true, "blue");
var result = provider.TryGetFlagAsync("variant-flag", FeatureFlagEvaluationContext.Empty).Result;
// Assert
result.Should().NotBeNull();
result!.Variant.Should().Be("blue");
}
[Fact]
public void RemoveFlag_RemovesExistingFlag()
{
// Arrange
var flags = new Dictionary<string, bool>
{
{ "to-remove", true }
};
var provider = new InMemoryFeatureFlagProvider(flags);
// Act
provider.RemoveFlag("to-remove");
var result = provider.TryGetFlagAsync("to-remove", FeatureFlagEvaluationContext.Empty).Result;
// Assert
result.Should().BeNull();
}
[Fact]
public void RemoveFlag_DoesNotThrowForNonexistentFlag()
{
// Arrange
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
// Act & Assert
provider.Invoking(p => p.RemoveFlag("nonexistent"))
.Should().NotThrow();
}
[Fact]
public void Clear_RemovesAllFlags()
{
// Arrange
var flags = new Dictionary<string, bool>
{
{ "flag-1", true },
{ "flag-2", false }
};
var provider = new InMemoryFeatureFlagProvider(flags);
// Act
provider.Clear();
var result1 = provider.TryGetFlagAsync("flag-1", FeatureFlagEvaluationContext.Empty).Result;
var result2 = provider.TryGetFlagAsync("flag-2", FeatureFlagEvaluationContext.Empty).Result;
// Assert
result1.Should().BeNull();
result2.Should().BeNull();
}
[Fact]
public async Task ListFlagsAsync_ReturnsAllFlags()
{
// Arrange
var flags = new Dictionary<string, bool>
{
{ "flag-a", true },
{ "flag-b", false },
{ "flag-c", true }
};
var provider = new InMemoryFeatureFlagProvider(flags);
// Act
var result = await provider.ListFlagsAsync();
// Assert
result.Should().HaveCount(3);
result.Select(f => f.Key).Should().Contain(["flag-a", "flag-b", "flag-c"]);
}
[Fact]
public async Task ListFlagsAsync_ReturnsEmptyWhenNoFlags()
{
// Arrange
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
// Act
var result = await provider.ListFlagsAsync();
// Assert
result.Should().BeEmpty();
}
[Fact]
public async Task ListFlagsAsync_ReflectsCurrentStateAfterModifications()
{
// Arrange
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
provider.SetFlag("dynamic-flag", true);
// Act
var result = await provider.ListFlagsAsync();
// Assert
result.Should().HaveCount(1);
result.Single().Key.Should().Be("dynamic-flag");
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.FeatureFlags.Tests</RootNamespace>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.FeatureFlags\StellaOps.FeatureFlags.csproj" />
</ItemGroup>
</Project>