From 7f557b0ee5b3f79ac58ec492b705f2e00118a421 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 11:08:05 -0400 Subject: [PATCH] feat(central-ui): Retry/Discard a parked outbound node from the Flow Debugger with scope check, confirm and re-poll The page wires FlowStepDetail's OnRetry/OnDiscard to IFlowDebugService.RetryOutboundAsync / DiscardOutboundAsync behind the same site-scope re-check and confirm wording the Site Calls / Notification report pages use, toasts the relay outcome, and on success re-polls Replay (same 5 s / 2 min loop) until the acted node leaves Parked. Also drops a duplicated ?correlationId= test block that a concurrent index-only commit left in QueryStringDrillInTests. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- ...9-06-flow-debugger-followups.md.tasks.json | 18 +-- .../Pages/Deployment/FlowDebugger.razor | 4 +- .../Pages/Deployment/FlowDebugger.razor.cs | 133 ++++++++++++++++- .../Deployment/FlowDebuggerPageTests.cs | 137 +++++++++++++++++- .../Pages/QueryStringDrillInTests.cs | 45 ------ 5 files changed, 277 insertions(+), 60 deletions(-) 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) // -----------------------------------------------------------------