feat(central-ui): FlowDebugService relays Retry/Discard for a parked outbound step
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -10,10 +11,13 @@ 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;
|
||||
@@ -22,9 +26,17 @@ 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) and the
|
||||
/// prediction resolver wiring over substituted repositories.
|
||||
/// 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()
|
||||
@@ -38,6 +50,15 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
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")
|
||||
@@ -52,7 +73,165 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
new Claim(JwtTokenService.RoleClaimType, role),
|
||||
};
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, NullLogger<FlowDebugService>.Instance);
|
||||
|
||||
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("D"), 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)
|
||||
@@ -384,4 +563,42 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <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));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user