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; /// /// Tests for (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). /// /// /// is a concrete class with non-virtual methods, so the /// relay tests wire its two central-local proxies (SetSiteCallAudit / /// SetNotificationOutbox) to scripted s on the test's /// — the same seam SiteCallsReportPageTests and /// NotificationReportPageTests use. /// 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(); // Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive. private readonly List _siteCallRequests = new(); private readonly List _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.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.Instance); } /// An External-lane step as FlowStepProjector would project it (positional per ). 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(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(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(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(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 { 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); } /// /// Regression for the 2026-09-06 circuit deadlock: /// runs on Blazor Server's single-threaded renderer sync context, and the route-target resolver used /// to block on the repository with GetAwaiter().GetResult() 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. /// [Fact] public void PredictMethod_OnASingleThreadedSyncContext_NeverBlocksTheDispatcher() { _inboundApi.GetApiMethodByIdAsync(4, Arg.Any()) .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()) .Returns(_ => YieldThen(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 })); _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) .Returns(Task.FromResult>( [ new TemplateScript("Process", """await Attributes.SetAsync("Cmd", 1);""") { Id = 1, TemplateId = 10 }, ])); _templates.GetTemplateByIdAsync(10, Arg.Any()) .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()) .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 = 10, SiteId = 1 }); _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) .Returns(Task.FromResult>( [ 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()) .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 YieldThen(Instance instance) { await Task.Yield(); return instance; } /// A Blazor-Server-shaped sync context: a single thread draining a queue of posted callbacks. 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(Func> 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()).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); }); } } /// Stands in for the Site Call Audit singleton proxy: records the relay request, replies as scripted. private sealed class ScriptedSiteCallAuditActor : ReceiveActor { public ScriptedSiteCallAuditActor(FlowDebugServiceTests test) { Receive(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(r => { lock (test._siteCallRequests) test._siteCallRequests.Add(r); Sender.Tell(new DiscardSiteCallResponse(r.CorrelationId, test._siteCallOutcome, test._siteCallOutcome == SiteCallRelayOutcome.Applied, test._siteReachable, test._siteCallError)); }); } } /// Stands in for the Notification Outbox singleton proxy: records the request, replies success. private sealed class ScriptedOutboxActor : ReceiveActor { public ScriptedOutboxActor(FlowDebugServiceTests test) { Receive(r => { lock (test._outboxRequests) test._outboxRequests.Add(r); Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)); }); Receive(r => { lock (test._outboxRequests) test._outboxRequests.Add(r); Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null)); }); } } }