using System.Security.Claims; using System.Text.Json; using Akka.Actor; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// /// Tests for (Flow Debugger design 2026-09-05 §5.5/§7.1): /// the management-command bridge (holder null, error mapping, success → graph) and the /// prediction resolver wiring over substituted repositories. /// public sealed class FlowDebugServiceTests : IDisposable { private static readonly JsonSerializerOptions ServerJson = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, }; private readonly ActorSystem _system = ActorSystem.Create("flow-debug-service-tests"); private readonly IInboundApiRepository _inboundApi = Substitute.For(); private readonly ITemplateEngineRepository _templates = Substitute.For(); private readonly List _received = new(); public void Dispose() => _system.Terminate().Wait(TimeSpan.FromSeconds(5)); private FlowDebugService CreateService(object? reply, string role = "Deployer") { var holder = new ManagementActorHolder(); if (reply != null) holder.ActorRef = _system.ActorOf(Props.Create(() => new ScriptedManagementActor(reply, _received))); var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.RoleClaimType, role), }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, NullLogger.Instance); } private static string SuccessPayload(int events, bool completed = true, bool truncated = false) { var root = Guid.NewGuid(); var child = Guid.NewGuid(); var t0 = DateTime.UtcNow; var list = new List { ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, occurredAtUtc: t0, target: "POST /api/M", executionId: root, durationMs: 100, requestSummary: """{"MachineCode":"Z28061"}""", responseSummary: """{"Result":true}"""), }; if (events > 1) list.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, occurredAtUtc: t0.AddMilliseconds(1), target: "I.S", executionId: root, durationMs: 90, extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson())); if (events > 2) list.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: t0.AddMilliseconds(2), target: "S", executionId: child, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "I", durationMs: 80)); var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1)); return JsonSerializer.Serialize(result, ServerJson); } [Fact] public async Task RunMethod_HolderNull_ReportsServiceUnavailable() { var svc = CreateService(reply: null); var outcome = await svc.RunMethodAsync(4, null); Assert.False(outcome.Success); Assert.Null(outcome.Graph); Assert.Equal("SERVICE_UNAVAILABLE", outcome.ErrorCode); Assert.Empty(_received); } [Fact] public async Task RunMethod_ManagementError_MapsCodeAndMessageVerbatim() { var svc = CreateService(new ManagementError("c", "This node is standby.", "STANDBY_NODE")); var outcome = await svc.RunMethodAsync(4, null); Assert.False(outcome.Success); Assert.Equal("STANDBY_NODE", outcome.ErrorCode); Assert.Equal("This node is standby.", outcome.Error); } [Fact] public async Task RunMethod_Unauthorized_MapsToUnauthorizedCode() { var svc = CreateService(new ManagementUnauthorized("c", "Deployer role required."), role: "Viewer"); var outcome = await svc.RunMethodAsync(4, null); Assert.Equal("UNAUTHORIZED", outcome.ErrorCode); Assert.Equal("Deployer role required.", outcome.Error); } [Fact] public async Task RunMethod_Success_BuildsGraphWithTheReturnedSteps_AndSendsTheTypedCommandForThePrincipal() { var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3))); using var doc = JsonDocument.Parse("""{"MachineCode":"Z28061"}"""); var outcome = await svc.RunMethodAsync(4, doc.RootElement.Clone()); Assert.True(outcome.Success); Assert.NotNull(outcome.Graph); Assert.NotNull(outcome.Raw); Assert.True(outcome.Raw!.Completed); // 3 returned events + the synthesized Response node. Assert.Equal(4, outcome.Graph!.Steps.Count); Assert.Contains(outcome.Graph.Steps, s => s.Kind == FlowStepKind.ScriptRun && s.Lane == FlowLane.Site); var envelope = Assert.Single(_received); var cmd = Assert.IsType(envelope.Command); Assert.Equal(4, cmd.ApiMethodId); Assert.Equal("Z28061", cmd.Parameters!.Value.GetProperty("MachineCode").GetString()); Assert.Equal("tester", envelope.User.Username); Assert.Contains("Deployer", envelope.User.Roles); } [Fact] public async Task RunValueChange_AndReplay_SendTheirCommands() { var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1))); var exec = Guid.NewGuid(); var vc = await svc.RunValueChangeAsync(7, "Speed", "42"); var rp = await svc.ReplayAsync(exec); Assert.True(vc.Success); Assert.True(rp.Success); Assert.Equal(2, _received.Count); var vcCmd = Assert.IsType(_received[0].Command); Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value)); var rpCmd = Assert.IsType(_received[1].Command); Assert.Equal(exec, rpCmd.ExecutionId); } [Fact] public async Task Run_TruncatedResult_FlagsTheGraph() { var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 2, truncated: true))); var outcome = await svc.RunMethodAsync(4, null); Assert.True(outcome.Success); Assert.True(outcome.Graph!.Truncated); } [Fact] public async Task PredictMethod_ResolvesConstantRouteTargets_ThroughInstanceTemplateChain() { _inboundApi.GetApiMethodByIdAsync(4, Arg.Any()) .Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 }); _templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any()) .Returns(new Instance("Sim01") { Id = 9, TemplateId = 20, SiteId = 1 }); // Derived template 20 has no scripts of its own; its base 10 defines Process. _templates.GetScriptsByTemplateIdAsync(20, Arg.Any()) .Returns(Task.FromResult>([])); _templates.GetTemplateByIdAsync(20, Arg.Any()) .Returns(new Template("Derived") { Id = 20, ParentTemplateId = 10 }); _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) .Returns(Task.FromResult>( [ new TemplateScript("Process", """await Attributes.SetAsync("Cmd", 1); await Attributes.WaitAsync("Ack", true, System.TimeSpan.FromSeconds(1));""") { Id = 1, TemplateId = 10 }, ])); _templates.GetTemplateByIdAsync(10, Arg.Any()) .Returns(new Template("Base") { Id = 10, ParentTemplateId = null }); var svc = CreateService(reply: null); var flow = await svc.PredictMethodAsync(4); Assert.Equal( [ FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun, FlowStepKind.AttributeWrite, FlowStepKind.AttributeWait, FlowStepKind.Response, ], flow.Nodes.Select(n => n.Kind).ToList()); Assert.Equal("Sim01.Process", flow.Nodes[2].Title); Assert.Equal("Sim01.Cmd", flow.Nodes[3].Title); } [Fact] public async Task PredictMethod_UnknownMethod_IsEmpty() { _inboundApi.GetApiMethodByIdAsync(99, Arg.Any()).Returns((ApiMethod?)null); var svc = CreateService(reply: null); var flow = await svc.PredictMethodAsync(99); Assert.Empty(flow.Nodes); } [Fact] public async Task PredictValueChange_RootsAtTheWrite_AndAppendsEachTriggeredScript() { _templates.GetInstanceByIdAsync(9, Arg.Any()) .Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }); _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) .Returns(Task.FromResult>( [ new TemplateScript("OnFlag", """Attributes["Ack"] = true;""") { Id = 1, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Flag"}""" }, new TemplateScript("OnOther", "return 1;") { Id = 2, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Other"}""" }, new TemplateScript("Manual", "return 1;") { Id = 3, TemplateId = 10, TriggerType = "call" }, ])); _templates.GetTemplateByIdAsync(10, Arg.Any()) .Returns(new Template("Base") { Id = 10 }); var svc = CreateService(reply: null); var flow = await svc.PredictValueChangeAsync(9, "Flag"); Assert.Equal( [ (FlowStepKind.AttributeWrite, "Sim01.Flag"), (FlowStepKind.ScriptRun, "Sim01.OnFlag"), (FlowStepKind.AttributeWrite, "Ack"), ], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList()); Assert.Equal(2, flow.Edges.Count); Assert.Equal(("n1", "n2"), (flow.Edges[0].From, flow.Edges[0].To)); Assert.Equal(("n2", "n3"), (flow.Edges[1].From, flow.Edges[1].To)); } [Fact] public async Task PredictValueChange_NoTriggeredScript_NotesIt() { _templates.GetInstanceByIdAsync(9, Arg.Any()) .Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }); _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) .Returns(Task.FromResult>([])); _templates.GetTemplateByIdAsync(10, Arg.Any()).Returns(new Template("Base") { Id = 10 }); var svc = CreateService(reply: null); var flow = await svc.PredictValueChangeAsync(9, "Flag"); var root = Assert.Single(flow.Nodes); Assert.Contains("no value-change script", root.Note); } private sealed class StubAuth : AuthenticationStateProvider { private readonly ClaimsPrincipal _user; public StubAuth(ClaimsPrincipal user) => _user = user; public override Task GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user)); } private sealed class ScriptedManagementActor : ReceiveActor { public ScriptedManagementActor(object reply, List received) { Receive(env => { lock (received) received.Add(env); Sender.Tell(reply); }); } } }