using NSubstitute; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; 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; /// /// Flow Debugger (design 2026-09-05 §5.1/§5.2/§7.1): writes an /// ALWAYS-ON ScriptTrace.RouteCall row (traced or not), propagates Trace on both /// route requests, and feeds the row plus the reply's inline site events to the trace sink. /// public class RouteHelperTraceTests { private readonly IInstanceLocator _locator = Substitute.For(); private readonly IInstanceRouter _router = Substitute.For(); private readonly List _written = new(); private readonly ICentralAuditWriter _writer = Substitute.For(); private readonly INodeIdentityProvider _node = Substitute.For(); public RouteHelperTraceTests() { _locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any()).Returns("SiteA"); _writer.WriteAsync(Arg.Do(e => _written.Add(e)), Arg.Any()).Returns(Task.CompletedTask); _node.NodeName.Returns("central-a"); } private RouteHelper Helper() => new(_locator, _router, _writer, _node); private static AuditEvent SiteEvent(Guid exec) => ScadaBridgeAuditEventFactory.Create( AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, executionId: exec, target: "doWork"); [Fact] public async Task Call_WritesRouteCallRow_WithParentAsExecutionId_AndChildFromReply() { var parent = Guid.NewGuid(); var child = Guid.NewGuid(); _router.RouteToCallAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 99, null, DateTimeOffset.UtcNow, child, null)); var result = await Helper().WithParentExecutionId(parent).To("inst-1").Call("doWork", new { x = 1 }); Assert.Equal(99, result); var evt = Assert.Single(_written); var row = evt.AsRow(); Assert.Equal("ScriptTrace.RouteCall", evt.Action); Assert.Equal("inbound-api", evt.Actor); Assert.Equal("inst-1.doWork", evt.Target); Assert.Equal("central-a", evt.SourceNode); Assert.Equal(parent, row.ExecutionId); Assert.Null(row.ParentExecutionId); Assert.Equal(AuditStatus.Delivered, row.Status); Assert.Equal(child, ScriptTraceExtra.TryParse(row.Extra)!.ChildExecutionId); Assert.Contains("\"x\":1", row.RequestSummary); Assert.Equal("99", row.ResponseSummary); } [Fact] public async Task Call_IsAlwaysOn_EvenWhenNotTraced_AndTraceFalseOnRequest() { RouteToCallRequest? captured = null; _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); await Helper().To("inst-1").Call("doWork"); Assert.False(captured!.Trace); Assert.Single(_written); } [Fact] public async Task WithTrace_PropagatesOnCallAndSetAttributes() { RouteToCallRequest? call = null; RouteToSetAttributesRequest? set = null; _router.RouteToCallAsync("SiteA", Arg.Do(r => call = r), Arg.Any()) .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); _router.RouteToSetAttributesAsync("SiteA", Arg.Do(r => set = r), Arg.Any()) .Returns(ci => new RouteToSetAttributesResponse(((RouteToSetAttributesRequest)ci[1]).CorrelationId, true, null, DateTimeOffset.UtcNow)); var traced = Helper().WithTrace(true).WithDeadline(CancellationToken.None).To("inst-1"); await traced.Call("doWork"); await traced.SetAttributes(new Dictionary { ["A"] = "1" }); Assert.True(call!.Trace); Assert.True(set!.Trace); } [Fact] public async Task Sink_ReceivesRouteCallRow_ThenTheReplysTraceEvents_InOrder() { var child = Guid.NewGuid(); var siteEvents = new[] { SiteEvent(child), SiteEvent(child) }; _router.RouteToCallAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow, child, siteEvents)); var sunk = new List(); await Helper().WithTrace(true).WithTraceSink(sunk.Add).To("inst-1").Call("doWork"); Assert.Equal(3, sunk.Count); Assert.Equal("ScriptTrace.RouteCall", sunk[0].Action); Assert.Same(siteEvents[0], sunk[1]); Assert.Same(siteEvents[1], sunk[2]); } [Fact] public async Task Failure_WritesFailedRow_AndStillThrows() { _router.RouteToCallAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, false, null, "script faulted", DateTimeOffset.UtcNow)); await Assert.ThrowsAsync(() => Helper().To("inst-1").Call("doWork")); var row = Assert.Single(_written).AsRow(); Assert.Equal(AuditStatus.Failed, row.Status); Assert.Equal("script faulted", row.ErrorMessage); } [Fact] public async Task NoWriterAndNoSink_WritesNothing_AndNeverThrows() { _router.RouteToCallAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 1, null, DateTimeOffset.UtcNow)); var result = await new RouteHelper(_locator, _router).WithTrace(true).To("inst-1").Call("doWork"); Assert.Equal(1, result); Assert.Empty(_written); } [Fact] public async Task FaultingWriter_DoesNotFailTheCall() { var badWriter = Substitute.For(); badWriter.WriteAsync(Arg.Any(), Arg.Any()).Returns(Task.FromException(new IOException("disk"))); _router.RouteToCallAsync("SiteA", Arg.Any(), Arg.Any()) .Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 5, null, DateTimeOffset.UtcNow)); var result = await new RouteHelper(_locator, _router, badWriter, null).To("inst-1").Call("doWork"); Assert.Equal(5, result); } }