using System.Security.Claims; using Akka.Actor; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.CentralUI.Components.Shared; using NSubstitute; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Communication; using NotificationReportPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport; namespace ScadaLink.CentralUI.Tests.Pages; /// /// 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 's seam: the report's /// calls route through an injected scripted /// actor (the notification-outbox proxy). /// 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 { 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.Instance); var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))); _comms.SetNotificationOutbox(outbox); Services.AddSingleton(_comms); Services.AddSingleton(new AlwaysConfirmDialogService()); var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(Task.FromResult>(new List { new("Plant A", "plant-a") { Id = 1 }, new("Plant B", "plant-b") { Id = 2 }, })); Services.AddSingleton(siteRepo); var claims = new[] { new Claim("Username", "tester"), new Claim(ClaimTypes.Role, "Deployment"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); } [Fact] public void DoubleClickRow_OpensDetailModal() { var cut = Render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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); }); } 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(_ => Sender.Tell(test._queryReply)); Receive(r => Sender.Tell(test._detailReply with { CorrelationId = r.CorrelationId, })); Receive(r => Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null))); Receive(r => Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null))); } } private sealed class AlwaysConfirmDialogService : IDialogService { public Task ConfirmAsync(string title, string message, bool danger = false) => Task.FromResult(true); public Task PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult(null); } }