Files
scadaproj/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/MinimumLevelGovernanceTests.cs
T
Joseph Doherty 1ab5ca026b feat(telemetry): AddZbSerilog minimum-level governance hook; bump to 0.2.0 (scadaproj#12)
Adds an additive AddZbSerilog(builder, configure, configureSerilog) overload
taking ZbSerilogOptions:
- MinimumLevelGovernor: Func<IConfiguration, LogEventLevel?> applied with
  MinimumLevel.Is AFTER ReadFrom.Configuration, so an app-owned key (e.g.
  ScadaBridge:Logging:MinimumLevel) outranks Serilog:MinimumLevel; null defers
  to config; Serilog:MinimumLevel:Override:* entries still apply.
- ConfigureLogger: runs last, for app filters/enrichers/sinks (ScadaBridge's
  standby active-node exclusion).

The two-argument overload is unchanged. 8 new tests (4 fail with the hook
neutralized). Telemetry + Telemetry.Serilog 0.1.0 -> 0.2.0, not published.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:39:06 -05:00

163 lines
5.7 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using ZB.MOM.WW.Telemetry.Serilog;
namespace ZB.MOM.WW.Telemetry.Serilog.Tests;
/// <summary>
/// scadaproj#12: the caller-supplied minimum-level governor on <c>AddZbSerilog</c>. The contract
/// being pinned is ScadaBridge's (REQ-HOST-8 / Host-011): an app-owned key is the floor and
/// OVERRIDES <c>Serilog:MinimumLevel</c>, while per-namespace overrides from config still apply.
/// </summary>
public sealed class MinimumLevelGovernanceTests
{
private const string AppLevelKey = "App:Logging:MinimumLevel";
private static IHost Build(
Dictionary<string, string?> config,
Action<ZbSerilogOptions>? configureSerilog)
{
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
DisableDefaults = true,
});
builder.Configuration.AddInMemoryCollection(config);
if (configureSerilog is null)
builder.AddZbSerilog(o => o.ServiceName = "governance-test");
else
builder.AddZbSerilog(o => o.ServiceName = "governance-test", configureSerilog);
return builder.Build();
}
private static ILogger Logger(IHost host) => host.Services.GetRequiredService<ILogger>();
private static LogEventLevel? FromAppKey(IConfiguration c) =>
Enum.TryParse<LogEventLevel>(c[AppLevelKey], ignoreCase: true, out var level) ? level : null;
[Fact]
public void Without_a_governor_Serilog_MinimumLevel_from_config_wins()
{
// Backward-compat pin: the two-argument overload keeps "config overrides the code floor".
using var host = Build(
new() { ["Serilog:MinimumLevel:Default"] = "Warning", [AppLevelKey] = "Debug" },
configureSerilog: null);
var log = Logger(host);
Assert.False(log.IsEnabled(LogEventLevel.Information));
Assert.True(log.IsEnabled(LogEventLevel.Warning));
}
[Fact]
public void Governor_overrides_Serilog_MinimumLevel_downward()
{
using var host = Build(
new() { ["Serilog:MinimumLevel:Default"] = "Warning", [AppLevelKey] = "Debug" },
o => o.MinimumLevelGovernor = FromAppKey);
var log = Logger(host);
Assert.True(log.IsEnabled(LogEventLevel.Debug));
Assert.False(log.IsEnabled(LogEventLevel.Verbose));
}
[Fact]
public void Governor_overrides_Serilog_MinimumLevel_upward_including_the_scalar_form()
{
// "Serilog:MinimumLevel": "Verbose" (scalar, not :Default) is the other shape config takes.
using var host = Build(
new() { ["Serilog:MinimumLevel"] = "Verbose", [AppLevelKey] = "Error" },
o => o.MinimumLevelGovernor = FromAppKey);
var log = Logger(host);
Assert.False(log.IsEnabled(LogEventLevel.Warning));
Assert.True(log.IsEnabled(LogEventLevel.Error));
}
[Fact]
public void Governor_returning_null_defers_to_config()
{
using var host = Build(
new() { ["Serilog:MinimumLevel:Default"] = "Warning" },
o => o.MinimumLevelGovernor = FromAppKey);
var log = Logger(host);
Assert.False(log.IsEnabled(LogEventLevel.Information));
Assert.True(log.IsEnabled(LogEventLevel.Warning));
}
[Fact]
public void Governor_receives_the_host_configuration()
{
IConfiguration? seen = null;
using var host = Build(
new() { [AppLevelKey] = "Fatal" },
o => o.MinimumLevelGovernor = c => { seen = c; return null; });
Assert.NotNull(seen);
Assert.Equal("Fatal", seen![AppLevelKey]);
}
[Fact]
public void Namespace_overrides_from_config_still_apply_under_a_governor()
{
using var host = Build(
new()
{
[AppLevelKey] = "Debug",
["Serilog:MinimumLevel:Override:Noisy.Namespace"] = "Error",
},
o => o.MinimumLevelGovernor = FromAppKey);
var log = Logger(host);
var noisy = log.ForContext(Constants.SourceContextPropertyName, "Noisy.Namespace.Thing");
Assert.True(log.IsEnabled(LogEventLevel.Debug));
Assert.False(noisy.IsEnabled(LogEventLevel.Warning));
Assert.True(noisy.IsEnabled(LogEventLevel.Error));
}
[Fact]
public void ConfigureLogger_runs_last_and_can_add_a_filter()
{
using var host = Build(
new() { [AppLevelKey] = "Information" },
o =>
{
o.MinimumLevelGovernor = FromAppKey;
o.ConfigureLogger = (lc, sp) =>
{
Assert.NotNull(sp);
lc.Filter.ByExcluding(e => e.MessageTemplate.Text == "drop me")
.WriteTo.Sink(CapturingSink.Instance);
};
});
CapturingSink.Instance.Events.Clear();
var log = Logger(host);
log.Information("drop me");
log.Information("keep me");
var texts = CapturingSink.Instance.Events.Select(e => e.MessageTemplate.Text).ToList();
Assert.Equal(["keep me"], texts);
}
[Fact]
public void Null_hook_configurator_is_rejected()
{
var builder = Host.CreateApplicationBuilder();
Assert.Throws<ArgumentNullException>(() =>
builder.AddZbSerilog(o => o.ServiceName = "x", configureSerilog: null!));
}
private sealed class CapturingSink : ILogEventSink
{
public static readonly CapturingSink Instance = new();
public List<LogEvent> Events { get; } = [];
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
}
}