using System.Collections.Concurrent; using Serilog; using ZB.MOM.WW.ScadaBridge.Commons.Observability; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Observability; /// /// ScadaBridge#56: the per-execution sink copies a line to the target registered for the line's /// ExecutionId — and to nothing else. Each test uses fresh ids, because the registry is /// process-wide and other tests may register concurrently. /// public class ExecutionLogCaptureTests { private sealed class Target : IExecutionLogTarget { public ConcurrentQueue Lines { get; } = new(); public Action? OnCapture { get; init; } public void Capture(CapturedLogLine line) { Lines.Enqueue(line); OnCapture?.Invoke(line); } } private static Serilog.ILogger Node() => new LoggerConfiguration() .MinimumLevel.Information() .Enrich.FromLogContext() .WriteTo.Sink(new ExecutionLogCaptureSink()) .CreateLogger(); /// A line tagged with a registered id reaches its target with level, category, text, exception and time. [Fact] public void TaggedLine_ReachesItsTarget() { var node = Node().ForContext("SourceContext", "Acme.InstanceActor"); var id = Guid.NewGuid(); var target = new Target(); using var registration = ExecutionLogCapture.Register(id, target); using (ExecutionLogContext.Push(id)) node.Warning(new InvalidOperationException("bad"), "wrote {Tag}", "T1"); var line = Assert.Single(target.Lines); Assert.Equal("warn", line.Level); Assert.Equal("Acme.InstanceActor", line.Category); Assert.Equal("wrote \"T1\"", line.Message); Assert.Equal("bad", line.Exception!.Message); Assert.Equal(DateTimeKind.Utc, line.OccurredAtUtc.Kind); Assert.True(ExecutionLogCapture.HasTargets); } /// Untagged lines, lines of another execution, and lines below the node's level are not captured. [Fact] public void UntaggedOrOtherOrFilteredLines_AreNotCaptured() { var node = Node(); var id = Guid.NewGuid(); var target = new Target(); using var registration = ExecutionLogCapture.Register(id, target); node.Information("untagged"); using (ExecutionLogContext.Push(Guid.NewGuid())) node.Information("other execution"); using (ExecutionLogContext.Push(id)) node.Debug("below the node minimum"); Assert.Empty(target.Lines); } /// A line written inside (already a trace row) is skipped. [Fact] public void SuppressedLine_IsNotCaptured() { var node = Node(); var id = Guid.NewGuid(); var target = new Target(); using var registration = ExecutionLogCapture.Register(id, target); using (ExecutionLogContext.Push(id)) { using (ExecutionLogCapture.Suppress()) node.Information("already a row"); node.Information("captured"); } Assert.Equal("captured", Assert.Single(target.Lines).Message); } /// A target that logs for the same execution while capturing does not loop back into itself. [Fact] public void TargetThatLogs_DoesNotRecapture() { var node = Node(); var id = Guid.NewGuid(); // Logs once only, so a broken guard fails this test instead of overflowing the stack. var logged = 0; var target = new Target { OnCapture = _ => { if (Interlocked.Increment(ref logged) == 1) node.Warning("emit failed"); } }; using var registration = ExecutionLogCapture.Register(id, target); using (ExecutionLogContext.Push(id)) node.Information("first"); Assert.Equal("first", Assert.Single(target.Lines).Message); } /// A line logged on another thread that re-pushed the id (an actor serving the run) is captured. /// A task that represents the asynchronous operation. [Fact] public async Task LineOnAnotherThread_WithTheIdRepushed_IsCaptured() { var node = Node(); var id = Guid.NewGuid(); var target = new Target(); using var registration = ExecutionLogCapture.Register(id, target); await Task.Factory.StartNew(() => { using (ExecutionLogContext.Push(id)) node.Information("from an actor thread"); }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); Assert.Equal("from an actor thread", Assert.Single(target.Lines).Message); } /// Disposing removes the registration; a duplicate registration is ignored and its disposal harmless. [Fact] public void Dispose_Unregisters_AndDuplicateIsIgnored() { var node = Node(); var id = Guid.NewGuid(); var first = new Target(); var second = new Target(); var registration = ExecutionLogCapture.Register(id, first); ExecutionLogCapture.Register(id, second).Dispose(); // duplicate: no-op, must not remove the first Assert.True(ExecutionLogCapture.TryGetTarget(id, out var found)); Assert.Same(first, found); registration.Dispose(); registration.Dispose(); Assert.False(ExecutionLogCapture.TryGetTarget(id, out _)); using (ExecutionLogContext.Push(id)) node.Information("after"); Assert.Empty(first.Lines); Assert.Empty(second.Lines); } }