using Mbproxy.Configuration; using Mbproxy.Options; using Xunit; namespace Mbproxy.Tests.Configuration; /// /// Unit tests for . /// Each test covers one specific failure mode or the happy path. /// [Trait("Category", "Unit")] public sealed class ReloadValidatorTests { // ── Helpers ─────────────────────────────────────────────────────────────────────────── private static PlcOptions MakePlc(string name, int listenPort, string host = "127.0.0.1") => new() { Name = name, ListenPort = listenPort, Host = host, Port = 502 }; private static MbproxyOptions MakeOptions( PlcOptions[] plcs, int adminPort = 8080, BcdTagListOptions? global = null) => new() { Plcs = plcs, AdminPort = adminPort, BcdTags = global ?? new BcdTagListOptions(), }; // ── 1. Duplicate PLC name → fails ──────────────────────────────────────────────────── [Fact] public void Validate_DuplicatePlcName_Fails() { var opts = MakeOptions([ MakePlc("PLC-A", 5020), MakePlc("PLC-A", 5021), // same name ]); bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("PLC-A") && e.Contains("uplicate")); } // ── 2. Duplicate ListenPort → fails ────────────────────────────────────────────────── [Fact] public void Validate_DuplicateListenPort_Fails() { var opts = MakeOptions([ MakePlc("PLC-A", 5020), MakePlc("PLC-B", 5020), // same port ]); bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("5020") && e.Contains("uplicate")); } // ── 3. AdminPort collides with a PLC's ListenPort → fails ──────────────────────────── [Fact] public void Validate_AdminPortCollidesWith_PlcListenPort_Fails() { var opts = MakeOptions( plcs: [MakePlc("PLC-A", 5020)], adminPort: 5020); // collides with PLC-A bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("AdminPort") && e.Contains("5020")); } // ── 4. Per-PLC BCD map build error → fails ──────────────────────────────────────────── [Fact] public void Validate_PerPlc_BcdMapBuildError_Fails() { // A 32-bit tag at address 100 and a 16-bit tag at 101 collide on high register. var global = new BcdTagListOptions { Global = [ new BcdTagOptions { Address = 100, Width = 32 }, new BcdTagOptions { Address = 101, Width = 16 }, // overlaps 100's high register ], }; var opts = MakeOptions([MakePlc("PLC-A", 5020)], global: global); bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("PLC-A")); } // ── 5. Port out of range → fails ───────────────────────────────────────────────────── [Fact] public void Validate_PortOutOfRange_Fails() { // ListenPort 0 is below the valid [1, 65535] range. var opts = MakeOptions([MakePlc("PLC-A", 0)]); bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("0") && e.Contains("range")); } // ── 5b. AdminPort out of range → fails ─────────────────────────────────────────────── [Fact] public void Validate_AdminPortOutOfRange_Fails() { var opts = MakeOptions([MakePlc("PLC-A", 5020)], adminPort: 70000); bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("70000") && e.Contains("range")); } // ── 6. Happy path → passes ─────────────────────────────────────────────────────────── [Fact] public void Validate_HappyPath_Passes() { var global = new BcdTagListOptions { Global = [new BcdTagOptions { Address = 1072, Width = 16 }], }; var opts = MakeOptions( plcs: [MakePlc("PLC-A", 5020), MakePlc("PLC-B", 5021)], adminPort: 8080, global: global); bool valid = ReloadValidator.Validate(opts, out var errors); Assert.True(valid); Assert.Empty(errors); } // ── 7. Empty PLC name → fails ──────────────────────────────────────────────────────── [Fact] public void Validate_EmptyPlcName_Fails() { var opts = MakeOptions([MakePlc("", 5020)]); bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("non-empty")); } // ── Cache.AllowLongTtl gate ───────────────────────────────────────────────────────── /// /// Per-tag CacheTtlMs > 60_000 without Cache.AllowLongTtl is rejected. /// [Fact] public void Validate_PerTagCacheTtl_Above60s_Without_AllowLongTtl_Fails() { var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], BcdTags = new BcdTagListOptions { Global = [ new BcdTagOptions { Address = 1024, Width = 16, CacheTtlMs = 120_000 } ], }, Cache = new CacheOptions { AllowLongTtl = false }, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("AllowLongTtl") && e.Contains("60_000")); } /// /// Same value passes when AllowLongTtl is true (operator opt-in). /// [Fact] public void Validate_PerTagCacheTtl_Above60s_With_AllowLongTtl_Passes() { var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], BcdTags = new BcdTagListOptions { Global = [ new BcdTagOptions { Address = 1024, Width = 16, CacheTtlMs = 120_000 } ], }, Cache = new CacheOptions { AllowLongTtl = true }, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.True(valid); Assert.Empty(errors); } /// /// Per-PLC DefaultCacheTtlMs > 60_000 inherited by a tag with null CacheTtlMs is /// caught by the resolved-value check even if the per-PLC default check itself /// passes (it doesn't, but this validates the defensive resolved re-check). /// [Fact] public void Validate_ResolvedTtl_FromPerPlcDefault_AboveCap_Fails() { var opts = new MbproxyOptions { Plcs = [ new PlcOptions { Name = "PLC-A", ListenPort = 5020, Host = "127.0.0.1", Port = 502, DefaultCacheTtlMs = 90_000, }, ], BcdTags = new BcdTagListOptions { // Tag with no explicit CacheTtlMs — inherits the per-PLC 90_000. Global = [ new BcdTagOptions { Address = 1024, Width = 16 } ], }, Cache = new CacheOptions { AllowLongTtl = false }, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("60_000")); } // ── ConnectionOptions validation ──────────────────────────────────────────────────── [Fact] public void Validate_ZeroBackendConnectTimeoutMs_Fails() { var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], Connection = new ConnectionOptions { BackendConnectTimeoutMs = 0 }, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("BackendConnectTimeoutMs")); } [Fact] public void Validate_NegativeGracefulShutdownTimeoutMs_Fails() { var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], Connection = new ConnectionOptions { GracefulShutdownTimeoutMs = -1 }, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("GracefulShutdownTimeoutMs")); } // ── Keepalive section ───────────────────────────────────────────────────── [Fact] public void Validate_DefaultKeepalive_Passes() { // Default ConnectionOptions → default KeepaliveOptions (idle 30 s, request 3 s). var opts = MakeOptions([MakePlc("PLC-A", 5020)]); bool valid = ReloadValidator.Validate(opts, out _); Assert.True(valid); } [Fact] public void Validate_NonPositiveTcpProbeCount_Fails() { var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], Connection = new ConnectionOptions { Keepalive = new KeepaliveOptions { TcpProbeCount = 0 }, }, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("TcpProbeCount")); } [Fact] public void Validate_OutOfRangeHeartbeatProbeAddress_Fails() { var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], Connection = new ConnectionOptions { Keepalive = new KeepaliveOptions { BackendHeartbeatProbeAddress = 70000 }, }, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("BackendHeartbeatProbeAddress")); } [Fact] public void Validate_HeartbeatIdleNotAboveRequestTimeout_Fails() { // BackendHeartbeatIdleMs must sit ABOVE BackendRequestTimeoutMs, else a heartbeat // would be timed out as fast as it could be issued. var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], Connection = new ConnectionOptions { BackendRequestTimeoutMs = 3000, Keepalive = new KeepaliveOptions { BackendHeartbeatIdleMs = 3000 }, }, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("BackendHeartbeatIdleMs")); } // ── AdminPushIntervalMs ──────────────────────────────────────────────────── [Fact] public void Validate_AdminPushIntervalMs_Zero_Fails() { var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], AdminPushIntervalMs = 0, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs")); } [Fact] public void Validate_AdminPushIntervalMs_Negative_Fails() { var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], AdminPushIntervalMs = -5, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs")); } [Fact] public void Validate_AdminPushIntervalMs_AboveUpperBound_Fails() { // The soft upper bound (60 s) catches a seconds-as-milliseconds typo that // would make the "live" dashboard feed effectively non-live. var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], AdminPushIntervalMs = 60_001, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.False(valid); Assert.Contains(errors, e => e.Contains("AdminPushIntervalMs")); } [Fact] public void Validate_AdminPushIntervalMs_AtUpperBound_Passes() { var opts = new MbproxyOptions { Plcs = [MakePlc("PLC-A", 5020)], AdminPushIntervalMs = 60_000, }; bool valid = ReloadValidator.Validate(opts, out var errors); Assert.True(valid, string.Join("; ", errors)); } }