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);
}
}