using ZB.MOM.WW.Overview.Polling; namespace ZB.MOM.WW.Overview.Tests; /// /// Status derivation and two-strike flap damping. These rules decide what an operator sees, so /// every branch is pinned: the difference between Down and Unreachable sends someone to a /// different place entirely, and damping that fires the wrong way either hides a real outage or /// cries wolf on every dropped packet. /// public class StatusDerivationTests { private static HealthProbeResult Reachable(string reportStatus, int statusCode) => new(true, statusCode, new ZbHealthReport { Status = reportStatus }, TimeSpan.FromMilliseconds(5), null); private static HealthProbeResult Unreachable(string error) => new(false, null, null, TimeSpan.FromMilliseconds(5), error); [Theory] [InlineData("Healthy", 200, InstanceStatus.Up)] [InlineData("Degraded", 200, InstanceStatus.Degraded)] [InlineData("Unhealthy", 503, InstanceStatus.Down)] public void Observe_MapsCanonicalStatuses(string reportStatus, int code, InstanceStatus expected) { Assert.Equal(expected, StatusDerivation.Observe(Reachable(reportStatus, code))); } [Fact] public void Observe_TransportFailure_IsUnreachable_NotDown() { // The distinction is the whole point: Down means "the app answered and said it is sick", // Unreachable means "nothing answered" — usually VPN, firewall or a registry typo. Assert.Equal(InstanceStatus.Unreachable, StatusDerivation.Observe(Unreachable("Connection refused"))); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("Weird")] public void Observe_UnrecognisedStatus_IsUnreachable(string? reportStatus) { // Parsed as JSON but not this contract — something else is on that port. Assert.Equal(InstanceStatus.Unreachable, StatusDerivation.Observe(Reachable(reportStatus!, 200))); } [Theory] [InlineData(false, 200, ActiveState.NotApplicable)] [InlineData(false, 503, ActiveState.NotApplicable)] public void ObserveActive_WithoutActiveRole_IsNotApplicable(bool hasRole, int code, ActiveState expected) { Assert.Equal(expected, StatusDerivation.ObserveActive(hasRole, Reachable("Healthy", code))); } [Theory] [InlineData(200, ActiveState.Active)] [InlineData(503, ActiveState.Standby)] [InlineData(404, ActiveState.Unknown)] public void ObserveActive_MapsStatusCodes(int code, ActiveState expected) { Assert.Equal(expected, StatusDerivation.ObserveActive(true, Reachable("Healthy", code))); } [Fact] public void ObserveActive_NoAnswer_IsUnknown_NotStandby() { // Guessing Standby on a failed probe would let a pair render with zero Active nodes during // a blip, or — worse, if guessed the other way — with two. Assert.Equal(ActiveState.Unknown, StatusDerivation.ObserveActive(true, Unreachable("timed out"))); Assert.Equal(ActiveState.Unknown, StatusDerivation.ObserveActive(true, probe: null)); } [Fact] public void Damp_FirstFailureFromGoodState_HoldsThePreviousStatus() { var (status, failures) = StatusDerivation.Damp(InstanceStatus.Up, 0, InstanceStatus.Unreachable); Assert.Equal(InstanceStatus.Up, status); Assert.Equal(1, failures); } [Fact] public void Damp_SecondConsecutiveFailure_Flips() { var (status, failures) = StatusDerivation.Damp(InstanceStatus.Up, 1, InstanceStatus.Unreachable); Assert.Equal(InstanceStatus.Unreachable, status); Assert.Equal(2, failures); } [Fact] public void Damp_RecoveryIsImmediate_EvenAfterManyFailures() { var (status, failures) = StatusDerivation.Damp(InstanceStatus.Down, 27, InstanceStatus.Up); Assert.Equal(InstanceStatus.Up, status); Assert.Equal(0, failures); } [Fact] public void Damp_FirstEverPollThatFails_IsAdoptedImmediately() { // No previous state means nothing to protect and nothing better to show. Holding "Up" here // would be inventing a healthy reading that was never observed. var (status, failures) = StatusDerivation.Damp(previous: null, 0, InstanceStatus.Unreachable); Assert.Equal(InstanceStatus.Unreachable, status); Assert.Equal(1, failures); } [Fact] public void Damp_AlternatingFailures_NeverFlip() { // The flapping link this exists for: fail, recover, fail, recover. The counter must reset // on every good poll, so the card stays Up instead of oscillating. InstanceStatus? status = InstanceStatus.Up; var failures = 0; foreach (var observed in new[] { InstanceStatus.Unreachable, InstanceStatus.Up, InstanceStatus.Unreachable, InstanceStatus.Up, InstanceStatus.Unreachable, InstanceStatus.Up, }) { (var next, failures) = StatusDerivation.Damp(status, failures, observed); status = next; } Assert.Equal(InstanceStatus.Up, status); Assert.Equal(0, failures); } [Fact] public void Damp_DegradedIsNotAFailure_AndIsAdoptedImmediately() { // Degraded is a real answer from a live app, not a failed probe: it must show at once, and // must not accumulate strikes toward Down. var (status, failures) = StatusDerivation.Damp(InstanceStatus.Up, 1, InstanceStatus.Degraded); Assert.Equal(InstanceStatus.Degraded, status); Assert.Equal(0, failures); } [Fact] public void Damp_TwoDifferentFailureKindsInARow_StillFlips() { // Down then Unreachable is two consecutive failures, not one of each kind restarting the // count — the instance is not answering healthily either way. var (first, failures) = StatusDerivation.Damp(InstanceStatus.Up, 0, InstanceStatus.Down); Assert.Equal(InstanceStatus.Up, first); var (second, _) = StatusDerivation.Damp(first, failures, InstanceStatus.Unreachable); Assert.Equal(InstanceStatus.Unreachable, second); } }