using Microsoft.Extensions.Configuration; using Serilog; using Serilog.Events; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// Deferred-work register row 71 — a standby node logged the correct-by-design /// active-node health-check verdict at ERROR on every probe. /// /// /// /// DefaultHealthCheckService picks the log level from the verdict (Debug/Warning/Error /// for Healthy/Degraded/Unhealthy). Both active-node checks answer Unhealthy on the standby /// because 503 is the contract /health/active publishes, so a healthy standby logged /// ERR every 10 s on a site node and every 5 s on a central node behind Traefik's probe. /// /// /// The constraint on the fix is that a GENUINE failure must still log at Error, which is why /// the filter is keyed on the check NAME rather than on the logger category. /// /// public class StandbyHealthCheckLogNoiseTests { private const string HealthCheckCategory = "Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService"; private const string Template = "Health check {HealthCheckName} with status {HealthStatus} completed after " + "{ElapsedMilliseconds}ms with message '{HealthCheckDescription}'"; private static ILogger BuildLogger(InMemorySink sink) { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ScadaBridge:Logging:MinimumLevel"] = "Debug", }) .Build(); return LoggerConfigurationFactory .Build(config, "Site", "site-a", "site-a-b") .WriteTo.Sink(sink) .CreateLogger(); } /// Emits one event shaped exactly like the framework's per-check line. private static void EmitHealthCheckLine( ILogger logger, string category, LogEventLevel level, string checkName, string status) => logger .ForContext("SourceContext", category) .Write(level, Template, checkName, status, 1.0, "some message"); /// Verifies that the standby active-node verdict is not logged. [Fact] public void StandbyActiveNodeVerdict_IsNotLogged() { // The regression: "Health check active-node with status Unhealthy … 'not the // site pair primary (standby)'" at Error, once per probe, forever. var sink = new InMemorySink(); var logger = BuildLogger(sink); EmitHealthCheckLine( logger, HealthCheckCategory, LogEventLevel.Error, "active-node", "Unhealthy"); Assert.Empty(sink.LogEvents); } /// Verifies that a genuine failure of a health check still logs at Error. /// The name of the failing health check. [Theory] [InlineData("localdb")] [InlineData("akka-cluster")] [InlineData("database")] [InlineData("required-singletons")] public void GenuineCheckFailure_StillLogsAtError(string checkName) { // The constraint. The filter is name-scoped, so every OTHER check on the same logger // category keeps its Error line — a blanket category override would have silenced // these too, which is exactly the trap this test exists to catch. var sink = new InMemorySink(); var logger = BuildLogger(sink); EmitHealthCheckLine( logger, HealthCheckCategory, LogEventLevel.Error, checkName, "Unhealthy"); var evt = Assert.Single(sink.LogEvents); Assert.Equal(LogEventLevel.Error, evt.Level); } /// Verifies that a healthy active-node verdict is unaffected. [Fact] public void ActiveNodeHealthyVerdict_IsUnaffected() { // The active node reports Healthy, which the framework logs at Debug. Nothing about // the filter should touch it — it only ever matches Error. var sink = new InMemorySink(); var logger = BuildLogger(sink); EmitHealthCheckLine( logger, HealthCheckCategory, LogEventLevel.Debug, "active-node", "Healthy"); Assert.Single(sink.LogEvents); } /// Verifies that a degraded active-node verdict is unaffected. [Fact] public void ActiveNodeDegradedVerdict_IsUnaffected() { // Degraded logs at Warning. If a future check shape ever reports Degraded for // active-node, that should still be visible — the filter is Error-only. var sink = new InMemorySink(); var logger = BuildLogger(sink); EmitHealthCheckLine( logger, HealthCheckCategory, LogEventLevel.Warning, "active-node", "Degraded"); Assert.Single(sink.LogEvents); } /// Verifies that an error mentioning the active node from another category is not suppressed. [Fact] public void AnErrorMentioningActiveNode_FromAnotherCategory_IsNotSuppressed() { // The predicate requires BOTH the health-check category and the check name, so // application code logging about the active node keeps its Error. var sink = new InMemorySink(); var logger = BuildLogger(sink); EmitHealthCheckLine( logger, "ZB.MOM.WW.ScadaBridge.Host.Actors.AkkaHostedService", LogEventLevel.Error, "active-node", "Unhealthy"); Assert.Single(sink.LogEvents); } /// Verifies that ordinary errors with no health-check properties are not suppressed. [Fact] public void OrdinaryErrorsWithNoHealthCheckProperties_AreNotSuppressed() { var sink = new InMemorySink(); var logger = BuildLogger(sink); logger.Error("something genuinely broke"); Assert.Single(sink.LogEvents); } /// Verifies that the predicate rejects a null event. [Fact] public void Predicate_RejectsANullEvent() { // Defensive: Serilog never hands the filter a null, but the predicate is internal // and reachable, and returning "suppress" for null would be the wrong default. Assert.False(LoggerConfigurationFactory.IsExpectedStandbyActiveNodeProbe(null!)); } }