From fd3903d97286fe5967c782c290647f645933c470 Mon Sep 17 00:00:00 2001 From: Jake Carpenter Date: Sat, 18 Apr 2026 21:29:50 -0600 Subject: [PATCH] fix: proper coalescing of config source Issue: #43 --- Optify.Tests/DummySettings.cs | 13 ++++ Optify.Tests/TestData.cs | 12 ++++ Optify.Tests/UseOptifyTSectionNameTests.cs | 70 ++++++++++++++++++++++ Optify.Tests/UseOptifyTValidationTests.cs | 54 +++++++++++++++++ Optify/HostBuilderExtensions.cs | 26 ++++---- 5 files changed, 161 insertions(+), 14 deletions(-) diff --git a/Optify.Tests/DummySettings.cs b/Optify.Tests/DummySettings.cs index a7697ac..6df7c8a 100644 --- a/Optify.Tests/DummySettings.cs +++ b/Optify.Tests/DummySettings.cs @@ -37,6 +37,12 @@ public class UnmarkedDummyClassSettings public string? X { get; init; } } +[OptifyOptions(SectionName = "")] +public class EmptySectionNameDummyClassSettings +{ + public string? X { get; init; } +} + [OptifyOptions] public class DummySettingsWithRequiredKeyword { @@ -63,3 +69,10 @@ public class AttrValidatedOnStartDummySettings [Required] public string? X { get; init; } } + +[OptifyOptions(Validation = ValidationFlag.OnStart)] +public class AttrOnStartOnlyDummySettings +{ + [Required] + public string? X { get; init; } +} diff --git a/Optify.Tests/TestData.cs b/Optify.Tests/TestData.cs index be466ee..64668ff 100644 --- a/Optify.Tests/TestData.cs +++ b/Optify.Tests/TestData.cs @@ -11,6 +11,16 @@ public static IEnumerable GenericStringTestData() yield return "two"; } + /// + /// Test data for null or whitespace strings. + /// + public static IEnumerable NullOrWhitespaceTestData() + { + yield return null; + yield return ""; + yield return " "; + } + /// /// A valid entry for all properties on all dummy settings types in the testing project. /// Tests should start with this and override what is needed within the test. @@ -23,9 +33,11 @@ public static IEnumerable GenericStringTestData() new("OverrideNamedDummyClassSettings:X", "valid"), new("OverrideNamedDummyRecordSettings:X", "valid"), new($"{nameof(UnmarkedDummyClassSettings)}:X", "valid"), + new($"{nameof(EmptySectionNameDummyClassSettings)}:X", "valid"), new($"{nameof(DummySettingsWithRequiredKeyword)}:X", "valid"), new($"{nameof(ValidatedDummySettings)}:X", "valid"), new($"{nameof(AttrValidatedDummySettings)}:X", "valid"), new($"{nameof(AttrValidatedOnStartDummySettings)}:X", "valid"), + new($"{nameof(AttrOnStartOnlyDummySettings)}:X", "valid"), ]; } diff --git a/Optify.Tests/UseOptifyTSectionNameTests.cs b/Optify.Tests/UseOptifyTSectionNameTests.cs index b29793e..92bb891 100644 --- a/Optify.Tests/UseOptifyTSectionNameTests.cs +++ b/Optify.Tests/UseOptifyTSectionNameTests.cs @@ -53,4 +53,74 @@ public async Task Registers_section_name_from_type_name_as_third_priority() await Assert.That(options.Value.X).IsEqualTo("came-from-type-name"); } + + [Test] + public async Task Registers_section_name_from_type_name_when_no_attribute_present() + { + var host = new HostBuilder() + .IncludeConfiguration([ + .. TestData.AllTestSettings, + new("UnmarkedDummyClassSettings:X", "came-from-type-name"), + ]) + .UseOptify(new OptifyConfiguration()) + .Build(); + + var options = host.Services.GetRequiredService>(); + + await Assert.That(options.Value.X).IsEqualTo("came-from-type-name"); + } + + [Test] + [MethodDataSource(nameof(TestData.NullOrWhitespaceTestData))] + public async Task Registers_section_name_from_type_name_when_configuration_section_name_is_null_or_whitespace( + string? sectionName + ) + { + var host = new HostBuilder() + .IncludeConfiguration([ + .. TestData.AllTestSettings, + new("UnmarkedDummyClassSettings:X", "came-from-type-name"), + ]) + .UseOptify(new OptifyConfiguration { SectionName = sectionName }) + .Build(); + + var options = host.Services.GetRequiredService>(); + + await Assert.That(options.Value.X).IsEqualTo("came-from-type-name"); + } + + [Test] + [MethodDataSource(nameof(TestData.NullOrWhitespaceTestData))] + public async Task Registers_section_name_from_type_name_when_both_configuration_and_attribute_section_names_are_null_or_whitespace( + string? sectionName + ) + { + var host = new HostBuilder() + .IncludeConfiguration([ + .. TestData.AllTestSettings, + new("EmptySectionNameDummyClassSettings:X", "came-from-type-name"), + ]) + .UseOptify(new OptifyConfiguration { SectionName = sectionName }) + .Build(); + + var options = host.Services.GetRequiredService>(); + + await Assert.That(options.Value.X).IsEqualTo("came-from-type-name"); + } + + [Test] + public async Task Registers_section_name_from_type_name_when_configuration_is_null_and_no_attribute() + { + var host = new HostBuilder() + .IncludeConfiguration([ + .. TestData.AllTestSettings, + new("UnmarkedDummyClassSettings:X", "came-from-type-name"), + ]) + .UseOptify(null!) + .Build(); + + var options = host.Services.GetRequiredService>(); + + await Assert.That(options.Value.X).IsEqualTo("came-from-type-name"); + } } diff --git a/Optify.Tests/UseOptifyTValidationTests.cs b/Optify.Tests/UseOptifyTValidationTests.cs index 56f9739..b0d9fd2 100644 --- a/Optify.Tests/UseOptifyTValidationTests.cs +++ b/Optify.Tests/UseOptifyTValidationTests.cs @@ -121,4 +121,58 @@ await Assert }) .Throws(); } + + [Test] + public async Task Combines_validation_flags_from_configuration_and_attribute() + { + // Attribute has OnStart, configuration has DataAnnotations + // Combined: DataAnnotations | OnStart = validates on startup + var host = new HostBuilder() + .IncludeConfiguration([.. TestData.AllTestSettings, new("AttrOnStartOnlyDummySettings:X", null)]) + .UseOptify( + new OptifyConfiguration { Validation = ValidationFlag.DataAnnotations } + ) + .Build(); + + await Assert.That(() => host.StartAsync()).Throws(); + } + + [Test] + public async Task Uses_attribute_validation_when_configuration_is_null() + { + var host = new HostBuilder() + .IncludeConfiguration([.. TestData.AllTestSettings, new("AttrValidatedDummySettings:X", null)]) + .UseOptify(null!) + .Build(); + + var options = host.Services.GetRequiredService>(); + + await Assert.That(() => options.Value).Throws(); + } + + [Test] + public async Task Uses_attribute_validation_when_configuration_has_no_validation() + { + var host = new HostBuilder() + .IncludeConfiguration([.. TestData.AllTestSettings, new("AttrValidatedDummySettings:X", null)]) + .UseOptify(new OptifyConfiguration()) + .Build(); + + var options = host.Services.GetRequiredService>(); + + await Assert.That(() => options.Value).Throws(); + } + + [Test] + public async Task No_validation_when_neither_configuration_nor_attribute_specify_validation() + { + var host = new HostBuilder() + .IncludeConfiguration([.. TestData.AllTestSettings, new("DummyClassSettingsA:X", null)]) + .UseOptify(new OptifyConfiguration()) + .Build(); + + var options = host.Services.GetRequiredService>(); + + await Assert.That(() => options.Value).ThrowsNothing(); + } } diff --git a/Optify/HostBuilderExtensions.cs b/Optify/HostBuilderExtensions.cs index 7714f78..f5346ba 100644 --- a/Optify/HostBuilderExtensions.cs +++ b/Optify/HostBuilderExtensions.cs @@ -21,26 +21,22 @@ public static IHostBuilder UseOptify(this IHostBuilder hostBuilder, OptifyCon (ctx, services) => { var type = typeof(T); - var attribute = type.GetCustomAttributes(typeof(OptifyOptionsAttribute), false) - .FirstOrDefault(a => a is OptifyOptionsAttribute); + var attribute = + type.GetCustomAttributes(typeof(OptifyOptionsAttribute), false) + .FirstOrDefault(a => a is OptifyOptionsAttribute) as OptifyOptionsAttribute; // Can come from 3 sources. Order of precedence: // 1. `configuration.SectionName` // 2. Attribute `SectionName` // 3. `typeof(T).Name` - var sectionName = configuration.SectionName; - var validation = configuration.Validation; + // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract + var sectionName = + NullIfWhitespace(configuration?.SectionName) + ?? NullIfWhitespace(attribute?.SectionName) + ?? type.Name; + var validation = (configuration?.Validation ?? 0) | (attribute?.Validation ?? 0); - if (attribute is OptifyOptionsAttribute attr) - { - sectionName ??= string.IsNullOrWhiteSpace(attr.SectionName) ? type.Name : attr.SectionName; - validation |= attr.Validation; - } - - services - .AddOptions() - .Bind(ctx.Configuration.GetSection(sectionName!)) - .MaybeAddValidation(validation); + services.AddOptions().Bind(ctx.Configuration.GetSection(sectionName)).MaybeAddValidation(validation); } ); @@ -56,4 +52,6 @@ public static IHostBuilder UseOptify(this IHostBuilder hostBuilder, OptifyCon /// The extended instance. public static IHostBuilder UseOptify(this IHostBuilder hostBuilder) where T : class => hostBuilder.UseOptify(new OptifyConfiguration()); + + private static string? NullIfWhitespace(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; }