0db253b55f
The Health dashboard's "Trigger failover" confirmation offered a red button
labelled *Delete*. The copy was right; the button was not. `DialogHost` derived
the confirm label solely from `danger`, so `danger: true` meant the verb was
literally "Delete" for every caller — and no caller could say otherwise, because
`IDialogService.ConfirmAsync` had no label parameter.
`danger` now selects styling only; a new optional `confirmLabel` names the action
and wins over the fallback. The kind/danger fallback is deliberately RETAINED
("Save" for a prompt, "Delete" for a bare danger confirm, "Confirm" otherwise):
around 20 call sites are genuine deletes where it is correct, and the Playwright
suites click the literal "Delete". Requiring the label everywhere would have
churned those 20 sites for no behaviour change, with the miss-detection resting
on Playwright runs that need a live rig.
Audited all 34 confirm call sites; 9 read "Delete" for something that is not a
delete and are relabelled:
Fail over CentralFailoverControl (central pair, site pair)
Approve SecuredWrites — approve (this one WRITES TO A DEVICE)
Reject SecuredWrites — reject
Disable Topology — disable instance
Discard SiteCallsReport, NotificationReport, ParkedMessages (bulk + single)
The remaining 25 are correct as they stand: 18 genuine deletes plus 7 non-danger
confirms ("Revert to base", the three Retry paths) whose button reads "Confirm".
Tests: FailoverConfirmLabelTests drives the real DialogService behind a real
DialogHost — a substitute would pin the argument but not what the operator reads
— covering both branches, that the button keeps btn-danger, and that an
unlabelled danger confirm still falls back to "Delete". Note its click is
deliberately not awaited: with a real dialog the handler parks on ConfirmAsync
until answered, so awaiting deadlocks. Existing IDialogService fakes and
NSubstitute matchers gain the 4th parameter (an optional param is baked into the
call site, so a 3-arg Arg.Any stub silently stops matching a labelled call).
353 lines
14 KiB
C#
353 lines
14 KiB
C#
using System.Security.Claims;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using Akka.Actor;
|
|
using Bunit;
|
|
using Microsoft.AspNetCore.Components.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 NotificationReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationReport;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
|
|
|
/// <summary>
|
|
/// bUnit tests for the Notification Report row-detail modal — double-clicking a
|
|
/// notification row opens a Bootstrap modal showing that notification's full,
|
|
/// untruncated details.
|
|
///
|
|
/// Mirrors <see cref="NotificationReportPageTests"/>'s seam: the report's
|
|
/// <see cref="CommunicationService"/> calls route through an injected scripted
|
|
/// actor (the notification-outbox proxy).
|
|
/// </summary>
|
|
public class NotificationReportDetailModalTests : BunitContext
|
|
{
|
|
private readonly ActorSystem _system = ActorSystem.Create("notif-report-modal-tests");
|
|
private readonly CommunicationService _comms;
|
|
|
|
private NotificationDetailResponse _detailReply =
|
|
new("d", true, null, new NotificationDetail(
|
|
NotificationId: "notif-aaaaaaaa-1111-full-id",
|
|
Type: "Email",
|
|
ListName: "Ops On-Call",
|
|
Subject: "Pump fault at Plant-A",
|
|
Body: "Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
|
|
Status: "Parked",
|
|
RetryCount: 3,
|
|
LastError: "SMTP timeout connecting to mail relay",
|
|
ResolvedTargets: "[\"ops@example.com\",\"oncall@example.com\"]",
|
|
TypeData: null,
|
|
SourceSiteId: "plant-a",
|
|
SourceInstanceId: "Pump-001",
|
|
SourceScript: "PumpFault.csx",
|
|
SiteEnqueuedAt: DateTimeOffset.UtcNow.AddMinutes(-31),
|
|
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
|
|
LastAttemptAt: DateTimeOffset.UtcNow.AddMinutes(-5),
|
|
NextAttemptAt: null,
|
|
DeliveredAt: null));
|
|
|
|
private NotificationOutboxQueryResponse _queryReply =
|
|
new("q", true, null, new List<NotificationSummary>
|
|
{
|
|
new("notif-aaaaaaaa-1111-full-id", "Email", "Ops On-Call", "Pump fault at Plant-A",
|
|
"Parked", RetryCount: 3, LastError: "SMTP timeout connecting to mail relay",
|
|
SourceSiteId: "plant-a", SourceInstanceId: "Pump-001",
|
|
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
|
|
DeliveredAt: null, IsStuck: true),
|
|
new("notif-bbbbbbbb-2222-full-id", "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);
|
|
|
|
public NotificationReportDetailModalTests()
|
|
{
|
|
_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();
|
|
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
|
}
|
|
|
|
[Fact]
|
|
public void DoubleClickRow_OpensDetailModal()
|
|
{
|
|
var cut = Render<NotificationReportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
|
|
|
// No modal initially.
|
|
Assert.Empty(cut.FindAll(".modal.show"));
|
|
|
|
var row = cut.FindAll("tbody tr")
|
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
|
row.DoubleClick();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
var modal = cut.Find(".modal.show");
|
|
Assert.Contains("Pump fault at Plant-A", modal.TextContent);
|
|
Assert.Contains("Ops On-Call", modal.TextContent);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Modal_ShowsFullNotificationId_NotTruncated()
|
|
{
|
|
var cut = Render<NotificationReportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
|
|
|
var row = cut.FindAll("tbody tr")
|
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
|
row.DoubleClick();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
var modal = cut.Find(".modal.show");
|
|
// The grid renders ShortId(...) (first 12 chars); the modal must show
|
|
// the complete identifier.
|
|
Assert.Contains("notif-aaaaaaaa-1111-full-id", modal.TextContent);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void CloseButton_DismissesModal()
|
|
{
|
|
var cut = Render<NotificationReportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
|
|
|
var row = cut.FindAll("tbody tr")
|
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
|
row.DoubleClick();
|
|
|
|
cut.WaitForState(() => cut.FindAll(".modal.show").Count > 0);
|
|
|
|
var closeButton = cut.Find(".modal.show .modal-footer button");
|
|
closeButton.Click();
|
|
|
|
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show")));
|
|
}
|
|
|
|
[Fact]
|
|
public void Modal_ShowsLastError_WhenPresent()
|
|
{
|
|
var cut = Render<NotificationReportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
|
|
|
var row = cut.FindAll("tbody tr")
|
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
|
row.DoubleClick();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
var modal = cut.Find(".modal.show");
|
|
Assert.Contains("SMTP timeout connecting to mail relay", modal.TextContent);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Modal_FetchesAndShowsBody()
|
|
{
|
|
var cut = Render<NotificationReportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
|
|
|
var row = cut.FindAll("tbody tr")
|
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
|
row.DoubleClick();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
var modal = cut.Find(".modal.show");
|
|
Assert.Contains(
|
|
"Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
|
|
modal.TextContent);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Modal_ShowsRecipients_FromResolvedTargets()
|
|
{
|
|
var cut = Render<NotificationReportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
|
|
|
var row = cut.FindAll("tbody tr")
|
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
|
row.DoubleClick();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
var modal = cut.Find(".modal.show");
|
|
Assert.Contains("ops@example.com", modal.TextContent);
|
|
Assert.Contains("oncall@example.com", modal.TextContent);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Modal_ShowsListFallback_WhenResolvedTargetsNull()
|
|
{
|
|
_detailReply = _detailReply with
|
|
{
|
|
Detail = _detailReply.Detail! with { ResolvedTargets = null },
|
|
};
|
|
|
|
var cut = Render<NotificationReportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
|
|
|
var row = cut.FindAll("tbody tr")
|
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
|
row.DoubleClick();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
var modal = cut.Find(".modal.show");
|
|
Assert.Contains("Not yet resolved", modal.TextContent);
|
|
Assert.Contains("Ops On-Call", modal.TextContent);
|
|
Assert.Contains("at delivery time", modal.TextContent);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Modal_ShowsError_WhenDetailFetchFails()
|
|
{
|
|
_detailReply = new NotificationDetailResponse("d", false, "detail store unavailable", null);
|
|
|
|
var cut = Render<NotificationReportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
|
|
|
var row = cut.FindAll("tbody tr")
|
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
|
row.DoubleClick();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
var modal = cut.Find(".modal.show");
|
|
// The error surfaces in the body/recipient sections...
|
|
Assert.Contains("detail store unavailable", modal.TextContent);
|
|
// ...but the summary fields (from the grid row) still render.
|
|
Assert.Contains("Ops On-Call", modal.TextContent);
|
|
Assert.Contains("notif-aaaaaaaa-1111-full-id", modal.TextContent);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// The modal must stay mounted when the row it was opened for leaves the
|
|
/// current page — it is gated on user intent (the held id), never on the
|
|
/// row still resolving.
|
|
///
|
|
/// This is a race regression guard, not a cosmetic one. When the subtree was
|
|
/// gated on the resolve, a refresh that dropped the row unmounted the whole
|
|
/// modal and disposed every event-handler id inside it, Close's included. A
|
|
/// click already in flight against a disposed handler makes the renderer
|
|
/// throw GetRequiredEventBindingEntry — which is exactly how this surfaced,
|
|
/// as an intermittent failure in CloseButton_DismissesModal.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Modal_StaysOpen_WhenItsRowLeavesThePage()
|
|
{
|
|
var cut = Render<NotificationReportPage>();
|
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
|
|
|
var row = cut.FindAll("tbody tr")
|
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
|
row.DoubleClick();
|
|
cut.WaitForState(() => cut.FindAll(".modal.show").Count > 0);
|
|
|
|
// The next query drops the opened row, as a refresh, filter change or
|
|
// page change legitimately can while the modal is open.
|
|
_queryReply = _queryReply with
|
|
{
|
|
Notifications = _queryReply.Notifications!
|
|
.Where(n => n.NotificationId != "notif-aaaaaaaa-1111-full-id")
|
|
.ToList(),
|
|
TotalCount = 1,
|
|
};
|
|
|
|
cut.FindAll("button").First(b => b.TextContent.Contains("Query")).Click();
|
|
cut.WaitForState(() => !cut.Markup.Contains("Pump fault at Plant-A"));
|
|
|
|
// Still mounted, and it says why the summary is missing rather than
|
|
// vanishing out from under the user.
|
|
var modal = cut.Find(".modal.show");
|
|
Assert.NotNull(modal.QuerySelector("[data-test='detail-row-gone']"));
|
|
|
|
// The detail that was already fetched is keyed by id, so it survives.
|
|
Assert.Contains(
|
|
"Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
|
|
modal.TextContent);
|
|
|
|
// Row-scoped actions are gone (nothing to relay against) but Close is not,
|
|
// and it still works — the handler was never disposed.
|
|
Assert.DoesNotContain("Retry", modal.QuerySelector(".modal-footer")!.TextContent);
|
|
cut.Find(".modal.show .modal-footer button").Click();
|
|
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show")));
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
|
|
private sealed class ScriptedOutboxActor : ReceiveActor
|
|
{
|
|
public ScriptedOutboxActor(NotificationReportDetailModalTests test)
|
|
{
|
|
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
|
|
Receive<NotificationDetailRequest>(r => Sender.Tell(test._detailReply with
|
|
{
|
|
CorrelationId = r.CorrelationId,
|
|
}));
|
|
Receive<RetryNotificationRequest>(r =>
|
|
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)));
|
|
Receive<DiscardNotificationRequest>(r =>
|
|
Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null)));
|
|
}
|
|
}
|
|
|
|
private sealed class AlwaysConfirmDialogService : IDialogService
|
|
{
|
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, 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);
|
|
}
|
|
}
|