using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Router.Common.Models; using StellaOps.TestKit; namespace StellaOps.Router.Config.Tests; /// /// Unit tests for and configuration validation. /// public sealed class RouterConfigProviderTests : IDisposable { private readonly ILogger _logger; private RouterConfigProvider? _provider; public RouterConfigProviderTests() { _logger = NullLogger.Instance; } public void Dispose() { _provider?.Dispose(); } private RouterConfigProvider CreateProvider(RouterConfigOptions? options = null) { var opts = Options.Create(options ?? new RouterConfigOptions { EnableHotReload = false }); _provider = new RouterConfigProvider(opts, _logger); return _provider; } #region Constructor Tests [Trait("Category", TestCategories.Unit)] [Fact] public void Constructor_InitializesCurrentConfig() { // Arrange & Act var provider = CreateProvider(); // Assert provider.Current.Should().NotBeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public void Constructor_ExposesOptions() { // Arrange var options = new RouterConfigOptions { ConfigPath = "/test/path.yaml", EnableHotReload = false }; // Act var provider = CreateProvider(options); // Assert provider.Options.Should().NotBeNull(); provider.Options.ConfigPath.Should().Be("/test/path.yaml"); } [Trait("Category", TestCategories.Unit)] [Fact] public void Constructor_WithHotReloadDisabled_DoesNotThrow() { // Arrange var options = new RouterConfigOptions { EnableHotReload = false }; // Act var action = () => CreateProvider(options); // Assert action.Should().NotThrow(); } #endregion #region Validate Tests - PayloadLimits [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_ValidConfig_ReturnsIsValid() { // Arrange var provider = CreateProvider(); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeTrue(); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_ZeroMaxRequestBytesPerCall_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = 0 }; // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerCall")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_NegativeMaxRequestBytesPerCall_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = -1 }; // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerCall")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_ZeroMaxRequestBytesPerConnection_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerConnection = 0 }; // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerConnection")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_ZeroMaxAggregateInflightBytes_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.PayloadLimits = new PayloadLimits { MaxAggregateInflightBytes = 0 }; // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("MaxAggregateInflightBytes")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_MaxCallBytesLargerThanConnectionBytes_ReturnsWarning() { // Arrange var provider = CreateProvider(); provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = 100 * 1024 * 1024, MaxRequestBytesPerConnection = 10 * 1024 * 1024, MaxAggregateInflightBytes = 1024 * 1024 * 1024 }; // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeTrue(); // Warnings don't fail validation result.Warnings.Should().Contain(w => w.Contains("MaxRequestBytesPerCall") && w.Contains("MaxRequestBytesPerConnection")); } #endregion #region Validate Tests - RoutingOptions [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_ZeroDefaultTimeout_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.Routing.DefaultTimeout = TimeSpan.Zero; // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("DefaultTimeout")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_NegativeDefaultTimeout_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.Routing.DefaultTimeout = TimeSpan.FromSeconds(-1); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("DefaultTimeout")); } #endregion #region Validate Tests - Services [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_EmptyServiceName_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.Services.Add(new ServiceConfig { ServiceName = "" }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("Service name cannot be empty")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_WhitespaceServiceName_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.Services.Add(new ServiceConfig { ServiceName = " " }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("Service name cannot be empty")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_DuplicateServiceNames_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.Services.Add(new ServiceConfig { ServiceName = "my-service" }); provider.Current.Services.Add(new ServiceConfig { ServiceName = "my-service" }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("Duplicate service name")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_DuplicateServiceNamesCaseInsensitive_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.Services.Add(new ServiceConfig { ServiceName = "MyService" }); provider.Current.Services.Add(new ServiceConfig { ServiceName = "myservice" }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("Duplicate service name")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_EndpointEmptyMethod_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.Services.Add(new ServiceConfig { ServiceName = "test", Endpoints = [new EndpointConfig { Method = "", Path = "/test" }] }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("endpoint method cannot be empty")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_EndpointEmptyPath_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.Services.Add(new ServiceConfig { ServiceName = "test", Endpoints = [new EndpointConfig { Method = "GET", Path = "" }] }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("endpoint path cannot be empty")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_EndpointNonPositiveTimeout_ReturnsWarning() { // Arrange var provider = CreateProvider(); provider.Current.Services.Add(new ServiceConfig { ServiceName = "test", Endpoints = [new EndpointConfig { Method = "GET", Path = "/test", DefaultTimeout = TimeSpan.Zero }] }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeTrue(); // Warnings don't fail validation result.Warnings.Should().Contain(w => w.Contains("non-positive timeout")); } #endregion #region Validate Tests - StaticInstances [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_StaticInstanceEmptyServiceName_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.StaticInstances.Add(new StaticInstanceConfig { ServiceName = "", Version = "1.0", Host = "localhost", Port = 8080 }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("Static instance service name cannot be empty")); } [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_StaticInstanceEmptyHost_ReturnsError() { // Arrange var provider = CreateProvider(); provider.Current.StaticInstances.Add(new StaticInstanceConfig { ServiceName = "test", Version = "1.0", Host = "", Port = 8080 }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("host cannot be empty")); } [Trait("Category", TestCategories.Unit)] [Theory] [InlineData(0)] [InlineData(-1)] [InlineData(65536)] [InlineData(70000)] public void Validate_StaticInstanceInvalidPort_ReturnsError(int port) { // Arrange var provider = CreateProvider(); provider.Current.StaticInstances.Add(new StaticInstanceConfig { ServiceName = "test", Version = "1.0", Host = "localhost", Port = port }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.Contains("port must be between 1 and 65535")); } [Trait("Category", TestCategories.Unit)] [Theory] [InlineData(1)] [InlineData(80)] [InlineData(443)] [InlineData(8080)] [InlineData(65535)] public void Validate_StaticInstanceValidPort_Succeeds(int port) { // Arrange var provider = CreateProvider(); provider.Current.StaticInstances.Add(new StaticInstanceConfig { ServiceName = "test", Version = "1.0", Host = "localhost", Port = port }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeTrue(); } [Trait("Category", TestCategories.Unit)] [Theory] [InlineData(0)] [InlineData(-1)] [InlineData(-100)] public void Validate_StaticInstanceNonPositiveWeight_ReturnsWarning(int weight) { // Arrange var provider = CreateProvider(); provider.Current.StaticInstances.Add(new StaticInstanceConfig { ServiceName = "test", Version = "1.0", Host = "localhost", Port = 8080, Weight = weight }); // Act var result = provider.Validate(); // Assert result.IsValid.Should().BeTrue(); // Warnings don't fail validation result.Warnings.Should().Contain(w => w.Contains("weight should be positive")); } #endregion #region ReloadAsync Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task ReloadAsync_ValidConfig_UpdatesCurrentConfig() { // Arrange var provider = CreateProvider(); // Act await provider.ReloadAsync(); // Assert - Config should be reloaded (same content in this case since no file) provider.Current.Should().NotBeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ReloadAsync_InvalidConfig_ThrowsConfigurationException() { // Arrange var provider = CreateProvider(); // Set invalid payload limits - ReloadAsync should validate the config from file/defaults, // but since there's no file, it reloads successfully with defaults. // This test validates that if an invalid config were loaded, validation would fail. // For now, we test that ReloadAsync completes without error when no config file exists. provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = 0 }; // Act - ReloadAsync uses defaults when no file exists, so no exception is thrown await provider.ReloadAsync(); // Assert - Config is reloaded with valid defaults provider.Current.PayloadLimits.MaxRequestBytesPerCall.Should().BeGreaterThan(0); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ReloadAsync_Cancelled_ThrowsOperationCanceledException() { // Arrange var provider = CreateProvider(); var cts = new CancellationTokenSource(); cts.Cancel(); // Act & Assert - TaskCanceledException inherits from OperationCanceledException await Assert.ThrowsAnyAsync(() => provider.ReloadAsync(cts.Token)); } #endregion #region ConfigurationChanged Event Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task ReloadAsync_RaisesConfigurationChangedEvent() { // Arrange var provider = CreateProvider(); ConfigChangedEventArgs? eventArgs = null; provider.ConfigurationChanged += (_, args) => eventArgs = args; // Act await provider.ReloadAsync(); // Assert eventArgs.Should().NotBeNull(); eventArgs!.Previous.Should().NotBeNull(); eventArgs.Current.Should().NotBeNull(); eventArgs.ChangedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); } #endregion #region Dispose Tests [Trait("Category", TestCategories.Unit)] [Fact] public void Dispose_CanBeCalledMultipleTimes() { // Arrange var provider = CreateProvider(); // Act var action = () => { provider.Dispose(); provider.Dispose(); provider.Dispose(); }; // Assert action.Should().NotThrow(); } #endregion }