using System.Diagnostics; using Microsoft.Extensions.Configuration; using Serilog; using Serilog.Core; using Serilog.Events; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// ScadaBridge#94: the host logger moved from ScadaBridge's own LoggerConfigurationFactory /// onto the shared AddZbSerilog bootstrap (Telemetry.Serilog 0.2.0) with a minimum-level /// governor and a final ConfigureLogger hook. These tests pin the behaviour the old factory /// had, through the real pipeline: level governance (the ScadaBridge key outranks the Serilog key, /// namespace overrides still apply), the exact node-identity enrichers, trace correlation and the /// property set of a plain line. The standby filter is pinned in /// and the configured file sink in /// , both through the same pipeline. /// public class ScadaBridgeLoggingParityTests { private static IConfiguration Config(params (string Key, string Value)[] values) => new ConfigurationBuilder() .AddInMemoryCollection(values.ToDictionary(v => v.Key, v => (string?)v.Value)) .Build(); private static Logger Create( IConfiguration configuration, InMemorySink sink, string nodeRole = "Site", string siteId = "site-a", string nodeHostname = "configured-node-hostname", TextWriter? writer = null) => ScadaBridgeLogging.CreateLogger( configuration, nodeRole, siteId, nodeHostname, writer ?? new StringWriter(), lc => lc.WriteTo.Sink(sink)); /// /// Host-011: ScadaBridge:Logging:MinimumLevel is the floor even when /// Serilog:MinimumLevel asks for something more verbose. Fails if the governor stops /// running after ReadFrom.Configuration (the shared default ordering). /// [Fact] public void ScadaBridgeLevel_OutranksAMoreVerboseSerilogLevel() { var sink = new InMemorySink(); using var logger = Create( Config(("ScadaBridge:Logging:MinimumLevel", "Warning"), ("Serilog:MinimumLevel", "Verbose")), sink); logger.Debug("debug"); logger.Information("info"); logger.Warning("warning"); var evt = Assert.Single(sink.LogEvents); Assert.Equal(LogEventLevel.Warning, evt.Level); } /// /// The governor never defers to the Serilog key: with the ScadaBridge key absent the floor is /// Information (the default), not the configured Serilog Error. /// [Fact] public void ScadaBridgeKeyAbsent_FloorIsInformation_NotTheSerilogKey() { var sink = new InMemorySink(); using var logger = Create(Config(("Serilog:MinimumLevel:Default", "Error")), sink); logger.Debug("debug"); logger.Information("info"); var evt = Assert.Single(sink.LogEvents); Assert.Equal(LogEventLevel.Information, evt.Level); } /// /// Only the DEFAULT level is governed: a Serilog:MinimumLevel:Override:* namespace entry /// still raises that namespace's floor, as it did under the old factory. /// [Fact] public void NamespaceOverrides_StillApply() { var sink = new InMemorySink(); using var logger = Create( Config( ("ScadaBridge:Logging:MinimumLevel", "Debug"), ("Serilog:MinimumLevel:Override:Noisy.Namespace", "Error")), sink); logger.ForContext(Constants.SourceContextPropertyName, "Noisy.Namespace.Thing").Warning("noisy warning"); logger.ForContext(Constants.SourceContextPropertyName, "Quiet.Namespace.Thing").Debug("quiet debug"); var evt = Assert.Single(sink.LogEvents); Assert.Equal("quiet debug", evt.RenderMessage()); } /// The override warning is written once per logger build, not per line. [Fact] public void OverrideWarning_IsOneShot() { var sink = new InMemorySink(); var writer = new StringWriter(); using var logger = Create( Config(("ScadaBridge:Logging:MinimumLevel", "Warning"), ("Serilog:MinimumLevel", "Debug")), sink, writer: writer); logger.Warning("one"); logger.Warning("two"); var lines = writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); Assert.Single(lines); } /// /// NodeHostname is the CONFIGURED ScadaBridge:Node:NodeHostname, not the machine /// name the shared bootstrap stamps, and SiteId/NodeRole are the configured values. /// [Fact] public void IdentityEnrichers_AreTheConfiguredValues() { var sink = new InMemorySink(); const string hostname = "configured-node-hostname"; Assert.NotEqual(hostname, Environment.MachineName); // positive control: the two differ using var logger = Create(Config(), sink, nodeRole: "Central", siteId: "central", nodeHostname: hostname); logger.Information("line"); var evt = Assert.Single(sink.LogEvents); Assert.Equal("central", Scalar(evt, "SiteId")); Assert.Equal("Central", Scalar(evt, "NodeRole")); Assert.Equal(hostname, Scalar(evt, "NodeHostname")); } /// /// The old factory stamped an empty SiteId verbatim; the shared bootstrap skips empty /// identity values. ScadaBridge keeps stamping it. /// [Fact] public void EmptySiteId_IsStillStamped() { var sink = new InMemorySink(); using var logger = Create(Config(), sink, siteId: ""); logger.Information("line"); Assert.Equal("", Scalar(Assert.Single(sink.LogEvents), "SiteId")); } /// /// A plain line carries exactly the three identity properties — no enricher was added or lost /// in the move (the trace enricher adds nothing outside an Activity, the redaction seam is inert). /// [Fact] public void PlainLine_CarriesExactlyTheIdentityProperties() { var sink = new InMemorySink(); using var logger = Create(Config(), sink); logger.Information("plain"); var keys = Assert.Single(sink.LogEvents).Properties.Keys.OrderBy(k => k, StringComparer.Ordinal); Assert.Equal(new[] { "NodeHostname", "NodeRole", "SiteId" }, keys); } /// Inside an Activity the shared TraceContextEnricher stamps trace_id/span_id. [Fact] public void InsideAnActivity_TraceContextIsStamped() { var sink = new InMemorySink(); using var logger = Create(Config(), sink); using var activity = new Activity("parity-test").SetIdFormat(ActivityIdFormat.W3C).Start(); logger.Information("traced"); var evt = Assert.Single(sink.LogEvents); Assert.Equal(activity.TraceId.ToHexString(), Scalar(evt, "trace_id")); Assert.Equal(activity.SpanId.ToHexString(), Scalar(evt, "span_id")); } private static string? Scalar(LogEvent evt, string name) => evt.Properties.TryGetValue(name, out var value) && value is ScalarValue { Value: var v } ? v?.ToString() : null; }