audit, advisories and doctors/setup work
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user