diff --git a/LaunchDarkly.OpenFeature.ServerProvider.sln b/LaunchDarkly.OpenFeature.ServerProvider.sln index 71d0189..a6fdb7b 100644 --- a/LaunchDarkly.OpenFeature.ServerProvider.sln +++ b/LaunchDarkly.OpenFeature.ServerProvider.sln @@ -1,32 +1,40 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30114.105 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36127.28 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.OpenFeature.ServerProvider", "src\LaunchDarkly.OpenFeature.ServerProvider\LaunchDarkly.OpenFeature.ServerProvider.csproj", "{B61EC563-2D25-47C8-86A4-0C3A8C625109}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.OpenFeature.ServerProvider.Tests", "test\LaunchDarkly.OpenFeature.ServerProvider.Tests\LaunchDarkly.OpenFeature.ServerProvider.Tests.csproj", "{A1B1DB34-6B22-4AA4-9974-6EE67145BDB9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection", "src\LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection\LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.csproj", "{DB523227-21AE-4B92-A263-05050CEF6C8D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests", "test\LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests\LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests.csproj", "{F2C8E3A6-12D4-4B8F-9A7E-3F5C8D6B4E2A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B61EC563-2D25-47C8-86A4-0C3A8C625109}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B61EC563-2D25-47C8-86A4-0C3A8C625109}.Debug|Any CPU.Build.0 = Debug|Any CPU {B61EC563-2D25-47C8-86A4-0C3A8C625109}.Release|Any CPU.ActiveCfg = Release|Any CPU {B61EC563-2D25-47C8-86A4-0C3A8C625109}.Release|Any CPU.Build.0 = Release|Any CPU - {E8CE160B-0A65-480F-AA3F-028AD9F17F6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E8CE160B-0A65-480F-AA3F-028AD9F17F6E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E8CE160B-0A65-480F-AA3F-028AD9F17F6E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E8CE160B-0A65-480F-AA3F-028AD9F17F6E}.Release|Any CPU.Build.0 = Release|Any CPU {A1B1DB34-6B22-4AA4-9974-6EE67145BDB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B1DB34-6B22-4AA4-9974-6EE67145BDB9}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B1DB34-6B22-4AA4-9974-6EE67145BDB9}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B1DB34-6B22-4AA4-9974-6EE67145BDB9}.Release|Any CPU.Build.0 = Release|Any CPU + {DB523227-21AE-4B92-A263-05050CEF6C8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB523227-21AE-4B92-A263-05050CEF6C8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB523227-21AE-4B92-A263-05050CEF6C8D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB523227-21AE-4B92-A263-05050CEF6C8D}.Release|Any CPU.Build.0 = Release|Any CPU + {F2C8E3A6-12D4-4B8F-9A7E-3F5C8D6B4E2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2C8E3A6-12D4-4B8F-9A7E-3F5C8D6B4E2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2C8E3A6-12D4-4B8F-9A7E-3F5C8D6B4E2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2C8E3A6-12D4-4B8F-9A7E-3F5C8D6B4E2A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.csproj b/src/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.csproj new file mode 100644 index 0000000..1b9467f --- /dev/null +++ b/src/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.csproj @@ -0,0 +1,52 @@ + + + + + 2.1.1 + + + net8.0 + $(BUILDFRAMEWORKS) + + portable + LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection + Library + LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection + LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection + 7.3 + + Dependency injection support for the LaunchDarkly OpenFeature .NET Server Provider. + Enables seamless integration of LaunchDarkly feature flagging with .NET applications via OpenFeature’s DI patterns. + + LaunchDarkly + LaunchDarkly + LaunchDarkly + LaunchDarkly + LaunchDarkly + Copyright 2022 LaunchDarkly + Apache-2.0 + https://github.com/launchdarkly/openfeature-dotnet-server + https://github.com/launchdarkly/openfeature-dotnet-server + main + true + snupkg + + + 1570,1571,1572,1573,1574,1580,1581,1584,1591,1710,1711,1712 + + + + + + + + + + + + bin\$(Configuration)\$(TargetFramework)\LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.xml + + diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection/LaunchDarklyOpenFeatureBuilderExtensions.cs b/src/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection/LaunchDarklyOpenFeatureBuilderExtensions.cs new file mode 100644 index 0000000..2827fbf --- /dev/null +++ b/src/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection/LaunchDarklyOpenFeatureBuilderExtensions.cs @@ -0,0 +1,165 @@ +using LaunchDarkly.Sdk.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenFeature; +using OpenFeature.DependencyInjection; +using System; + +namespace LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection +{ + /// + /// Provides extension methods for configuring the to use LaunchDarkly as a . + /// + public static partial class LaunchDarklyOpenFeatureBuilderExtensions + { + /// + /// Configures the to use LaunchDarkly as the default provider + /// using the specified instance. + /// + /// The instance to configure. + /// A pre-built LaunchDarkly . + /// The updated instance. + /// + /// Thrown when the argument is null. + /// + public static OpenFeatureBuilder UseLaunchDarkly(this OpenFeatureBuilder builder, Configuration configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration), "Configuration cannot be null."); + } + + return RegisterLaunchDarklyProvider( + builder, + () => configuration, + sp => sp.GetRequiredService() + ); + } + + /// + /// Configures the to use LaunchDarkly as a domain-scoped provider + /// using the specified instance. + /// + /// The instance to configure. + /// A domain identifier (e.g., tenant or environment). + /// A pre-built LaunchDarkly specific to the domain. + /// The updated instance. + /// + /// Thrown when the argument is null. + /// + public static OpenFeatureBuilder UseLaunchDarkly(this OpenFeatureBuilder builder, string domain, Configuration configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration), "Configuration cannot be null."); + } + + return RegisterLaunchDarklyProvider( + builder, + domain, + () => configuration, + (sp, key) => sp.GetRequiredKeyedService(key) + ); + } + + /// + /// Configures the to use LaunchDarkly as the default provider + /// using the specified SDK key and optional configuration delegate. + /// + /// The instance to configure. + /// The SDK key used to initialize the LaunchDarkly configuration. + /// An optional delegate to customize the . + /// The updated instance. + public static OpenFeatureBuilder UseLaunchDarkly(this OpenFeatureBuilder builder, string sdkKey, Action configure = null) + => RegisterLaunchDarklyProvider( + builder, + () => CreateConfiguration(sdkKey, configure), + sp => sp.GetRequiredService()); + + /// + /// Configures the to use LaunchDarkly as a domain-scoped provider + /// using the specified SDK key and optional configuration delegate. + /// + /// The instance to configure. + /// A domain identifier (e.g., tenant or environment). + /// The SDK key used to initialize the LaunchDarkly configuration. + /// An optional delegate to customize the . + /// The updated instance. + public static OpenFeatureBuilder UseLaunchDarkly(this OpenFeatureBuilder builder, string domain, string sdkKey, Action configure = null) + => RegisterLaunchDarklyProvider( + builder, + domain, + () => CreateConfiguration(sdkKey, configure), + (sp, key) => sp.GetRequiredKeyedService(key)); + + /// + /// Registers LaunchDarkly as the default feature provider using the given configuration factory and resolution logic. + /// + /// The instance to configure. + /// A delegate that returns a instance. + /// A delegate that resolves the from the service provider. + /// The updated instance. + private static OpenFeatureBuilder RegisterLaunchDarklyProvider( + OpenFeatureBuilder builder, + Func createConfiguration, + Func resolveConfiguration) + { + // Perform early configuration validation to ensure the provider is correctly constructed. + // This ensures any misconfiguration is caught during application startup rather than at runtime. + var config = createConfiguration(); + builder.Services.TryAddSingleton(config); + + return builder.AddProvider(serviceProvider => new Provider(resolveConfiguration(serviceProvider))); + } + + /// + /// Registers LaunchDarkly as a domain-scoped feature provider using the given configuration factory and resolution logic. + /// + /// The instance to configure. + /// A domain identifier (e.g., tenant or environment). + /// A delegate that returns a domain-specific instance. + /// A delegate that resolves the domain-scoped from the service provider. + /// The updated instance. + private static OpenFeatureBuilder RegisterLaunchDarklyProvider( + OpenFeatureBuilder builder, + string domain, + Func createConfiguration, + Func resolveConfiguration) + { + // Perform early validation of the configuration to ensure it is valid before registration. + // This approach is consistent with the default (non-domain) registration path and helps fail fast on misconfiguration. + var config = createConfiguration(); + + // Register the domain-scoped configuration as a keyed singleton. + builder.Services.TryAddKeyedSingleton(domain, (_, key) => config); + + // Register the default configuration provider, which resolves the appropriate domain-scoped configuration + // using the default name selection policy defined in PolicyNameOptions. + // This enables resolving Configuration via serviceProvider.GetRequiredService() + // when no specific domain key is explicitly provided. + builder.Services.TryAddSingleton(provider => + { + var policy = provider.GetRequiredService>().Value; + var name = policy.DefaultNameSelector(provider); + return provider.GetRequiredKeyedService(name); + }); + + // Register the domain-scoped provider instance. + return builder.AddProvider(domain, (serviceProvider, key) => new Provider(resolveConfiguration(serviceProvider, key))); + } + + /// + /// Creates a new using the specified SDK key and optional configuration delegate. + /// + /// The SDK key used to initialize the configuration. + /// An optional delegate to customize the . + /// A fully constructed instance. + private static Configuration CreateConfiguration(string sdkKey, Action configure = null) + { + var configBuilder = Configuration.Builder(sdkKey); + configure?.Invoke(configBuilder); + return configBuilder.Build(); + } + } +} diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests.csproj b/test/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests.csproj new file mode 100644 index 0000000..3b5f47d --- /dev/null +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests.csproj @@ -0,0 +1,41 @@ + + + + + net8.0 + $(BUILDFRAMEWORKS) + + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests/LaunchDarklyIntegrationTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests/LaunchDarklyIntegrationTests.cs new file mode 100644 index 0000000..d61eea6 --- /dev/null +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests/LaunchDarklyIntegrationTests.cs @@ -0,0 +1,533 @@ +using System; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Server; +using Microsoft.Extensions.DependencyInjection; +using OpenFeature; +using OpenFeature.DependencyInjection; +using OpenFeature.Model; +using Xunit; + +namespace LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests +{ + public class LaunchDarklyIntegrationTests + { + private const string TestSdkKey = "test-sdk-key"; + private const string TestDomain = "test-domain"; + private const string TestFlagKey = "test-flag"; + + #region Helper Methods + + /// + /// Configures an with OpenFeature and LaunchDarkly as the default feature provider. + /// + /// + /// Optional delegate to customize the LaunchDarkly during registration. + /// + /// + /// An initialized with LaunchDarkly configured as the default provider. + /// + private static ValueTask ConfigureLaunchDarklyAsync(Action configure = null) + => ConfigureOpenFeatureAsync(builder => builder.UseLaunchDarkly(TestSdkKey, configure)); + + /// + /// Configures an with OpenFeature and LaunchDarkly registered as a domain-scoped feature provider. + /// + /// The domain identifier to associate with the scoped provider (e.g., tenant or environment). + /// + /// Optional delegate to customize the LaunchDarkly for the specified domain. + /// + /// + /// An initialized with domain-scoped LaunchDarkly support. + /// + private static ValueTask ConfigureLaunchDarklyAsync(string domain, Action configure = null) + => ConfigureOpenFeatureAsync(builder => builder.UseLaunchDarkly(domain, TestSdkKey, configure)); + + /// + /// Configures an with OpenFeature and one or more feature providers. + /// + /// + /// Delegate to configure the with feature providers. + /// + /// + /// An initialized with provider lifecycle setup and validation enabled. + /// + private static async ValueTask ConfigureOpenFeatureAsync(Action configureBuilder) + { + var services = new ServiceCollection(); + services.AddLogging(); + + // Register OpenFeature with the configured providers + services.AddOpenFeature(configureBuilder); + + // Build the root service provider with scope validation enabled + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + + // Ensure the feature provider lifecycle is initialized (e.g., LaunchDarkly ready for evaluations) + var lifecycleManager = serviceProvider.GetRequiredService(); + await lifecycleManager.EnsureInitializedAsync(); + + return serviceProvider; + } + + /// + /// Creates a scoped service provider for testing scoped services like IFeatureClient. + /// + /// The root service provider to create a scope from. + /// A scoped service provider. + private static IServiceProvider CreateScopedServiceProvider(IServiceProvider rootServiceProvider) + { + var scopeFactory = rootServiceProvider.GetRequiredService(); + return scopeFactory.CreateScope().ServiceProvider; + } + + #endregion + + #region Configuration Overload Integration Tests + + [Fact] + public async Task ConfigurationOverload_DefaultProvider_ShouldResolveFeatureClientSuccessfully() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(cfg => cfg.Offline(true)); + var scopedProvider = CreateScopedServiceProvider(serviceProvider); + var client = scopedProvider.GetRequiredService(); + + // Act + var result = await client.GetBooleanValueAsync(TestFlagKey, false); + + // Assert + Assert.False(result); // Default value should be returned in offline mode + } + + [Fact] + public async Task ConfigurationOverload_DomainProvider_ShouldResolveKeyedFeatureClientSuccessfully() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(TestDomain, cfg => cfg.Offline(true)); + var scopedProvider = CreateScopedServiceProvider(serviceProvider); + var client = scopedProvider.GetRequiredKeyedService(TestDomain); + + // Act + var result = await client.GetBooleanValueAsync(TestFlagKey, true); + + // Assert + Assert.True(result); // Default value should be returned in offline mode + } + + [Fact] + public async Task ConfigurationOverload_DefaultProvider_ShouldPreserveCustomConfigurationSettings() + { + // Arrange + var startWaitTime = TimeSpan.FromSeconds(5); + var serviceProvider = await ConfigureLaunchDarklyAsync(cfg => cfg + .Offline(true) + .StartWaitTime(startWaitTime)); + + // Act + var registeredConfig = serviceProvider.GetRequiredService(); + + // Assert + Assert.True(registeredConfig.Offline); + Assert.Equal(startWaitTime, registeredConfig.StartWaitTime); + } + + [Fact] + public async Task ConfigurationOverload_DomainProvider_ShouldPreserveCustomConfigurationSettings() + { + // Arrange + var startWaitTime = TimeSpan.FromSeconds(10); + var serviceProvider = await ConfigureLaunchDarklyAsync(TestDomain, cfg => cfg + .Offline(true) + .StartWaitTime(startWaitTime)); + + // Act + var registeredConfig = serviceProvider.GetRequiredKeyedService(TestDomain); + + // Assert + Assert.True(registeredConfig.Offline); + Assert.Equal(startWaitTime, registeredConfig.StartWaitTime); + } + + #endregion + + #region SDK Key Overload Integration Tests + + [Fact] + public async Task SdkKeyOverload_DefaultProvider_ShouldResolveFeatureClientSuccessfully() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(cfg => cfg.Offline(true)); + var scopedProvider = CreateScopedServiceProvider(serviceProvider); + var client = scopedProvider.GetRequiredService(); + + // Act + var result = await client.GetBooleanValueAsync(TestFlagKey, false); + + // Assert + Assert.False(result); // Default value should be returned in offline mode + } + + [Fact] + public async Task SdkKeyOverload_DomainProvider_ShouldResolveKeyedFeatureClientSuccessfully() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(TestDomain, cfg => cfg.Offline(true)); + var scopedProvider = CreateScopedServiceProvider(serviceProvider); + var client = scopedProvider.GetRequiredKeyedService(TestDomain); + + // Act + var result = await client.GetBooleanValueAsync(TestFlagKey, true); + + // Assert + Assert.True(result); // Default value should be returned in offline mode + } + + [Fact] + public async Task SdkKeyOverload_DefaultProvider_ShouldApplyCustomConfigurationFromDelegate() + { + // Arrange + var startWaitTime = TimeSpan.FromSeconds(3); + var serviceProvider = await ConfigureLaunchDarklyAsync(cfg => cfg + .Offline(true) + .StartWaitTime(startWaitTime)); + + // Act + var registeredConfig = serviceProvider.GetRequiredService(); + + // Assert + Assert.True(registeredConfig.Offline); + Assert.Equal(startWaitTime, registeredConfig.StartWaitTime); + } + + [Fact] + public async Task SdkKeyOverload_DomainProvider_ShouldApplyCustomConfigurationFromDelegate() + { + // Arrange + var startWaitTime = TimeSpan.FromSeconds(7); + var serviceProvider = await ConfigureLaunchDarklyAsync(TestDomain, cfg => cfg + .Offline(true) + .StartWaitTime(startWaitTime)); + + // Act + var registeredConfig = serviceProvider.GetRequiredKeyedService(TestDomain); + + // Assert + Assert.True(registeredConfig.Offline); + Assert.Equal(startWaitTime, registeredConfig.StartWaitTime); + } + + #endregion + + #region Multi-Provider Setup Integration Tests + + [Fact] + public async Task MultiProvider_MixedOverloads_ShouldRegisterBothDefaultAndDomainProvidersCorrectly() + { + // Arrange + var serviceProvider = await ConfigureOpenFeatureAsync(builder => + { + // Register both default and domain-scoped providers using different overloads + builder + .UseLaunchDarkly("domain1", TestSdkKey, cfg => cfg.Offline(true)) + .UseLaunchDarkly("domain2", TestSdkKey, cfg => cfg.Offline(true)) + .AddPolicyName(policy => + { + policy.DefaultNameSelector = _ => "domain1"; + }); + }); + + var scopedProvider = CreateScopedServiceProvider(serviceProvider); + + // Act + var defaultClient = scopedProvider.GetRequiredService(); + var domain1Client = scopedProvider.GetRequiredKeyedService("domain1"); + var domain2Client = scopedProvider.GetRequiredKeyedService("domain2"); + + var defaultConfig = serviceProvider.GetRequiredService(); + var domain1Config = serviceProvider.GetRequiredKeyedService("domain1"); + var domain2Config = serviceProvider.GetRequiredKeyedService("domain2"); + + // Assert + Assert.Same(defaultClient, domain1Client); + Assert.NotSame(domain1Client, domain2Client); + Assert.NotSame(domain1Config, domain2Config); + + Assert.True(domain1Config.Offline, "Expected 'domain1' LaunchDarkly config to be in offline mode."); + Assert.True(domain2Config.Offline, "Expected 'domain2' LaunchDarkly config to be in offline mode."); + } + + [Fact] + public async Task MultiProvider_MultipleDomains_ShouldIsolateConfigurationsCorrectly() + { + // Arrange + const string fastDomain = "fast-domain"; + const string slowDomain = "slow-domain"; + var fastStartWait = TimeSpan.FromMilliseconds(100); + var slowStartWait = TimeSpan.FromSeconds(5); + + var serviceProvider = await ConfigureOpenFeatureAsync(builder => + { + // Register using different configurations for variety + builder.UseLaunchDarkly(fastDomain, TestSdkKey, cfg => cfg + .Offline(true) + .StartWaitTime(fastStartWait)); + + builder.UseLaunchDarkly(slowDomain, TestSdkKey, cfg => cfg + .Offline(true) + .StartWaitTime(slowStartWait)); + + builder.AddPolicyName(policy => policy.DefaultNameSelector = _ => fastDomain); + }); + + // Act & Assert + var config1 = serviceProvider.GetRequiredKeyedService(fastDomain); + var config2 = serviceProvider.GetRequiredKeyedService(slowDomain); + + Assert.NotSame(config1, config2); + Assert.Equal(fastStartWait, config1.StartWaitTime); + Assert.Equal(slowStartWait, config2.StartWaitTime); + Assert.True(config1.Offline); + Assert.True(config2.Offline); + } + + #endregion + + #region OpenFeature Value Type Support Tests + + [Fact] + public async Task AllOverloads_FeatureProviders_ShouldSupportAllOpenFeatureValueTypes() + { + // Arrange + var serviceProvider = await ConfigureOpenFeatureAsync(builder => + { + // Test all SDK key overloads + builder.UseLaunchDarkly("domain1", TestSdkKey, cfg => cfg.Offline(true)); // Default provider + builder.UseLaunchDarkly("domain2", TestSdkKey, cfg => cfg.Offline(true)); // Domain provider + builder.UseLaunchDarkly("domain3", TestSdkKey, cfg => cfg.Offline(true)); // Domain provider + builder.AddPolicyName(policy => policy.DefaultNameSelector = _ => "domain1"); + }); + + var scopedProvider = CreateScopedServiceProvider(serviceProvider); + + var defaultClient = scopedProvider.GetRequiredService(); + var domain1Client = scopedProvider.GetRequiredKeyedService("domain1"); + var domain2Client = scopedProvider.GetRequiredKeyedService("domain2"); + var domain3Client = scopedProvider.GetRequiredKeyedService("domain3"); + + // Act & Assert - Test all supported types on all providers + foreach (var client in new[] { defaultClient, domain1Client, domain2Client, domain3Client }) + { + var boolResult = await client.GetBooleanValueAsync("bool-flag", true); + Assert.True(boolResult); + + var stringResult = await client.GetStringValueAsync("string-flag", "default"); + Assert.Equal("default", stringResult); + + var intResult = await client.GetIntegerValueAsync("int-flag", 42); + Assert.Equal(42, intResult); + + var doubleResult = await client.GetDoubleValueAsync("double-flag", 3.14); + Assert.Equal(3.14, doubleResult); + + var structureResult = await client.GetObjectValueAsync("object-flag", new Value("default")); + Assert.Equal("default", structureResult.AsString); + } + } + + #endregion + + #region OpenFeature Behavior Integration Tests + + [Fact] + public async Task DefaultProvider_InOfflineMode_ShouldReturnCorrectReasonAndDefaultValue() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(cfg => cfg.Offline(true)); + var scopedProvider = CreateScopedServiceProvider(serviceProvider); + var client = scopedProvider.GetRequiredService(); + + // Act + var result = await client.GetBooleanDetailsAsync(TestFlagKey, false); + + // Assert + Assert.False(result.Value); + } + + [Fact] + public async Task DefaultProvider_WithEvaluationContext_ShouldHandleContextCorrectly() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(cfg => cfg.Offline(true)); + var scopedProvider = CreateScopedServiceProvider(serviceProvider); + var client = scopedProvider.GetRequiredService(); + + var context = EvaluationContext.Builder() + .Set("userId", "test-user") + .Set("email", "test@example.com") + .Build(); + + // Act + var result = await client.GetBooleanDetailsAsync(TestFlagKey, false, context); + + // Assert + Assert.False(result.Value); + //Assert.Equal(Reason.Default, result.Reason); + } + + #endregion + + #region Service Lifetime Integration Tests + + [Fact] + public async Task DefaultProvider_Configuration_ShouldBeRegisteredAsSingleton() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(cfg => cfg.Offline(true)); + + // Act + var config1 = serviceProvider.GetRequiredService(); + var config2 = serviceProvider.GetRequiredService(); + + // Assert + Assert.Same(config1, config2); + } + + [Fact] + public async Task DomainProvider_Configuration_ShouldBeRegisteredAsKeyedSingleton() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(TestDomain, cfg => cfg.Offline(true)); + + // Act + var config1 = serviceProvider.GetRequiredKeyedService(TestDomain); + var config2 = serviceProvider.GetRequiredKeyedService(TestDomain); + + // Assert + Assert.Same(config1, config2); + } + + [Fact] + public async Task TryAddSingleton_MultipleRegistrations_ShouldNotReplaceExistingRegistration() + { + // Arrange & Act + var serviceProvider = await ConfigureOpenFeatureAsync(builder => + { + // First registration should win due to TryAddSingleton behavior + builder.UseLaunchDarkly(TestSdkKey, cfg => cfg.Offline(true)); + builder.UseLaunchDarkly(TestSdkKey, cfg => cfg.Offline(false)); // Should not replace the first + }); + + // Assert + var registeredConfig = serviceProvider.GetRequiredService(); + Assert.True(registeredConfig.Offline); // Should still be the first configuration + } + + [Fact] + public async Task TryAddKeyedSingleton_MultipleRegistrations_ShouldNotReplaceExistingRegistration() + { + // Arrange + var serviceProvider = await ConfigureOpenFeatureAsync(builder => + { + // First registration should win due to TryAddKeyedSingleton behavior + builder.UseLaunchDarkly(TestDomain, TestSdkKey, cfg => cfg.Offline(true)); + builder.UseLaunchDarkly(TestDomain, TestSdkKey, cfg => cfg.Offline(false)); // Should not replace the first + builder.AddPolicyName(policy => policy.DefaultNameSelector = _ => TestDomain); + }); + + // Act + var registeredConfig = serviceProvider.GetRequiredKeyedService(TestDomain); + + // Assert + Assert.True(registeredConfig.Offline); // Should still be the first configuration + } + + #endregion + + #region Resource Management Integration Tests + + [Fact] + public async Task ServiceProviderDisposal_AfterUsingProviders_ShouldNotCauseMemoryLeaks() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(cfg => cfg.Offline(true)); + + // Act + var config = serviceProvider.GetRequiredService(); + + // Assert + Assert.NotNull(config); + Assert.True(config.Offline); + } + + [Fact] + public async Task MultipleServiceProviders_WithSameConfiguration_ShouldIsolateConfigurations() + { + // Arrange + var serviceProvider1 = await ConfigureLaunchDarklyAsync(cfg => cfg.Offline(true)); + var serviceProvider2 = await ConfigureLaunchDarklyAsync(cfg => cfg.Offline(false)); + + // Act + var config1 = serviceProvider1.GetRequiredService(); + var config2 = serviceProvider2.GetRequiredService(); + + // Assert + Assert.NotSame(config1, config2); + Assert.True(config1.Offline); + Assert.False(config2.Offline); + } + + #endregion + + #region Early Validation Integration Tests + + [Fact] + public async Task EarlyValidation_WithValidConfiguration_ShouldPassValidationAndRegisterCorrectly() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(cfg => cfg.Offline(true)); + + // Act + var registeredConfig = serviceProvider.GetRequiredService(); + + // Assert + Assert.NotNull(registeredConfig); + Assert.True(registeredConfig.Offline); + } + + [Fact] + public async Task EarlyValidation_WithValidDomainConfiguration_ShouldPassValidationAndRegisterCorrectly() + { + // Arrange + var serviceProvider = await ConfigureLaunchDarklyAsync(TestDomain, cfg => cfg.Offline(true)); + + // Act + var registeredConfig = serviceProvider.GetRequiredKeyedService(TestDomain); + + // Assert + Assert.NotNull(registeredConfig); + Assert.True(registeredConfig.Offline); + } + + [Fact] + public void EarlyValidation_WithPrebuiltConfiguration_ShouldPassValidationAndRegisterCorrectly() + { + // Arrange + var services = new ServiceCollection(); + var config = Configuration.Builder(TestSdkKey).Offline(true).Build(); + + services.AddOpenFeature(builder => + { + builder.UseLaunchDarkly(config); + }); + + // Act + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + + // Assert + var registeredConfig = serviceProvider.GetRequiredService(); + Assert.NotNull(registeredConfig); + } + + #endregion + } +} \ No newline at end of file diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests/LaunchDarklyOpenFeatureBuilderExtensionsTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests/LaunchDarklyOpenFeatureBuilderExtensionsTests.cs new file mode 100644 index 0000000..fa9249a --- /dev/null +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests/LaunchDarklyOpenFeatureBuilderExtensionsTests.cs @@ -0,0 +1,538 @@ +using System; +using LaunchDarkly.Sdk.Server; +using Microsoft.Extensions.DependencyInjection; +using OpenFeature.DependencyInjection; +using Xunit; + +namespace LaunchDarkly.OpenFeature.ServerProvider.DependencyInjection.Tests +{ + public class LaunchDarklyOpenFeatureBuilderExtensionsTests + { + private const string TestSdkKey = "test-sdk-key"; + private const string TestDomain = "test-domain"; + + #region UseLaunchDarkly(Configuration) - Default Provider Tests + + [Fact] + public void UseLaunchDarklyWithConfiguration_WhenCalled_ShouldReturnSameBuilderInstance() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var config = Configuration.Builder(TestSdkKey).Offline(true).Build(); + + // Act + var result = builder.UseLaunchDarkly(config); + + // Assert + Assert.Same(builder, result); + } + + [Fact] + public void UseLaunchDarklyWithConfiguration_WhenCalled_ShouldRegisterConfigurationAsSingleton() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var config = Configuration.Builder(TestSdkKey).Offline(true).Build(); + + // Act + builder.UseLaunchDarkly(config); + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var registeredConfig = serviceProvider.GetRequiredService(); + Assert.NotNull(registeredConfig); + Assert.True(registeredConfig.Offline); + } + + [Fact] + public void UseLaunchDarklyWithConfiguration_WhenCalledMultipleTimes_ShouldShareSameConfigurationInstance() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var config = Configuration.Builder(TestSdkKey).Offline(true).Build(); + + // Act + builder.UseLaunchDarkly(config); + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var config1 = serviceProvider.GetRequiredService(); + var config2 = serviceProvider.GetRequiredService(); + Assert.Same(config1, config2); + } + + [Fact] + public void UseLaunchDarklyWithConfiguration_WhenCalledMultipleTimesWithDifferentConfigs_ShouldUseFirstConfiguration() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var config1 = Configuration.Builder(TestSdkKey).Offline(true).Build(); + var config2 = Configuration.Builder(TestSdkKey).Offline(false).Build(); + + // Act + builder.UseLaunchDarkly(config1); + builder.UseLaunchDarkly(config2); // Should not replace the first + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var registeredConfig = serviceProvider.GetRequiredService(); + Assert.True(registeredConfig.Offline); // Should still be the first configuration + } + + #endregion + + #region UseLaunchDarkly(domain, Configuration) - Domain Provider Tests + + [Fact] + public void UseLaunchDarklyWithDomainAndConfiguration_WhenCalled_ShouldReturnSameBuilderInstance() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var config = Configuration.Builder(TestSdkKey).Offline(true).Build(); + + // Act + var result = builder.UseLaunchDarkly(TestDomain, config); + + // Assert + Assert.Same(builder, result); + } + + [Fact] + public void UseLaunchDarklyWithDomainAndConfiguration_WhenCalled_ShouldRegisterConfigurationAsKeyedSingleton() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var config = Configuration.Builder(TestSdkKey).Offline(true).Build(); + + // Act + builder.UseLaunchDarkly(TestDomain, config); + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var registeredConfig = serviceProvider.GetRequiredKeyedService(TestDomain); + Assert.NotNull(registeredConfig); + Assert.True(registeredConfig.Offline); + } + + [Fact] + public void UseLaunchDarklyWithDomainAndConfiguration_WhenCalledMultipleTimes_ShouldShareSameConfigurationInstance() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var config = Configuration.Builder(TestSdkKey).Offline(true).Build(); + + // Act + builder.UseLaunchDarkly(TestDomain, config); + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var config1 = serviceProvider.GetRequiredKeyedService(TestDomain); + var config2 = serviceProvider.GetRequiredKeyedService(TestDomain); + Assert.Same(config1, config2); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UseLaunchDarklyWithDomainAndConfiguration_WhenDomainIsNullOrWhitespace_ShouldReturnBuilder(string domain) + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var config = Configuration.Builder(TestSdkKey).Build(); + + // Act + var result = builder.UseLaunchDarkly(domain, config); + + // Assert + Assert.Same(builder, result); + } + + [Fact] + public void UseLaunchDarklyWithDomainAndConfiguration_WhenDifferentDomainsUsed_ShouldRegisterSeparateConfigurations() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var config1 = Configuration.Builder(TestSdkKey).Offline(true).Build(); + var config2 = Configuration.Builder(TestSdkKey).Offline(false).Build(); + const string domain1 = "domain1"; + const string domain2 = "domain2"; + + // Act + builder.UseLaunchDarkly(domain1, config1); + builder.UseLaunchDarkly(domain2, config2); + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var registeredConfig1 = serviceProvider.GetRequiredKeyedService(domain1); + var registeredConfig2 = serviceProvider.GetRequiredKeyedService(domain2); + Assert.NotSame(registeredConfig1, registeredConfig2); + Assert.True(registeredConfig1.Offline); + Assert.False(registeredConfig2.Offline); + } + + #endregion + + #region UseLaunchDarkly(sdkKey) - SDK Key Default Provider Tests + + [Fact] + public void UseLaunchDarklyWithSdkKey_WhenCalled_ShouldReturnSameBuilderInstance() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + var result = builder.UseLaunchDarkly(TestSdkKey); + + // Assert + Assert.Same(builder, result); + } + + [Fact] + public void UseLaunchDarklyWithSdkKey_WhenCalled_ShouldRegisterValidConfiguration() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.UseLaunchDarkly(TestSdkKey); + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var config = serviceProvider.GetRequiredService(); + Assert.NotNull(config); + } + + [Fact] + public void UseLaunchDarklyWithSdkKeyAndDelegate_WhenCalled_ShouldApplyCustomConfiguration() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var configureWasCalled = false; + ConfigurationBuilder capturedBuilder = null; + + // Act + var result = builder.UseLaunchDarkly(TestSdkKey, configBuilder => + { + configureWasCalled = true; + capturedBuilder = configBuilder; + configBuilder.Offline(true); + }); + + // Assert + Assert.Same(builder, result); + + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var config = serviceProvider.GetRequiredService(); + + Assert.True(configureWasCalled); + Assert.NotNull(capturedBuilder); + Assert.True(config.Offline); + } + + [Fact] + public void UseLaunchDarklyWithSdkKeyAndDelegate_WhenConfigurationDelegateThrows_ShouldPropagateExceptionImmediately() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var expectedException = new InvalidOperationException("Test exception"); + + // Act & Assert + // The exception should be thrown immediately during registration due to early validation + var actualException = Assert.Throws(() => + builder.UseLaunchDarkly(TestSdkKey, _ => throw expectedException)); + Assert.Same(expectedException, actualException); + } + + #endregion + + #region UseLaunchDarkly(domain, sdkKey) - SDK Key Domain Provider Tests + + [Fact] + public void UseLaunchDarklyWithDomainAndSdkKey_WhenCalled_ShouldReturnSameBuilderInstance() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + var result = builder.UseLaunchDarkly(TestDomain, TestSdkKey); + + // Assert + Assert.Same(builder, result); + } + + [Fact] + public void UseLaunchDarklyWithDomainAndSdkKey_WhenCalled_ShouldRegisterValidKeyedConfiguration() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.UseLaunchDarkly(TestDomain, TestSdkKey); + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var config = serviceProvider.GetRequiredKeyedService(TestDomain); + Assert.NotNull(config); + } + + [Fact] + public void UseLaunchDarklyWithDomainAndSdkKeyAndDelegate_WhenCalled_ShouldApplyCustomConfiguration() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var configureWasCalled = false; + + // Act + var result = builder.UseLaunchDarkly(TestDomain, TestSdkKey, configBuilder => + { + configureWasCalled = true; + configBuilder.Offline(true); + }); + + // Assert + Assert.Same(builder, result); + + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var config = serviceProvider.GetRequiredKeyedService(TestDomain); + + Assert.True(configureWasCalled); + Assert.True(config.Offline); + } + + [Fact] + public void UseLaunchDarklyWithDomainAndSdkKeyAndDelegate_WhenConfigurationDelegateThrows_ShouldPropagateExceptionImmediately() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var expectedException = new InvalidOperationException("Test exception"); + + // Act + InvalidOperationException registerWithThrowing() => Assert.Throws(() => + builder.UseLaunchDarkly(TestDomain, TestSdkKey, _ => throw expectedException)); + + // Assert + // The exception should be thrown immediately during registration due to early validation + var actualException = registerWithThrowing(); + Assert.Same(expectedException, actualException); + } + + [Fact] + public void UseLaunchDarklyWithDomainAndSdkKeyAndDelegate_WhenNullConfigurationDelegate_ShouldNotThrowAndReturnBuilder() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act & Assert - should not throw + var result = builder.UseLaunchDarkly(TestDomain, TestSdkKey, null); + + Assert.Same(builder, result); + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var config = serviceProvider.GetRequiredKeyedService(TestDomain); + Assert.NotNull(config); + } + + [Fact] + public void UseLaunchDarklyWithSdkKeyAndNullDelegate_WhenNullConfigurationPassed_ShouldThrowArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + Configuration configuration = null; + + // Assert + Assert.Throws(() => builder.UseLaunchDarkly(TestSdkKey, configuration)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UseLaunchDarklyWithDomainAndSdkKey_WhenSdkKeyIsNullOrWhitespace_ShouldReturnBuilder(string sdkKey) + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + var result = builder.UseLaunchDarkly(TestDomain, sdkKey); + + // Assert + Assert.Same(builder, result); + } + + #endregion + + #region Null Builder Validation Tests + + [Fact] + public void UseLaunchDarklyWithNullBuilder_WhenSdkKeyOverloadUsed_ShouldThrowNullReferenceException() + { + // Arrange + OpenFeatureBuilder builder = null; + + // Act & Assert + Assert.Throws(() => builder.UseLaunchDarkly(TestSdkKey)); + } + + [Fact] + public void UseLaunchDarklyWithNullBuilder_WhenConfigurationOverloadUsed_ShouldThrowNullReferenceException() + { + // Arrange + OpenFeatureBuilder builder = null; + var config = Configuration.Builder(TestSdkKey).Build(); + + // Act & Assert + Assert.Throws(() => builder.UseLaunchDarkly(config)); + } + + [Fact] + public void UseLaunchDarklyWithNullBuilder_WhenDomainSdkKeyOverloadUsed_ShouldThrowNullReferenceException() + { + // Arrange + OpenFeatureBuilder builder = null; + + // Act & Assert + Assert.Throws(() => builder.UseLaunchDarkly(TestDomain, TestSdkKey)); + } + + [Fact] + public void UseLaunchDarklyWithNullBuilder_WhenDomainConfigurationOverloadUsed_ShouldThrowNullReferenceException() + { + // Arrange + OpenFeatureBuilder builder = null; + var config = Configuration.Builder(TestSdkKey).Build(); + + // Act & Assert + Assert.Throws(() => builder.UseLaunchDarkly(TestDomain, config)); + } + + #endregion + + #region Configuration Property Preservation Tests + + [Fact] + public void UseLaunchDarklyWithConfiguration_WhenCustomPropertiesSet_ShouldPreserveConfigurationProperties() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var originalConfig = Configuration.Builder(TestSdkKey).Offline(true).Build(); + + // Act + builder.UseLaunchDarkly(originalConfig); + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var registeredConfig = serviceProvider.GetRequiredService(); + + // The registered config should be a rebuilt version, not the same instance + // but should have the same properties + Assert.True(registeredConfig.Offline); + } + + [Fact] + public void UseLaunchDarklyWithSdkKeyAndDelegate_WhenCustomPropertiesSet_ShouldPreserveConfigurationProperties() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var startWaitTime = TimeSpan.FromSeconds(5); + + // Act + builder.UseLaunchDarkly(TestSdkKey, config => + { + config.Offline(true); + config.StartWaitTime(startWaitTime); + }); + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var registeredConfig = serviceProvider.GetRequiredService(); + Assert.True(registeredConfig.Offline); + Assert.Equal(startWaitTime, registeredConfig.StartWaitTime); + } + + [Fact] + public void UseLaunchDarklyWithDomainAndSdkKeyAndDelegate_WhenCustomPropertiesSet_ShouldPreserveConfigurationProperties() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + var startWaitTime = TimeSpan.FromSeconds(10); + + // Act + builder.UseLaunchDarkly(TestDomain, TestSdkKey, config => + { + config.Offline(true); + config.StartWaitTime(startWaitTime); + }); + + // Assert + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var registeredConfig = serviceProvider.GetRequiredKeyedService(TestDomain); + Assert.True(registeredConfig.Offline); + Assert.Equal(startWaitTime, registeredConfig.StartWaitTime); + } + + #endregion + + #region Early Validation Behavior Tests + + [Fact] + public void UseLaunchDarklyWithSdkKeyAndDelegate_WhenEarlyValidationPasses_ShouldPreventRuntimeFailures() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act - Early validation should catch configuration issues immediately + builder.UseLaunchDarkly(TestSdkKey, cfg => cfg.Offline(true)); + + // Assert - If we reach here, early validation passed + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var config = serviceProvider.GetRequiredService(); + Assert.NotNull(config); + Assert.True(config.Offline); + } + + [Fact] + public void UseLaunchDarklyWithDomainAndSdkKeyAndDelegate_WhenEarlyValidationPasses_ShouldPreventRuntimeFailures() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act - Early validation should catch configuration issues immediately + builder.UseLaunchDarkly(TestDomain, TestSdkKey, cfg => cfg.Offline(true)); + + // Assert - If we reach here, early validation passed + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + var config = serviceProvider.GetRequiredKeyedService(TestDomain); + Assert.NotNull(config); + Assert.True(config.Offline); + } + + #endregion + } +}