diff --git a/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json b/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json
index 8700f528..a950397b 100644
--- a/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json
+++ b/docs/plans/2026-09-06-flow-debugger-followups.md.tasks.json
@@ -9,7 +9,7 @@
{
"id": 1,
"subject": "Task 1: FlowStep.CorrelationId + CLI column",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
0
]
@@ -17,7 +17,7 @@
{
"id": 2,
"subject": "Task 2: Site Calls query \u2014 filter by TrackedOperationId",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
0
]
@@ -25,7 +25,7 @@
{
"id": 3,
"subject": "Task 3: Notification Outbox query \u2014 filter by NotificationId",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
0
]
@@ -33,7 +33,7 @@
{
"id": 4,
"subject": "Task 4: Site Calls page accepts ?correlationId=",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
2
]
@@ -41,7 +41,7 @@
{
"id": 5,
"subject": "Task 5: Notification report page accepts ?correlationId=",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
3
]
@@ -49,7 +49,7 @@
{
"id": 6,
"subject": "Task 6: FlowStepDetail \u2014 report deep links + Retry/Discard controls",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
1
]
@@ -57,7 +57,7 @@
{
"id": 7,
"subject": "Task 7: IFlowDebugService.RetryOutboundAsync / DiscardOutboundAsync",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
1
]
@@ -65,7 +65,7 @@
{
"id": 8,
"subject": "Task 8: Flow Debugger page \u2014 wire actions, confirm, scope check, re-poll",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
6,
7
@@ -90,5 +90,5 @@
]
}
],
- "lastUpdated": "2026-09-06T15:12:00Z"
+ "lastUpdated": "2026-09-06T16:05:00Z"
}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor
index 8027dcd7..815c7d6e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor
@@ -9,6 +9,7 @@
Flow Debugger
+
@* ── Toolbar ─────────────────────────────────────────────────────────── *@
@@ -233,7 +234,8 @@
+ InstanceDbId="@(_entry == EntryKind.ValueChange && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
+ OnRetry="OnRetryStepAsync" OnDiscard="OnDiscardStepAsync" ActionInProgress="_actionInProgress" />
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs
index fe5c8783..71f1b25d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs
@@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
@@ -73,6 +74,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
[Inject] private IJSRuntime JS { get; set; } = default!;
+ [Inject] private IDialogService Dialog { get; set; } = default!;
/// Drill-in: pre-select an inbound method.
[SupplyParameterFromQuery] public int? MethodId { get; set; }
@@ -124,11 +126,23 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private CancellationTokenSource? _refreshCts;
private bool _refreshing;
+ // Node actions (follow-ups plan 2026-09-06 D5/D6): Retry / Discard on a Parked External node.
+ // The relay itself lives in IFlowDebugService; the page owns the site-scope re-check, the
+ // confirm dialog and the re-poll that watches the acted node until it leaves Parked.
+ private ToastNotification _toast = default!;
+ private bool _actionInProgress;
+ private Guid? _watchedStepId;
+
/// Delay seam for the pending-refresh loop (tests inject an immediate delay).
internal Func Delay { get; set; } = Task.Delay;
private int PendingCount => _graph?.Steps.Count(s => s.Outcome == FlowStepOutcome.Pending) ?? 0;
+ /// Pending outbound work, or an acted-on node that is still Parked, keeps the refresh loop alive.
+ private bool ShouldKeepPolling =>
+ PendingCount > 0
+ || (_watchedStepId is { } w && _graph?.Steps.Any(s => s.EventId == w && s.Outcome == FlowStepOutcome.Parked) == true);
+
private bool CanRefresh => !_running && _raw is not null;
private bool CanRun => !_running && _mode == FlowMode.Live && (
@@ -417,8 +431,20 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// Begins polling when a completed Live run left outbound work in flight. Never in Replay mode.
private void StartPendingRefresh()
{
- if (_mode != FlowMode.Live || _raw is null || _graph is null || PendingCount == 0)
+ if (_mode != FlowMode.Live || PendingCount == 0)
return;
+ StartRefreshLoop();
+ }
+
+ ///
+ /// Starts (or restarts) the bounded refresh loop while holds — after a
+ /// Live run with Pending nodes, and after a Retry/Discard relay in either mode.
+ ///
+ private void StartRefreshLoop()
+ {
+ if (_raw is null || _graph is null || !ShouldKeepPolling)
+ return;
+ CancelPendingRefresh(keepWatch: true);
var cts = new CancellationTokenSource();
_refreshCts = cts;
_ = RefreshPendingLoopAsync(_raw.RootExecutionId, cts);
@@ -438,7 +464,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
if (ct.IsCancellationRequested) return;
MergeRefreshedGraph(outcome);
await InvokeAsync(StateHasChanged);
- if (PendingCount == 0) return;
+ if (!ShouldKeepPolling) return;
}
}
catch (OperationCanceledException)
@@ -455,6 +481,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
{
_refreshing = false;
_refreshCts = null;
+ _watchedStepId = null;
cts.Dispose();
try { await InvokeAsync(StateHasChanged); } catch (ObjectDisposedException) { }
}
@@ -496,8 +523,10 @@ public partial class FlowDebugger : ComponentBase, IDisposable
_selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected;
}
- private void CancelPendingRefresh()
+ private void CancelPendingRefresh(bool keepWatch = false)
{
+ if (!keepWatch)
+ _watchedStepId = null;
var cts = _refreshCts;
if (cts is null) return;
_refreshCts = null;
@@ -505,6 +534,104 @@ public partial class FlowDebugger : ComponentBase, IDisposable
try { cts.Cancel(); } catch (ObjectDisposedException) { }
}
+ // ── Node actions ────────────────────────────────────────────────────────
+
+ private Task OnRetryStepAsync(FlowStep step) => ActOnStepAsync(step, retry: true);
+
+ private Task OnDiscardStepAsync(FlowStep step) => ActOnStepAsync(step, retry: false);
+
+ ///
+ /// Retry / Discard a Parked outbound node: site-scope re-check → confirm → relay through the
+ /// service → toast → re-poll Replay until the node leaves Parked. Mirrors the Site Calls /
+ /// Notification report pages' own action paths (same guards, same wording).
+ ///
+ private async Task ActOnStepAsync(FlowStep step, bool retry)
+ {
+ if (_actionInProgress || step.Outcome != FlowStepOutcome.Parked || step.CorrelationId is not { } corr)
+ return;
+
+ if (!await IsStepSiteAllowedAsync(step))
+ {
+ _toast.ShowError("You are not permitted to act on cached calls for this site.");
+ return;
+ }
+
+ var isNotify = step.Kind == FlowStepKind.Notify;
+ var shortId = corr.ToString("N")[..12];
+ var noun = isNotify ? "notification" : "cached call";
+ var confirmed = retry
+ ? await Dialog.ConfirmAsync(
+ isNotify ? "Retry notification" : "Retry cached call",
+ isNotify
+ ? $"Re-queue notification {shortId} (\"{step.Title}\") for delivery?"
+ : $"Relay a retry of cached call {shortId} (\"{step.Title}\") to site {step.SiteId}?")
+ : await Dialog.ConfirmAsync(
+ isNotify ? "Discard notification" : "Discard cached call",
+ isNotify
+ ? $"Permanently discard notification {shortId} (\"{step.Title}\")? This cannot be undone."
+ : $"Relay a discard of {noun} {shortId} (\"{step.Title}\") to site {step.SiteId}? This cannot be undone.",
+ danger: true,
+ confirmLabel: "Discard");
+ if (!confirmed)
+ return;
+
+ _actionInProgress = true;
+ FlowOutboundActionResult result;
+ try
+ {
+ result = retry
+ ? await FlowDebug.RetryOutboundAsync(step)
+ : await FlowDebug.DiscardOutboundAsync(step);
+ }
+ catch (Exception ex)
+ {
+ result = new FlowOutboundActionResult(false, (retry ? "Retry failed: " : "Discard failed: ") + ex.Message);
+ }
+ finally
+ {
+ _actionInProgress = false;
+ }
+
+ if (result.Success)
+ {
+ _toast.ShowSuccess(result.Message);
+ _watchedStepId = step.EventId;
+ StartRefreshLoop();
+ }
+ else
+ {
+ _toast.ShowError(result.Message);
+ }
+ }
+
+ ///
+ /// Server-side re-check before relaying — the action must not fire for a site outside the caller's
+ /// permitted set even though the node is on screen. Notifications are central-owned (no site).
+ /// A site the page cannot resolve is allowed through, as on the Site Calls page.
+ ///
+ private async Task IsStepSiteAllowedAsync(FlowStep step)
+ {
+ if (step.Kind == FlowStepKind.Notify || string.IsNullOrEmpty(step.SiteId))
+ return true;
+ if (await SiteScope.IsSystemWideAsync())
+ return true;
+ var site = _sites.FirstOrDefault(s => s.SiteIdentifier == step.SiteId);
+ if (site is null)
+ {
+ try
+ {
+ site = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == step.SiteId);
+ }
+ catch (Exception)
+ {
+ site = null;
+ }
+ }
+ if (site is null)
+ return true;
+ return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
+ }
+
///
public void Dispose() => CancelPendingRefresh();
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
index 58d7f385..d539fe8f 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
@@ -9,6 +9,8 @@ using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+using ZB.MOM.WW.ScadaBridge.Security;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
@@ -32,6 +34,7 @@ public class FlowDebuggerPageTests : BunitContext
private readonly IInboundApiRepository _inboundApi = Substitute.For();
private readonly ITemplateEngineRepository _templates = Substitute.For();
private readonly ISiteRepository _sites = Substitute.For();
+ private readonly IDialogService _dialog = Substitute.For();
private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;")
{
@@ -70,7 +73,7 @@ public class FlowDebuggerPageTests : BunitContext
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
}
- private void RegisterServices(string role = "Deployer")
+ private void RegisterServices(string role = "Deployer", IEnumerable? extraClaims = null)
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -85,9 +88,12 @@ public class FlowDebuggerPageTests : BunitContext
Services.AddSingleton(_inboundApi);
Services.AddSingleton(_templates);
Services.AddSingleton(_sites);
+ _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
+ _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
+ Services.AddSingleton(_dialog);
var identity = new ClaimsIdentity(
- [new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role)], "TestCookie");
+ [new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role), .. extraClaims ?? []], "TestCookie");
var stubAuth = new StubAuthStateProvider(new AuthenticationState(new ClaimsPrincipal(identity)));
Services.AddSingleton(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
@@ -210,6 +216,133 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForElement("[data-test='flow-site-not-traced']");
}
+ // ---- Node actions (follow-ups plan 2026-09-06, Task 8): Retry / Discard on a Parked node -------
+
+ /// Same events as but the cached call ends Parked; the second
+ /// outcome shares the event objects (stable ids, as real audit rows are) and adds a Delivered resolve.
+ private static (FlowDebugOutcome Parked, FlowDebugOutcome Resolved, Guid StepId) ParkedThenResolved(Guid root)
+ {
+ var child = Guid.NewGuid();
+ var corr = Guid.NewGuid();
+ var t0 = DateTime.UtcNow.AddSeconds(-10);
+ var submit = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
+ occurredAtUtc: t0.AddMilliseconds(3), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
+ sourceSiteId: "site-a", sourceInstanceId: "Sim01");
+ var events = new List
+ {
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
+ occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, responseSummary: "{}"),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
+ extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
+ sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
+ submit,
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Parked,
+ occurredAtUtc: t0.AddSeconds(4), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
+ sourceSiteId: "site-a", sourceInstanceId: "Sim01", errorMessage: "503 Service Unavailable"),
+ };
+ var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
+ var parked = new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
+
+ var later = new List(events)
+ {
+ ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
+ occurredAtUtc: t0.AddSeconds(9), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
+ sourceSiteId: "site-a", sourceInstanceId: "Sim01"),
+ };
+ var resolved = new FlowDebugOutcome(FlowGraphBuilder.Build(later), raw, null, null);
+ return (parked, resolved, submit.EventId);
+ }
+
+ private IRenderedComponent RunToParkedNode(Guid root, FlowDebugOutcome parked)
+ {
+ _flow.RunMethodAsync(4, Arg.Any(), Arg.Any()).Returns(parked);
+ var cut = Render();
+ cut.Instance.Delay = (_, _) => Task.CompletedTask;
+ cut.Find("select[data-test='flow-method-select']").Change("4");
+ cut.WaitForElement("input[data-test='flow-input-MachineCode']");
+ cut.Find("button[data-test='flow-run']").Click();
+ cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
+ cut.Find("g[data-test='flow-node'][data-kind='ApiCall']").Click();
+ cut.WaitForElement("button[data-test='flow-step-retry']");
+ return cut;
+ }
+
+ [Fact]
+ public void ParkedNode_Retry_ConfirmsRelaysAndRepollsUntilNotParked()
+ {
+ RegisterServices();
+ var root = Guid.NewGuid();
+ var (parked, resolved, stepId) = ParkedThenResolved(root);
+ _flow.RetryOutboundAsync(Arg.Any(), Arg.Any())
+ .Returns(new FlowOutboundActionResult(true, "Retry of abc relayed to site-a."));
+ _flow.ReplayAsync(root, Arg.Any()).Returns(parked, resolved);
+ var cut = RunToParkedNode(root, parked);
+ _flow.DidNotReceive().ReplayAsync(Arg.Any(), Arg.Any()); // Parked is not Pending: no auto-poll
+
+ cut.Find("button[data-test='flow-step-retry']").Click();
+
+ cut.WaitForAssertion(() => _flow.Received(1).RetryOutboundAsync(Arg.Is(s => s.EventId == stepId), Arg.Any()));
+ _dialog.Received(1).ConfirmAsync("Retry cached call", Arg.Is(m => m.Contains("Delmia.Ack")), Arg.Any(), Arg.Any());
+ cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any()));
+ cut.WaitForAssertion(() => Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok"));
+ Assert.Contains("relayed", cut.Markup);
+ Assert.Empty(cut.FindAll("button[data-test='flow-step-retry']")); // the selected node is no longer parked
+ }
+
+ [Fact]
+ public void ParkedNode_Discard_UsesDangerConfirm_AndDoesNotPollWhenRefused()
+ {
+ RegisterServices();
+ var root = Guid.NewGuid();
+ var (parked, _, _) = ParkedThenResolved(root);
+ _flow.DiscardOutboundAsync(Arg.Any(), Arg.Any())
+ .Returns(new FlowOutboundActionResult(false, "Site unreachable — the relay did not reach the owning site."));
+ var cut = RunToParkedNode(root, parked);
+
+ cut.Find("button[data-test='flow-step-discard']").Click();
+
+ cut.WaitForAssertion(() => _flow.Received(1).DiscardOutboundAsync(Arg.Any(), Arg.Any()));
+ _dialog.Received(1).ConfirmAsync("Discard cached call", Arg.Is(m => m.Contains("cannot be undone")), true, "Discard");
+ cut.WaitForAssertion(() => Assert.Contains("Site unreachable", cut.Markup));
+ _flow.DidNotReceive().ReplayAsync(Arg.Any(), Arg.Any());
+ Assert.NotNull(cut.Find("button[data-test='flow-step-retry']"));
+ }
+
+ [Fact]
+ public void ParkedNode_ConfirmDeclined_NeverRelays()
+ {
+ RegisterServices();
+ _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false);
+ var root = Guid.NewGuid();
+ var (parked, _, _) = ParkedThenResolved(root);
+ var cut = RunToParkedNode(root, parked);
+
+ cut.Find("button[data-test='flow-step-retry']").Click();
+
+ cut.WaitForAssertion(() => _dialog.Received(1).ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()));
+ _flow.DidNotReceive().RetryOutboundAsync(Arg.Any(), Arg.Any());
+ _flow.DidNotReceive().ReplayAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public void ParkedNode_OutOfScopeSite_RefusedBeforeConfirm()
+ {
+ // Deployer scoped to site 2 only; the parked call belongs to site-a (id 1).
+ RegisterServices(extraClaims: [new Claim(JwtTokenService.SiteIdClaimType, "2")]);
+ var root = Guid.NewGuid();
+ var (parked, _, _) = ParkedThenResolved(root);
+ var cut = RunToParkedNode(root, parked);
+
+ cut.Find("button[data-test='flow-step-retry']").Click();
+
+ cut.WaitForAssertion(() => Assert.Contains("not permitted", cut.Markup));
+ _dialog.DidNotReceive().ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ _flow.DidNotReceive().RetryOutboundAsync(Arg.Any(), Arg.Any());
+ }
+
// ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ----------
/// Inbound → route → site run → cached API call; the call is Pending unless .
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
index f777c95e..1c8cc84b 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
@@ -151,51 +151,6 @@ public sealed class QueryStringDrillInTests
Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']"));
}
- // -----------------------------------------------------------------
- // SiteCallsReport — ?correlationId= (Flow Debugger / Audit Log deep link, D2)
- // -----------------------------------------------------------------
-
- [Fact]
- public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilter()
- {
- // A well-formed GUID rides on the first query as the exact-match
- // TrackedOperationIdFilter and renders the read-only Operation pill.
- var op = Guid.Parse("44444444-4444-4444-4444-444444444444");
- using var ctx = new SiteCallsReportFixture();
- var nav = (BunitNavigationManager)ctx.Services.GetRequiredService();
- nav.NavigateTo($"/site-calls/report?correlationId={op}");
-
- var cut = ctx.Render();
-
- cut.WaitForAssertion(() =>
- {
- Assert.Single(ctx.QueryRequests);
- Assert.Equal(op, ctx.QueryRequests[0].TrackedOperationIdFilter);
- var pill = cut.Find("[data-test='site-calls-filter-operation']");
- Assert.Contains(op.ToString("N")[..12], pill.TextContent);
- });
- }
-
- [Fact]
- public void SiteCallsReport_CorrelationIdQuery_Malformed_IsIgnored()
- {
- // Lax parsing, like ?status= — a value that is not a GUID leaves the
- // filter null and renders no pill, so a hand-crafted bad URL loads the
- // page unfiltered instead of breaking it.
- using var ctx = new SiteCallsReportFixture();
- var nav = (BunitNavigationManager)ctx.Services.GetRequiredService();
- nav.NavigateTo("/site-calls/report?correlationId=nope");
-
- var cut = ctx.Render();
-
- cut.WaitForAssertion(() =>
- {
- Assert.Single(ctx.QueryRequests);
- Assert.Null(ctx.QueryRequests[0].TrackedOperationIdFilter);
- });
- Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']"));
- }
-
// -----------------------------------------------------------------
// NotificationReport — ?correlationId= (Flow Debugger follow-ups, D2)
// -----------------------------------------------------------------