using Microsoft.Extensions.Logging;
using NSubstitute;
using Serilog;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
///
/// ScadaBridge#56 (central half): while a method request is traced, other central node-log lines
/// tagged with its ExecutionId become node rows of the request; the script's own
/// Log.* line and the executor's outcome line are not copied, and an untraced request copies
/// nothing.
///
public class InboundNodeLogCaptureTests
{
private readonly Serilog.ILogger _node = new LoggerConfiguration()
.MinimumLevel.Information()
.Enrich.FromLogContext()
.WriteTo.Sink(new ExecutionLogCaptureSink())
.CreateLogger();
private readonly InboundScriptExecutor _executor;
private readonly ApiMethod _method = new("node", "x") { Id = 1, TimeoutSeconds = 10 };
/// Builds an executor whose own logger writes to the node pipeline, like the host's.
public InboundNodeLogCaptureTests()
=> _executor = new InboundScriptExecutor(new SerilogMel(_node), Substitute.For());
private sealed class SerilogMel(Serilog.ILogger inner) : ILogger
{
private readonly Serilog.ILogger _inner = inner.ForContext("SourceContext", typeof(InboundScriptExecutor).FullName);
public IDisposable? BeginScope(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func formatter)
{
if (!IsEnabled(logLevel)) return;
var level = logLevel switch
{
LogLevel.Information => Serilog.Events.LogEventLevel.Information,
LogLevel.Warning => Serilog.Events.LogEventLevel.Warning,
_ => Serilog.Events.LogEventLevel.Error,
};
_inner.Write(level, exception, "{Message:l}", formatter(state, exception));
}
}
private async Task> RunAsync(
Func> handler, bool trace)
{
_executor.RegisterHandler("node", handler);
var sink = new List();
var route = new RouteHelper(Substitute.For(), Substitute.For())
.WithTraceSink(e => { lock (sink) sink.Add(e); });
await _executor.ExecuteAsync(_method, new Dictionary(), route, TimeSpan.FromSeconds(10),
parentExecutionId: Guid.NewGuid(), trace: trace);
lock (sink)
{
return sink
.Select(e => AuditDetailsCodec.Deserialize(e.DetailsJson))
.Where(d => d.Kind == nameof(AuditKind.ScriptLog))
.Select(d => (d, ScriptTraceExtra.TryParse(d.Extra)!))
.ToList();
}
}
/// A callee's line on the request's flow becomes a node row with its category.
/// A task that represents the asynchronous operation.
[Fact]
public async Task TracedRequest_CalleeLine_IsANodeRow()
{
var rows = await RunAsync(async _ =>
{
await Task.Yield();
_node.ForContext("SourceContext", "Acme.InboundDatabaseHelper").Warning("query retried {Count}", 2);
return null;
}, trace: true);
var row = Assert.Single(rows);
Assert.Equal("node", row.Extra.LogSource);
Assert.Equal("warn", row.Extra.LogLevel);
Assert.Equal("Acme.InboundDatabaseHelper", row.Extra.LogCategory);
Assert.Equal("query retried 2", row.Details.RequestSummary);
}
/// An untraced request registers nothing, so the same line is not captured.
/// A task that represents the asynchronous operation.
[Fact]
public async Task UntracedRequest_CapturesNothing()
{
var rows = await RunAsync(_ =>
{
_node.Warning("query retried");
return Task.FromResult