using Microsoft.Extensions.Options; using ZB.MOM.WW.Overview.Registry; namespace ZB.MOM.WW.Overview.Tests; /// /// Registry validation. A malformed registry must fail the host at startup with a message naming /// the offending key — the alternative is a dashboard that boots and quietly shows nothing, or one /// that throws on its first poll where nobody is looking. /// public class OverviewOptionsValidatorTests { private static readonly OverviewOptionsValidator Validator = new(); private static OverviewOptions Valid(Action? mutate = null) { var options = new OverviewOptions { PollIntervalSeconds = 10, TimeoutSeconds = 3, StaleAfterSeconds = 45, Applications = { new ApplicationEntry { Name = "OtOpcUa", ManagementLabel = "AdminUI", Instances = { new InstanceEntry { Name = "central-1", Group = "central", BaseUrl = "http://otopcua-central-1:9000", ManagementUrl = "http://otopcua-central-1:9000/", HasActiveRole = true, }, }, }, }, }; mutate?.Invoke(options); return options; } private static ValidateOptionsResult Run(OverviewOptions options) => Validator.Validate(null, options); private static void AssertFailsWith(OverviewOptions options, string expectedFragment) { var result = Run(options); Assert.True(result.Failed, "expected validation to fail"); Assert.Contains( result.Failures, f => f.Contains(expectedFragment, StringComparison.OrdinalIgnoreCase)); } [Fact] public void ValidRegistry_Succeeds() { // Positive control: without this, every "fails with X" assertion below could be passing // because the fixture itself is malformed for some unrelated reason. Assert.True(Run(Valid()).Succeeded); } [Fact] public void EmptyRegistry_IsRejected() { AssertFailsWith(Valid(o => o.Applications.Clear()), "Overview:Applications"); } [Fact] public void ApplicationWithNoInstances_IsRejected() { AssertFailsWith(Valid(o => o.Applications[0].Instances.Clear()), "Instances"); } [Theory] [InlineData("")] [InlineData(" ")] public void ApplicationWithoutName_IsRejected(string name) { AssertFailsWith(Valid(o => o.Applications[0].Name = name), "Applications:0:Name"); } [Fact] public void DuplicateApplicationNames_AreRejected() { AssertFailsWith( Valid(o => o.Applications.Add(new ApplicationEntry { Name = "OtOpcUa", Instances = { new InstanceEntry { Name = "other", BaseUrl = "http://other:9000" } }, })), "duplicated"); } [Fact] public void DuplicateInstanceNamesWithinAnApplication_AreRejected() { AssertFailsWith( Valid(o => o.Applications[0].Instances.Add(new InstanceEntry { Name = "central-1", BaseUrl = "http://otopcua-central-2:9000", })), "duplicated"); } [Fact] public void SameInstanceNameInDifferentApplications_IsAllowed() { // "central-1" under OtOpcUa and "central-1" under ScadaBridge are different machines with // the same conventional name — uniqueness is per application, not global. var options = Valid(o => o.Applications.Add(new ApplicationEntry { Name = "ScadaBridge", Instances = { new InstanceEntry { Name = "central-1", BaseUrl = "http://sb-central-a:5000" } }, })); Assert.True(Run(options).Succeeded); } [Theory] [InlineData("")] [InlineData("otopcua-central-1:9000")] // no scheme [InlineData("/health/ready")] // relative [InlineData("ftp://otopcua-central-1")] // wrong scheme public void NonAbsoluteHttpBaseUrl_IsRejected(string baseUrl) { AssertFailsWith(Valid(o => o.Applications[0].Instances[0].BaseUrl = baseUrl), "BaseUrl"); } [Fact] public void RelativeManagementUrl_IsRejected() { AssertFailsWith(Valid(o => o.Applications[0].Instances[0].ManagementUrl = "/admin"), "ManagementUrl"); } [Fact] public void OmittedManagementUrl_IsAllowed() { Assert.True(Run(Valid(o => o.Applications[0].Instances[0].ManagementUrl = null)).Succeeded); } [Theory] [InlineData(0)] [InlineData(-1)] public void NonPositiveGlobalIntervals_AreRejected(int value) { AssertFailsWith(Valid(o => o.PollIntervalSeconds = value), "PollIntervalSeconds"); AssertFailsWith(Valid(o => o.TimeoutSeconds = value), "TimeoutSeconds"); AssertFailsWith(Valid(o => o.StaleAfterSeconds = value), "StaleAfterSeconds"); } [Fact] public void StaleWindowNotGreaterThanPollInterval_IsRejected() { AssertFailsWith(Valid(o => o.StaleAfterSeconds = o.PollIntervalSeconds), "StaleAfterSeconds"); AssertFailsWith(Valid(o => o.StaleAfterSeconds = o.PollIntervalSeconds - 1), "StaleAfterSeconds"); } [Fact] public void StaleWindowIsCheckedOnEffectiveValues_NotGlobals() { // The globals here are perfectly sane (10 / 45). Only the INSTANCE override inverts the // relationship, which a globals-only check would wave straight through. AssertFailsWith( Valid(o => o.Applications[0].Instances[0].PollIntervalSeconds = 120), "effective StaleAfterSeconds"); } [Fact] public void ApplicationLevelOverrideCanSatisfyTheStaleRule() { // The mirror of the previous test: an override that RESTORES the relationship must pass, so // the rule is not just "any override fails". var options = Valid(o => { o.Applications[0].Instances[0].PollIntervalSeconds = 120; o.Applications[0].Instances[0].StaleAfterSeconds = 300; }); Assert.True(Run(options).Succeeded); } [Fact] public void AllFailuresAreAccumulated_NotJustTheFirst() { // OptionsValidatorBase accumulates; an operator fixing a registry one boot at a time is the // failure mode this avoids. var result = Run(Valid(o => { o.Applications[0].Name = string.Empty; o.Applications[0].Instances[0].BaseUrl = "not-a-url"; o.PollIntervalSeconds = 0; })); Assert.True(result.Failed); Assert.True(result.Failures.Count() >= 3, $"expected >= 3 failures, got {result.Failures.Count()}"); } [Theory] [InlineData(null, null, null, 10, 3, 45)] // all defaults [InlineData(20, null, null, 20, 3, 45)] // application override [InlineData(20, 30, null, 30, 3, 45)] // instance beats application [InlineData(null, 30, 90, 30, 90, 45)] // independent fields resolve independently public void EffectiveTimings_ResolveInstanceOverApplicationOverGlobal( int? applicationPoll, int? instancePoll, int? instanceTimeout, int expectedPoll, int expectedTimeout, int expectedStale) { var options = Valid(o => { o.Applications[0].PollIntervalSeconds = applicationPoll; o.Applications[0].Instances[0].PollIntervalSeconds = instancePoll; o.Applications[0].Instances[0].TimeoutSeconds = instanceTimeout; }); var timings = EffectiveTimings.Resolve(options, options.Applications[0], options.Applications[0].Instances[0]); Assert.Equal(TimeSpan.FromSeconds(expectedPoll), timings.PollInterval); Assert.Equal(TimeSpan.FromSeconds(expectedTimeout), timings.Timeout); Assert.Equal(TimeSpan.FromSeconds(expectedStale), timings.StaleAfter); } }