Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LoggerConfigurationTests.cs
T
Joseph Doherty 7f479f3a5c feat(logging): stamp ExecutionId/InstanceName/ScriptName on the Serilog LogContext per run (ScadaBridge#56)
The site script and alarm on-trigger launchers push the run identity onto LogContext for the
run's lifetime and the host now enables Enrich.FromLogContext(), so log lines written on the
run's own async call chain carry the id. Bounded part only: LogContext does not cross an Akka
message hop, so Instance Actor/DCL/S&F lines and a site_events execution-id column remain open
(documented in the step-logs design doc section 9).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:20:02 -05:00

190 lines
7.2 KiB
C#

using Microsoft.Extensions.Configuration;
using Serilog.Events;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// <c>ScadaBridge:Logging:MinimumLevel</c> must actually drive the Serilog
/// minimum level. Previously the value was bound into <see cref="LoggingOptions"/>
/// but never read, so editing it had no effect.
/// </summary>
public class LoggerConfigurationTests
{
private static IConfiguration BuildConfig(string? minimumLevel)
{
var values = new Dictionary<string, string?>();
if (minimumLevel != null)
values["ScadaBridge:Logging:MinimumLevel"] = minimumLevel;
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
/// <summary>Verifies that a Warning minimum level suppresses Information logs.</summary>
[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);
}
/// <summary>Verifies the logger enriches events from the Serilog LogContext, so a run's pushed ExecutionId reaches the sink (ScadaBridge#56).</summary>
[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"));
}
/// <summary>Verifies that a Debug minimum level allows Debug logs.</summary>
[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);
}
/// <summary>Verifies that an absent minimum level defaults to Information.</summary>
[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);
}
/// <summary>
/// An unrecognised <c>ScadaBridge:Logging:MinimumLevel</c> (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.
/// </summary>
[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);
}
/// <summary>Verifies that a null or blank level falls back silently.</summary>
[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());
}
/// <summary>Verifies that a recognised level value produces no warning.</summary>
[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());
}
/// <summary>
/// <c>ScadaBridge:Logging:MinimumLevel</c> is the documented source
/// of truth for the Serilog floor, and the explicit <c>MinimumLevel.Is</c>
/// call deliberately runs after <c>ReadFrom.Configuration(...)</c> so a
/// <c>Serilog:MinimumLevel</c> entry is overridden. To make that precedence
/// visible — instead of silently swallowed — <see cref="LoggerConfigurationFactory.Build(IConfiguration,string,string,string,TextWriter)"/>
/// 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.
/// </summary>
[Fact]
public void Build_BothMinimumLevelKeysSet_WarnsAboutOverride()
{
var writer = new StringWriter();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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);
}
/// <summary>Verifies that setting only the ScadaBridge minimum level produces no override warning.</summary>
[Fact]
public void Build_OnlyScadaBridgeMinimumLevelSet_NoOverrideWarning()
{
var writer = new StringWriter();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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());
}
}