using Microsoft.Extensions.Configuration; using Serilog.Events; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// ScadaBridge:Logging:MinimumLevel must actually drive the Serilog /// minimum level. Previously the value was bound into /// but never read, so editing it had no effect. /// public class LoggerConfigurationTests { private static IConfiguration BuildConfig(string? minimumLevel) { var values = new Dictionary(); if (minimumLevel != null) values["ScadaBridge:Logging:MinimumLevel"] = minimumLevel; return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); } /// Verifies that a Warning minimum level suppresses Information logs. [Fact] public void MinimumLevel_Warning_SuppressesInformationLogs() { var sink = new InMemorySink(); var logger = LoggerConfigurationFactory .Build(BuildConfig("Warning"), "Central", "central", "node1") .WriteTo.Sink(sink) .CreateLogger(); logger.Information("info message"); logger.Warning("warning message"); Assert.Single(sink.LogEvents); Assert.Equal(LogEventLevel.Warning, sink.LogEvents[0].Level); } /// Verifies the logger enriches events from the Serilog LogContext, so a run's pushed ExecutionId reaches the sink (ScadaBridge#56). [Fact] public void LogContextProperties_AreEnrichedOntoEvents() { var sink = new InMemorySink(); var logger = LoggerConfigurationFactory .Build(BuildConfig(null), "Site", "site-a", "node1") .WriteTo.Sink(sink) .CreateLogger(); var id = Guid.NewGuid(); using (Serilog.Context.LogContext.PushProperty("ExecutionId", id)) { logger.Information("inside"); } logger.Information("outside"); Assert.Equal(2, sink.LogEvents.Count); Assert.Equal(id.ToString(), sink.LogEvents[0].Properties["ExecutionId"].ToString().Trim('"')); Assert.False(sink.LogEvents[1].Properties.ContainsKey("ExecutionId")); } /// Verifies that a Debug minimum level allows Debug logs. [Fact] public void MinimumLevel_Debug_AllowsDebugLogs() { var sink = new InMemorySink(); var logger = LoggerConfigurationFactory .Build(BuildConfig("Debug"), "Site", "site-a", "node1") .WriteTo.Sink(sink) .CreateLogger(); logger.Debug("debug message"); Assert.Single(sink.LogEvents); Assert.Equal(LogEventLevel.Debug, sink.LogEvents[0].Level); } /// Verifies that an absent minimum level defaults to Information. [Fact] public void MinimumLevel_Absent_DefaultsToInformation() { var sink = new InMemorySink(); var logger = LoggerConfigurationFactory .Build(BuildConfig(null), "Central", "central", "node1") .WriteTo.Sink(sink) .CreateLogger(); logger.Debug("debug message"); logger.Information("info message"); Assert.Single(sink.LogEvents); Assert.Equal(LogEventLevel.Information, sink.LogEvents[0].Level); } /// /// An unrecognised ScadaBridge:Logging:MinimumLevel (e.g. a typo /// like "Informaiton") must NOT abort startup but MUST emit a one-shot warning /// naming the offending value and the fallback so the silent coercion is /// visible. Null/blank is treated as "unset" and silently defaults. /// [Fact] public void ParseLevel_UnrecognisedValue_FallsBackAndWarns() { var writer = new StringWriter(); var result = LoggerConfigurationFactory.ParseLevel("Informaiton", writer); Assert.Equal(LogEventLevel.Information, result); var warning = writer.ToString(); Assert.Contains("warning", warning, StringComparison.OrdinalIgnoreCase); Assert.Contains("Informaiton", warning); Assert.Contains("Information", warning); } /// Verifies that a null or blank level falls back silently. [Fact] public void ParseLevel_NullOrBlank_FallsBackSilently() { var writer = new StringWriter(); var nullResult = LoggerConfigurationFactory.ParseLevel(null, writer); var blankResult = LoggerConfigurationFactory.ParseLevel(" ", writer); Assert.Equal(LogEventLevel.Information, nullResult); Assert.Equal(LogEventLevel.Information, blankResult); Assert.Empty(writer.ToString()); } /// Verifies that a recognised level value produces no warning. [Fact] public void ParseLevel_RecognisedValue_NoWarning() { var writer = new StringWriter(); var result = LoggerConfigurationFactory.ParseLevel("Warning", writer); Assert.Equal(LogEventLevel.Warning, result); Assert.Empty(writer.ToString()); } /// /// ScadaBridge:Logging:MinimumLevel is the documented source /// of truth for the Serilog floor, and the explicit MinimumLevel.Is /// call deliberately runs after ReadFrom.Configuration(...) so a /// Serilog:MinimumLevel entry is overridden. To make that precedence /// visible — instead of silently swallowed — /// writes a one-shot warning when both keys are present. The warning must /// name both values and point the operator at the documented key. When the /// Serilog key is absent the warning is silent. /// [Fact] public void Build_BothMinimumLevelKeysSet_WarnsAboutOverride() { var writer = new StringWriter(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ScadaBridge:Logging:MinimumLevel"] = "Warning", ["Serilog:MinimumLevel"] = "Debug", }) .Build(); LoggerConfigurationFactory.Build(configuration, "Central", "central", "node1", writer); var warning = writer.ToString(); Assert.Contains("warning", warning, StringComparison.OrdinalIgnoreCase); Assert.Contains("Serilog:MinimumLevel", warning); Assert.Contains("ScadaBridge:Logging:MinimumLevel", warning); Assert.Contains("Debug", warning); Assert.Contains("Warning", warning); } /// Verifies that setting only the ScadaBridge minimum level produces no override warning. [Fact] public void Build_OnlyScadaBridgeMinimumLevelSet_NoOverrideWarning() { var writer = new StringWriter(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ScadaBridge:Logging:MinimumLevel"] = "Warning", }) .Build(); LoggerConfigurationFactory.Build(configuration, "Central", "central", "node1", writer); // No Serilog override -> no override-warning. (The ScadaBridge value is // a recognised level, so ParseLevel is silent too.) Assert.Empty(writer.ToString()); } private sealed class CollectingTarget : ZB.MOM.WW.ScadaBridge.Commons.Observability.IExecutionLogTarget { public List Lines { get; } = new(); public void Capture(ZB.MOM.WW.ScadaBridge.Commons.Observability.CapturedLogLine line) => Lines.Add(line); } /// /// ScadaBridge#56: the host pipeline carries the per-execution capture sink, so a line tagged with /// a registered (traced) execution reaches that execution's step log. /// [Fact] public void Build_IncludesTheExecutionCaptureSink() { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build(); using var logger = LoggerConfigurationFactory.Build(configuration, "Site", "site-a", "node1", new StringWriter()) .CreateLogger(); var id = Guid.NewGuid(); var target = new CollectingTarget(); using (ZB.MOM.WW.ScadaBridge.Commons.Observability.ExecutionLogCapture.Register(id, target)) using (ZB.MOM.WW.ScadaBridge.Commons.Observability.ExecutionLogContext.Push(id)) { logger.Warning("tagged line"); } Assert.Equal("tagged line", Assert.Single(target.Lines).Message); } }