97b0a4ba16
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
623 lines
28 KiB
C#
623 lines
28 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
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.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
|
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.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
|
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="FlowDebugService"/> (Flow Debugger design 2026-09-05 §5.5/§7.1):
|
|
/// the management-command bridge (holder null, error mapping, success → graph), the
|
|
/// prediction resolver wiring over substituted repositories, and the Retry/Discard relay
|
|
/// for a parked outbound node (follow-ups plan 2026-09-06 D4).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="CommunicationService"/> is a concrete class with non-virtual methods, so the
|
|
/// relay tests wire its two central-local proxies (<c>SetSiteCallAudit</c> /
|
|
/// <c>SetNotificationOutbox</c>) to scripted <see cref="ReceiveActor"/>s on the test's
|
|
/// <see cref="ActorSystem"/> — the same seam <c>SiteCallsReportPageTests</c> and
|
|
/// <c>NotificationReportPageTests</c> use.
|
|
/// </remarks>
|
|
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<IInboundApiRepository>();
|
|
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
|
|
private readonly List<ManagementEnvelope> _received = new();
|
|
|
|
// Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive.
|
|
private readonly List<object> _siteCallRequests = new();
|
|
private readonly List<object> _outboxRequests = new();
|
|
|
|
// Scripted relay replies — overridable per test before the relay call.
|
|
private SiteCallRelayOutcome _siteCallOutcome = SiteCallRelayOutcome.Applied;
|
|
private bool _siteReachable = true;
|
|
private string? _siteCallError;
|
|
|
|
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"));
|
|
|
|
var comms = new CommunicationService(
|
|
Options.Create(new CommunicationOptions()),
|
|
NullLogger<CommunicationService>.Instance);
|
|
comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))));
|
|
comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))));
|
|
|
|
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, comms, NullLogger<FlowDebugService>.Instance);
|
|
}
|
|
|
|
/// <summary>An External-lane step as <c>FlowStepProjector</c> would project it (positional per <see cref="FlowStep"/>).</summary>
|
|
private static FlowStep OutboundStep(FlowStepKind kind, FlowStepOutcome outcome, Guid? correlationId, string? siteId)
|
|
=> new(
|
|
Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External,
|
|
"ERP.GetOrder", null, DateTimeOffset.UtcNow, null, outcome,
|
|
null, null, null, siteId, "node-a", "Sim01", "ERP.GetOrder", null,
|
|
Lifecycle: null, CorrelationId: correlationId);
|
|
|
|
[Fact]
|
|
public async Task RetryOutbound_ApiCallParked_RelaysRetrySiteCallWithCorrelationAndSite()
|
|
{
|
|
var c = Guid.NewGuid();
|
|
var svc = CreateService(reply: null);
|
|
|
|
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, c, "site-a"));
|
|
|
|
Assert.True(result.Success, result.Message);
|
|
Assert.Contains("relayed", result.Message);
|
|
Assert.Contains("site-a", result.Message);
|
|
var request = Assert.IsType<RetrySiteCallRequest>(Assert.Single(_siteCallRequests));
|
|
Assert.Equal(c, request.TrackedOperationId);
|
|
Assert.Equal("site-a", request.SourceSite);
|
|
Assert.Equal("tester", request.RequestedBy);
|
|
Assert.Empty(_outboxRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryOutbound_Notify_RelaysRetryNotification()
|
|
{
|
|
var c = Guid.NewGuid();
|
|
var svc = CreateService(reply: null);
|
|
|
|
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.Notify, FlowStepOutcome.Parked, c, siteId: null));
|
|
|
|
Assert.True(result.Success, result.Message);
|
|
Assert.Contains("re-queued", result.Message);
|
|
var request = Assert.IsType<RetryNotificationRequest>(Assert.Single(_outboxRequests));
|
|
Assert.Equal(c.ToString("N"), request.NotificationId);
|
|
Assert.Equal("tester", request.RequestedBy);
|
|
Assert.Empty(_siteCallRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardOutbound_DbCall_RelaysDiscardSiteCall()
|
|
{
|
|
var c = Guid.NewGuid();
|
|
var svc = CreateService(reply: null);
|
|
|
|
var result = await svc.DiscardOutboundAsync(OutboundStep(FlowStepKind.DbCall, FlowStepOutcome.Parked, c, "site-b"));
|
|
|
|
Assert.True(result.Success, result.Message);
|
|
Assert.Contains("Discard of", result.Message);
|
|
Assert.Contains("site-b", result.Message);
|
|
var request = Assert.IsType<DiscardSiteCallRequest>(Assert.Single(_siteCallRequests));
|
|
Assert.Equal(c, request.TrackedOperationId);
|
|
Assert.Equal("site-b", request.SourceSite);
|
|
Assert.Equal("tester", request.RequestedBy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryOutbound_NotParked_RefusesWithoutRelay()
|
|
{
|
|
var svc = CreateService(reply: null);
|
|
|
|
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Ok, Guid.NewGuid(), "site-a"));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("not parked", result.Message);
|
|
Assert.Empty(_siteCallRequests);
|
|
Assert.Empty(_outboxRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryOutbound_NoCorrelation_Refuses()
|
|
{
|
|
var svc = CreateService(reply: null);
|
|
|
|
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, correlationId: null, "site-a"));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("no tracked operation id", result.Message);
|
|
Assert.Empty(_siteCallRequests);
|
|
Assert.Empty(_outboxRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryOutbound_SiteKindWithoutSiteId_Refuses()
|
|
{
|
|
var svc = CreateService(reply: null);
|
|
|
|
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.DbCall, FlowStepOutcome.Parked, Guid.NewGuid(), siteId: " "));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("owning site", result.Message);
|
|
Assert.Empty(_siteCallRequests);
|
|
Assert.Empty(_outboxRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryOutbound_NonOutboundStep_Refuses()
|
|
{
|
|
var svc = CreateService(reply: null);
|
|
|
|
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ScriptRun, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a"));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Only an API call", result.Message);
|
|
Assert.Empty(_siteCallRequests);
|
|
Assert.Empty(_outboxRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryOutbound_SiteUnreachable_MapsToFailureMessage()
|
|
{
|
|
_siteCallOutcome = SiteCallRelayOutcome.SiteUnreachable;
|
|
_siteReachable = false;
|
|
var svc = CreateService(reply: null);
|
|
|
|
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a"));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("unreachable", result.Message);
|
|
Assert.Single(_siteCallRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryOutbound_NotParkedAtSite_MapsToFailureMessage()
|
|
{
|
|
_siteCallOutcome = SiteCallRelayOutcome.NotParked;
|
|
var svc = CreateService(reply: null);
|
|
|
|
var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a"));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("no longer parked", result.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardOutbound_OperationFailed_UsesTheSitesOwnErrorMessage()
|
|
{
|
|
_siteCallOutcome = SiteCallRelayOutcome.OperationFailed;
|
|
_siteCallError = "S&F buffer is locked by a sweep.";
|
|
var svc = CreateService(reply: null);
|
|
|
|
var result = await svc.DiscardOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a"));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal("S&F buffer is locked by a sweep.", result.Message);
|
|
Assert.IsType<DiscardSiteCallRequest>(Assert.Single(_siteCallRequests));
|
|
}
|
|
|
|
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<AuditEvent>
|
|
{
|
|
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<FlowDebugRunMethodCommand>(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);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(180, 205)]
|
|
[InlineData(1, 30)]
|
|
public void RunAskTimeoutFor_IsMethodTimeoutPlusRunGracePlusClientSlack_FlooredAtMinRunAskTimeout(int timeoutSeconds, int expectedSeconds)
|
|
{
|
|
var timeout = FlowDebugService.RunAskTimeoutFor(timeoutSeconds);
|
|
|
|
Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), timeout);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValueChangeAskTimeoutFor_IsWaitSecondsPlusClientSlack()
|
|
{
|
|
var timeout = FlowDebugService.ValueChangeAskTimeoutFor(45);
|
|
|
|
Assert.Equal(TimeSpan.FromSeconds(60), timeout);
|
|
}
|
|
|
|
[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<FlowDebugRunValueChangeCommand>(_received[0].Command);
|
|
Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value));
|
|
var rpCmd = Assert.IsType<FlowDebugReplayCommand>(_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<CancellationToken>())
|
|
.Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 });
|
|
_templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any<CancellationToken>())
|
|
.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<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>([]));
|
|
_templates.GetTemplateByIdAsync(20, Arg.Any<CancellationToken>())
|
|
.Returns(new Template("Derived") { Id = 20, ParentTemplateId = 10 });
|
|
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
|
|
[
|
|
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<CancellationToken>())
|
|
.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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression for the 2026-09-06 circuit deadlock: <see cref="FlowDebugService.PredictMethodAsync"/>
|
|
/// runs on Blazor Server's single-threaded renderer sync context, and the route-target resolver used
|
|
/// to block on the repository with <c>GetAwaiter().GetResult()</c> from inside the synchronous
|
|
/// predictor walk. A repository continuation posted back to that context could then never run —
|
|
/// the whole circuit hung (every later click / JS-interop reply queued behind the blocked dispatcher).
|
|
/// The pump below IS that context: one thread, posts queued, a blocked callback starves the queue.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PredictMethod_OnASingleThreadedSyncContext_NeverBlocksTheDispatcher()
|
|
{
|
|
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any<CancellationToken>())
|
|
.Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 });
|
|
// The instance lookup completes only after a Yield — i.e. only when the sync context gets to
|
|
// run the continuation. Blocking on it from the context's own thread is the deadlock.
|
|
_templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any<CancellationToken>())
|
|
.Returns(_ => YieldThen(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }));
|
|
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
|
|
[
|
|
new TemplateScript("Process", """await Attributes.SetAsync("Cmd", 1);""") { Id = 1, TemplateId = 10 },
|
|
]));
|
|
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>())
|
|
.Returns(new Template("Base") { Id = 10, ParentTemplateId = null });
|
|
var svc = CreateService(reply: null);
|
|
|
|
var pump = new SingleThreadPump();
|
|
PredictedFlow? flow = null;
|
|
Exception? failure = null;
|
|
var done = new ManualResetEventSlim();
|
|
var thread = new Thread(() =>
|
|
{
|
|
try { flow = pump.Run(() => svc.PredictMethodAsync(4)); }
|
|
catch (Exception ex) { failure = ex; }
|
|
finally { done.Set(); }
|
|
}) { IsBackground = true, Name = "flow-predict-pump" };
|
|
thread.Start();
|
|
|
|
Assert.True(done.Wait(TimeSpan.FromSeconds(10)),
|
|
"PredictMethodAsync deadlocked the single-threaded sync context (sync-over-async in the route resolver).");
|
|
Assert.Null(failure);
|
|
Assert.NotNull(flow);
|
|
Assert.Contains(flow!.Nodes, n => n.Kind == FlowStepKind.ScriptRun && n.Title == "Sim01.Process");
|
|
Assert.Contains(flow.Nodes, n => n.Kind == FlowStepKind.AttributeWrite && n.Title == "Sim01.Cmd");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PredictMethod_NestedRouteTargets_ResolveAcrossPasses()
|
|
{
|
|
// Process calls a second script on the same instance via Scripts.Call — discovered only once
|
|
// Process itself has been resolved, so the service needs a second resolve-and-rewalk pass.
|
|
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any<CancellationToken>())
|
|
.Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 });
|
|
_templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any<CancellationToken>())
|
|
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
|
|
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
|
|
[
|
|
new TemplateScript("Process", """await Scripts.Call("Inner", new { });""") { Id = 1, TemplateId = 10 },
|
|
new TemplateScript("Inner", """await Attributes.SetAsync("Cmd", 1);""") { Id = 2, TemplateId = 10 },
|
|
]));
|
|
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>())
|
|
.Returns(new Template("Base") { Id = 10, ParentTemplateId = null });
|
|
var svc = CreateService(reply: null);
|
|
|
|
var flow = await svc.PredictMethodAsync(4);
|
|
|
|
var kinds = flow.Nodes.Select(n => n.Kind).ToList();
|
|
Assert.Contains(FlowStepKind.AttributeWrite, kinds);
|
|
Assert.Contains(flow.Nodes, n => n.Title == "Sim01.Inner");
|
|
Assert.Contains(flow.Nodes, n => n.Title == "Sim01.Cmd");
|
|
}
|
|
|
|
private static async Task<Instance?> YieldThen(Instance instance)
|
|
{
|
|
await Task.Yield();
|
|
return instance;
|
|
}
|
|
|
|
/// <summary>A Blazor-Server-shaped sync context: a single thread draining a queue of posted callbacks.</summary>
|
|
private sealed class SingleThreadPump : SynchronizationContext
|
|
{
|
|
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
|
|
|
|
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
|
|
|
|
public override void Send(SendOrPostCallback d, object? state) => throw new NotSupportedException();
|
|
|
|
public T Run<T>(Func<Task<T>> body)
|
|
{
|
|
var previous = Current;
|
|
SetSynchronizationContext(this);
|
|
try
|
|
{
|
|
var task = body();
|
|
while (!task.IsCompleted)
|
|
{
|
|
var (callback, state) = _queue.Take();
|
|
callback(state);
|
|
}
|
|
|
|
return task.GetAwaiter().GetResult();
|
|
}
|
|
finally
|
|
{
|
|
SetSynchronizationContext(previous);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PredictMethod_UnknownMethod_IsEmpty()
|
|
{
|
|
_inboundApi.GetApiMethodByIdAsync(99, Arg.Any<CancellationToken>()).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<CancellationToken>())
|
|
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
|
|
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
|
|
[
|
|
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<CancellationToken>())
|
|
.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<CancellationToken>())
|
|
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
|
|
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>([]));
|
|
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>()).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<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user));
|
|
}
|
|
|
|
private sealed class ScriptedManagementActor : ReceiveActor
|
|
{
|
|
public ScriptedManagementActor(object reply, List<ManagementEnvelope> received)
|
|
{
|
|
Receive<ManagementEnvelope>(env =>
|
|
{
|
|
lock (received) received.Add(env);
|
|
Sender.Tell(reply);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>Stands in for the Site Call Audit singleton proxy: records the relay request, replies as scripted.</summary>
|
|
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
|
|
{
|
|
public ScriptedSiteCallAuditActor(FlowDebugServiceTests test)
|
|
{
|
|
Receive<RetrySiteCallRequest>(r =>
|
|
{
|
|
lock (test._siteCallRequests) test._siteCallRequests.Add(r);
|
|
Sender.Tell(new RetrySiteCallResponse(r.CorrelationId, test._siteCallOutcome,
|
|
test._siteCallOutcome == SiteCallRelayOutcome.Applied, test._siteReachable, test._siteCallError));
|
|
});
|
|
Receive<DiscardSiteCallRequest>(r =>
|
|
{
|
|
lock (test._siteCallRequests) test._siteCallRequests.Add(r);
|
|
Sender.Tell(new DiscardSiteCallResponse(r.CorrelationId, test._siteCallOutcome,
|
|
test._siteCallOutcome == SiteCallRelayOutcome.Applied, test._siteReachable, test._siteCallError));
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>Stands in for the Notification Outbox singleton proxy: records the request, replies success.</summary>
|
|
private sealed class ScriptedOutboxActor : ReceiveActor
|
|
{
|
|
public ScriptedOutboxActor(FlowDebugServiceTests test)
|
|
{
|
|
Receive<RetryNotificationRequest>(r =>
|
|
{
|
|
lock (test._outboxRequests) test._outboxRequests.Add(r);
|
|
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null));
|
|
});
|
|
Receive<DiscardNotificationRequest>(r =>
|
|
{
|
|
lock (test._outboxRequests) test._outboxRequests.Add(r);
|
|
Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null));
|
|
});
|
|
}
|
|
}
|
|
}
|