Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs
T
Joseph Doherty 40944a0d04 feat(centralui): row 75 — Approve is caution amber, not destructive red
The Secured Writes page rendered Approve and Reject as IDENTICAL red buttons.
Row 28 fixed the labels but left the styling, and the styling was the worse
half: at the moment of a two-person-control decision the colour carried no
discriminating information at all, and the affirmative half of the control wore
the delete affordance.

Root cause is that `danger` was a two-state flag for a three-state concept, so a
consequential-but-not-destructive action had to choose between looking like a
delete and looking like the safe default. Adds the missing middle rung:

    DialogSeverity { Normal -> btn-primary, Caution -> btn-warning,
                     Destructive -> btn-danger }

DECISION. Approve -> Caution (amber): approving executes a real write to live
plant equipment, so it is not a benign primary action, but it is not destroying
anything either. Reject KEEPS Destructive (red): it is terminal and irreversible
for the request — the Operator must resubmit — and holding it red is what keeps
the two opposite decisions visually distinct now that Approve is amber. Dropping
Reject to neutral was considered and rejected: it would invert the bias, making
declining look like the safe default. Colour is never the only signal — both
buttons carry their verb.

Only Destructive inherits the row-28 "Delete" label fallback; an unlabelled
Caution reads "Confirm", since a caution action is by definition not a delete —
otherwise row 28 would come back through the new door.

ADDITIVE OVERLOAD, deliberately. The boolean form stays the signature for all
~34 existing call sites, which are genuinely two-state, so nothing in src/
outside SecuredWrites.razor changed. Adding a fifth optional parameter to the
boolean form instead would have silently un-matched every existing four-argument
NSubstitute stub — the exact row-28 gotcha (null Task, caller NREs, no compile
error). The overload cannot: the only breakage was six hand-written IDialogService
fakes failing to compile, which is the loud failure mode. That gotcha did land
once, in SecuredWritesTests' own stub, and is now stubbed on both overloads with
a comment saying why.

DialogState's `bool Danger` became `DialogSeverity Severity` rather than gaining
a second field — it is read only by DialogHost, and one field cannot disagree
with itself.

Fail-before, both halves:
  - Approve call site reverted to `danger: true` -> both new Approve pins fail.
  - Caution remapped to btn-danger in DialogHost -> the amber styling pin fails.

Tests: DialogSeverityStylingTests renders the REAL DialogService behind a real
DialogHost (FailoverConfirmLabelTests' harness), so it pins what the user sees,
not what an argument was — all three severities, both label fallbacks, and the
boolean adapter. SecuredWritesTests pins that the page asks for Caution, with a
negative control that it did NOT also go through the boolean form (without which
the assertion would pass on a call site left at danger:true). Reject's red is
pinned too, so a later "make this page less alarming" pass cannot flatten both
to one colour.

No Playwright selectors needed updating — the suite has no Secured Writes
coverage, and no `.btn-danger` selector elsewhere targets this page.

Docs: Component-CentralUI.md gains the severity contract under Technology (next
to the row-28 label contract) plus the decision on the Secured Writes section.

CentralUI.Tests: 1055/1055 green.
2026-08-15 21:33:08 -04:00

294 lines
12 KiB
C#

using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using NotificationReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationReport;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Notification Report page.
///
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
/// non-virtual methods, so NSubstitute cannot intercept it. The report calls all
/// route through an injected <see cref="IActorRef"/> (the notification-outbox
/// proxy), so the tests wire a real, lightweight <see cref="ActorSystem"/> with a
/// scripted <see cref="ReceiveActor"/> that replies with fixed responses — the
/// same seam <c>SetNotificationOutbox</c> exists for.
/// </summary>
public class NotificationReportPageTests : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("notif-report-tests");
private readonly CommunicationService _comms;
// Mutable scripted reply — individual tests can override before rendering.
private NotificationOutboxQueryResponse _queryReply =
new("q", true, null, new List<NotificationSummary>
{
new("notif-aaaaaaaa-1111", "Email", "Ops On-Call", "Pump fault at Plant-A",
"Parked", RetryCount: 3, LastError: "SMTP timeout", SourceSiteId: "plant-a",
SourceInstanceId: "Pump-001", CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
DeliveredAt: null, IsStuck: true),
new("notif-bbbbbbbb-2222", "Email", "Maintenance", "Daily summary",
"Delivered", RetryCount: 0, LastError: null, SourceSiteId: "plant-b",
SourceInstanceId: null, CreatedAt: DateTimeOffset.UtcNow.AddHours(-2),
DeliveredAt: DateTimeOffset.UtcNow.AddHours(-2), IsStuck: false),
}, TotalCount: 2);
// Records the most recent retry/discard requests the actor received.
private readonly List<RetryNotificationRequest> _retryRequests = new();
private readonly List<DiscardNotificationRequest> _discardRequests = new();
public NotificationReportPageTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
Services.AddSingleton(_comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
// CentralUI-028: the page now injects SiteScopeService — the test user
// has no SiteId claims, so this resolves to system-wide and the
// pre-existing test expectations hold.
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
[Fact]
public void Page_RequiresDeploymentPolicy()
{
var attr = typeof(NotificationReportPage)
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
.Cast<AuthorizeAttribute>()
.FirstOrDefault();
Assert.NotNull(attr);
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
[Fact]
public void Renders_NotificationRows()
{
var cut = Render<NotificationReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("Pump fault at Plant-A", cut.Markup);
Assert.Contains("Daily summary", cut.Markup);
Assert.Contains("Ops On-Call", cut.Markup);
});
}
[Fact]
public void StuckRow_IsBadged()
{
var cut = Render<NotificationReportPage>();
cut.WaitForAssertion(() =>
{
var stuckRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
// The stuck row carries a visible "Stuck" badge.
Assert.Contains("badge", stuckRow.InnerHtml);
Assert.Contains("Stuck", stuckRow.TextContent);
});
}
[Fact]
public void ClickRetry_OnParkedRow_CallsRetryNotification()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
var retryButton = parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Retry"));
retryButton.Click();
cut.WaitForAssertion(() =>
{
Assert.Single(_retryRequests);
Assert.Equal("notif-aaaaaaaa-1111", _retryRequests[0].NotificationId);
});
}
[Fact]
public void ClickDiscard_OnParkedRow_CallsDiscardNotification()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
var discardButton = parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Discard"));
discardButton.Click();
cut.WaitForAssertion(() =>
{
Assert.Single(_discardRequests);
Assert.Equal("notif-aaaaaaaa-1111", _discardRequests[0].NotificationId);
});
}
[Fact]
public void QueryFailure_ShowsErrorMessage()
{
_queryReply = new NotificationOutboxQueryResponse(
"q", false, "outbox query backend unavailable",
new List<NotificationSummary>(), TotalCount: 0);
var cut = Render<NotificationReportPage>();
cut.WaitForAssertion(() =>
Assert.Contains("outbox query backend unavailable", cut.Markup));
}
// ─────────────────────────────────────────────────────────────────────────
// Bundle D drill-in (#23 M7-T10) — every notification row carries a
// "View audit history" link to /audit/log?correlationId={NotificationId}.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void NotificationRow_ViewAuditHistory_Link_HasCorrectHref()
{
var cut = Render<NotificationReportPage>();
cut.WaitForAssertion(() =>
{
// Both rows (Parked + Delivered) must surface the link — the drill-in
// is row-scope, not status-scope. We pin the parked row's href to the
// canonical correlationId-deep-link shape.
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
Assert.NotNull(link);
Assert.Equal(
"/audit/log?correlationId=notif-aaaaaaaa-1111",
link!.GetAttribute("href"));
Assert.Contains("View audit history", link.TextContent);
var deliveredRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Daily summary"));
var deliveredLink = deliveredRow.QuerySelector("a[data-test^=\"audit-link-\"]");
Assert.NotNull(deliveredLink);
Assert.Equal(
"/audit/log?correlationId=notif-bbbbbbbb-2222",
deliveredLink!.GetAttribute("href"));
});
}
[Fact]
public void Click_NavigatesTo_AuditLog_WithCorrelationId()
{
// The drill-in is a plain <a href> — browser-native navigation, not a
// Blazor onclick handler — so this test verifies the rendered anchor's
// attributes are exactly what a browser would follow: href, role, and
// human-visible text. (Triggering bUnit's .Click() on a bare anchor
// raises MissingEventHandlerException because there is no onclick
// handler to invoke; the navigation contract lives in the <a> markup.)
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]")!;
Assert.Equal("a", link.TagName, ignoreCase: true);
Assert.Equal("/audit/log?correlationId=notif-aaaaaaaa-1111", link.GetAttribute("href"));
Assert.Contains("View audit history", link.TextContent);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
/// <summary>
/// Stand-in for the notification-outbox actor. Replies to each outbox message
/// type with the test's currently-scripted response.
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
public ScriptedOutboxActor(NotificationReportPageTests test)
{
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
Receive<RetryNotificationRequest>(r =>
{
test._retryRequests.Add(r);
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null));
});
Receive<DiscardNotificationRequest>(r =>
{
test._discardRequests.Add(r);
Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null));
});
}
}
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
// Register row 75 added a DialogSeverity overload. This stub auto-confirms
// regardless of severity — it exists to let action paths run end to end, not to
// assert anything about styling.
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
string? size = null)
=> Task.FromResult<TResult?>(default);
}
}