d14e0ee4b1
The sweep's modal re-key (holding the row's id and re-resolving it, rather than holding the record) also used that resolve as the modal's visibility gate. That makes the modal's existence a function of list contents: any render where the row is momentarily unresolvable unmounts the whole subtree and disposes every event-handler id inside it, Close's included. A click already in flight against a disposed handler makes the renderer throw GetRequiredEventBindingEntry during DispatchEventAsync — which is how this surfaced, as an intermittent failure of CloseButton_DismissesModal (989/990 on one run, green on re-run). The record-held form made that structurally impossible: the modal existed because the user opened it, and no list mutation could retract that. This restores the property while keeping the re-key's actual benefit. Visibility now gates on the held id; the resolve drives only content. An unresolvable row degrades to an explicit notice and hides the row-scoped actions, while the frame and Close stay mounted. Detail fetched by id still renders, so the user does not lose the body they opened. Applied to all four surfaces that shared the construction: NotificationReport, ConfigurationAuditLog, ParkedMessages (offcanvas drawer) and SiteCallsReport. Modal_StaysOpen_WhenItsRowLeavesThePage drops the opened row from the next query and asserts the modal survives, keeps its fetched body, hides Retry/Discard, and that Close still works. It was run against a deliberately restored defective gate and failed there before passing here — a regression test that passes both ways would be worthless against a race. 20 consecutive runs of the previously flaky class: no failures. CentralUI.Tests 991/991, solution build 0/0. The plan doc gains a section recording that the sweep was reported as behaviour-preserving when it was not, and why the merge review missed it.
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)
|
|
=> 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);
|
|
}
|
|
}
|