Files
scadaproj/ZB.MOM.WW.Overview/tests/ZB.MOM.WW.Overview.Tests/OverviewOptionsValidatorTests.cs
T
Joseph Doherty d0110cf406 feat(overview): scaffold the family dashboard + registry + health client
Phase 3 tasks 3.1-3.3 of docs/plans/2026-07-22-overview-dashboard-impl-plan.md.

Scaffold (3.1): a plain directory in scadaproj (NOT a nested git repo), slnx over
src/ + tests/, HistorianGateway-pattern Directory.Build.props (warnings-as-errors)
and nuget.config (nuget.org * + the Gitea feed scoped to ZB.MOM.WW.*). Inline
package pins, no CPM — the app convention here. NO Auth/Audit/Secrets packages:
the dashboard is anonymous and read-only by requirement.

Registry (3.2): OverviewOptions/ApplicationEntry/InstanceEntry bound from the
"Overview" section, plus EffectiveTimings resolving instance > application >
global overrides in ONE place so no caller re-implements the precedence.
OverviewOptionsValidator (OptionsValidatorBase + AddValidatedOptions) rejects an
empty registry, applications with no instances, duplicate names, non-absolute or
non-http(s) URLs, and non-positive intervals — and checks stale > poll on the
EFFECTIVE values, since an override at either level can invert that on one
instance while the globals look fine. A ConfigPreflight presence check runs before
the host is built so a missing registry fails with the key name, not with
"Applications must have at least 1 entry".

Health client (3.3): ZbHealthReport DTOs for the canonical ZbHealthWriter body,
with per-entry `data` kept as JsonElement — it is an open contract, and binding it
to concrete types would break the moment a check adds a field. 200 AND 503 are
both parseable answers (503 carries the body naming the failing check); only a
transport failure or a non-canonical body is a failed probe, which is why
Unreachable stays distinct from Down. Host shutdown propagates as cancellation
rather than being recorded as every instance timing out.

Status model: Up/Degraded/Down/Unreachable + Active/Standby/Unknown/NotApplicable,
with two-strike flap damping — a failing observation only replaces a good state
after 2 consecutive failures, while recovery is immediate (damping suppresses
false alarms; symmetric damping would just make them linger).

53 tests green, 0 warnings in Debug and Release. Tests for the validator, the
client golden payloads (with AND without `data`, so a partly-bumped fleet renders)
and the derivation table are front-loaded here from task 3.8 so this batch is
verified rather than pending.

Also pins AngleSharp 1.5.2 test-side: bunit 1.40.0 pulls 1.2.0, which trips NU1902
and therefore the warnings-as-errors gate (same fix HistorianGateway made in
6bc005d).
2026-07-24 06:42:21 -04:00

227 lines
8.0 KiB
C#

using Microsoft.Extensions.Options;
using ZB.MOM.WW.Overview.Registry;
namespace ZB.MOM.WW.Overview.Tests;
/// <summary>
/// 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.
/// </summary>
public class OverviewOptionsValidatorTests
{
private static readonly OverviewOptionsValidator Validator = new();
private static OverviewOptions Valid(Action<OverviewOptions>? 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);
}
}