using Mbproxy.Options; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Shouldly; using Xunit; namespace Mbproxy.Tests.Options; /// /// Verifies that binds correctly from /// and that schema-level validation fires. /// [Trait("Category", "Unit")] public sealed class MbproxyOptionsBindingTests { // ------------------------------------------------------------------------- // Helper: build MbproxyOptions directly from an in-memory configuration. // We configure the DI container with IConfiguration so BindConfiguration works. // ------------------------------------------------------------------------- private static MbproxyOptions BindOptions(Dictionary values) { var config = new ConfigurationBuilder() .AddInMemoryCollection(values) .Build(); var services = new ServiceCollection(); // Register IConfiguration so BindConfiguration("Mbproxy") can resolve it. services.AddSingleton(config); services .AddOptions() .BindConfiguration("Mbproxy"); var provider = services.BuildServiceProvider(); return provider.GetRequiredService>().CurrentValue; } // ------------------------------------------------------------------------- // Test 1 — global BCD tags bind correctly // ------------------------------------------------------------------------- [Fact] public void MbproxyOptionsBinding_BindsGlobalBcdTags_From_appsettings() { var options = BindOptions(new Dictionary { ["Mbproxy:BcdTags:Global:0:Address"] = "1072", ["Mbproxy:BcdTags:Global:0:Width"] = "16", ["Mbproxy:BcdTags:Global:1:Address"] = "1080", ["Mbproxy:BcdTags:Global:1:Width"] = "32", }); options.BcdTags.Global.Count.ShouldBe(2); options.BcdTags.Global[0].Address.ShouldBe((ushort)1072); options.BcdTags.Global[0].Width.ShouldBe((byte)16); options.BcdTags.Global[1].Address.ShouldBe((ushort)1080); options.BcdTags.Global[1].Width.ShouldBe((byte)32); } // ------------------------------------------------------------------------- // Test 2 — per-PLC Add and Remove override lists bind correctly // ------------------------------------------------------------------------- [Fact] public void MbproxyOptionsBinding_BindsPerPlcAddAndRemove() { var options = BindOptions(new Dictionary { ["Mbproxy:Plcs:0:Name"] = "Line1-Mixer", ["Mbproxy:Plcs:0:ListenPort"] = "5020", ["Mbproxy:Plcs:0:Host"] = "10.0.1.1", ["Mbproxy:Plcs:0:BcdTags:Add:0:Address"] = "1200", ["Mbproxy:Plcs:0:BcdTags:Add:0:Width"] = "32", ["Mbproxy:Plcs:0:BcdTags:Remove:0"] = "1080", }); options.Plcs.Count.ShouldBe(1); var plc = options.Plcs[0]; plc.Name.ShouldBe("Line1-Mixer"); plc.ListenPort.ShouldBe(5020); plc.Host.ShouldBe("10.0.1.1"); plc.BcdTags.ShouldNotBeNull(); plc.BcdTags!.Add.Count.ShouldBe(1); plc.BcdTags.Add[0].Address.ShouldBe((ushort)1200); plc.BcdTags.Add[0].Width.ShouldBe((byte)32); plc.BcdTags.Remove.Count.ShouldBe(1); plc.BcdTags.Remove[0].ShouldBe((ushort)1080); } // ------------------------------------------------------------------------- // Test 3 — defaults apply when the "Mbproxy" section is absent // ------------------------------------------------------------------------- [Fact] public void MbproxyOptionsBinding_DefaultsAreApplied_WhenSectionMissing() { var options = BindOptions(new Dictionary()); options.AdminPort.ShouldBe(8080); options.Connection.BackendConnectTimeoutMs.ShouldBe(3000); options.Connection.BackendRequestTimeoutMs.ShouldBe(3000); options.Resilience.BackendConnect.MaxAttempts.ShouldBe(3); options.Resilience.BackendConnect.BackoffMs.ShouldBe([100, 500, 2000]); options.Resilience.ListenerRecovery.SteadyStateMs.ShouldBe(30000); options.Resilience.ListenerRecovery.InitialBackoffMs.ShouldBe([1000, 2000, 5000, 15000, 30000]); options.Plcs.ShouldBeEmpty(); options.BcdTags.Global.ShouldBeEmpty(); // Keepalive defaults — enabled, with the documented timer values. options.Connection.Keepalive.Enabled.ShouldBeTrue(); options.Connection.Keepalive.TcpIdleTimeMs.ShouldBe(30000); options.Connection.Keepalive.TcpProbeIntervalMs.ShouldBe(5000); options.Connection.Keepalive.TcpProbeCount.ShouldBe(4); options.Connection.Keepalive.BackendHeartbeatIdleMs.ShouldBe(30000); options.Connection.Keepalive.BackendHeartbeatProbeAddress.ShouldBe(0); } // ------------------------------------------------------------------------- // Test 5 — the Connection:Keepalive block binds from configuration // ------------------------------------------------------------------------- [Fact] public void MbproxyOptionsBinding_BindsKeepaliveBlock() { var options = BindOptions(new Dictionary { ["Mbproxy:Connection:Keepalive:Enabled"] = "false", ["Mbproxy:Connection:Keepalive:TcpIdleTimeMs"] = "45000", ["Mbproxy:Connection:Keepalive:TcpProbeIntervalMs"] = "7000", ["Mbproxy:Connection:Keepalive:TcpProbeCount"] = "6", ["Mbproxy:Connection:Keepalive:BackendHeartbeatIdleMs"] = "20000", ["Mbproxy:Connection:Keepalive:BackendHeartbeatProbeAddress"] = "1024", }); var ka = options.Connection.Keepalive; ka.Enabled.ShouldBeFalse(); ka.TcpIdleTimeMs.ShouldBe(45000); ka.TcpProbeIntervalMs.ShouldBe(7000); ka.TcpProbeCount.ShouldBe(6); ka.BackendHeartbeatIdleMs.ShouldBe(20000); ka.BackendHeartbeatProbeAddress.ShouldBe(1024); } // ------------------------------------------------------------------------- // Test 4 — validator rejects Width != 16 && != 32 (schema-level only) // ------------------------------------------------------------------------- [Fact] public void MbproxyOptionsBinding_RejectsInvalidWidth() { // Build options with an invalid Width=8. var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Mbproxy:BcdTags:Global:0:Address"] = "1072", ["Mbproxy:BcdTags:Global:0:Width"] = "8", // invalid: not 16 or 32 }) .Build(); // Get creates a new instance and populates it — works with init-only properties. var options = config.GetSection("Mbproxy").Get() ?? new MbproxyOptions(); // Call the validator directly to check schema-level rejection. var validator = new MbproxyOptionsValidator(); var result = validator.Validate(null, options); result.Failed.ShouldBeTrue("Width=8 should fail schema validation"); result.Failures.ShouldNotBeEmpty(); } // ------------------------------------------------------------------------- // Test 6 — every shipped install template (Windows + Linux) loads as JSONC, // binds to MbproxyOptions, and passes schema validation. This catches // a malformed template at build time and keeps the two platform // variants in lockstep. // ------------------------------------------------------------------------- [Theory] [InlineData("mbproxy.config.template.json")] [InlineData("mbproxy.linux.config.template.json")] public void MbproxyOptionsBinding_ShippedInstallTemplate_BindsAndValidates(string templateFileName) { var templatePath = ResolveInstallFile(templateFileName); // The templates are JSONC; the .NET JSON config provider skips // and /* */ // comments and allows trailing commas, so AddJsonFile loads them directly. var config = new ConfigurationBuilder() .AddJsonFile(templatePath, optional: false) .Build(); var options = config.GetSection("Mbproxy").Get() ?? new MbproxyOptions(); var result = new MbproxyOptionsValidator().Validate(null, options); result.Succeeded.ShouldBeTrue( $"{templateFileName} must pass schema validation — failures: " + string.Join("; ", result.Failures ?? [])); } /// /// Resolves an install/ file by walking up from the test assembly directory. /// Works from both the Windows dev box and the Linux test box. /// private static string ResolveInstallFile(string fileName) { for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent) { var candidate = Path.Combine(dir.FullName, "install", fileName); if (File.Exists(candidate)) return candidate; } throw new FileNotFoundException( $"Could not locate install/{fileName} above {AppContext.BaseDirectory}"); } }