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);
}
}