diff --git a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor index b083824..7b2b491 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor @@ -139,7 +139,9 @@ @foreach (var n in _notifications) { - + @ShortId(n.NotificationId) @n.Type @n.ListName @@ -162,7 +164,7 @@ @SiteName(n.SourceSiteId) - + @* Bundle D (#23 M7-T10) drill-in: NotificationId is the audit CorrelationId, so the link deep-links into the central Audit Log pre-filtered to this notification's lifecycle events. *@ @@ -206,6 +208,86 @@ } +@* ── Row detail modal ── *@ +@if (_detailNotification != null) +{ + var d = _detailNotification; + +} + @code { private const int _pageSize = 50; @@ -220,6 +302,9 @@ private string? _listError; private bool _actionInProgress; + // Row detail modal + private NotificationSummary? _detailNotification; + // Filters private string _statusFilter = string.Empty; private string _typeFilter = string.Empty; @@ -355,6 +440,24 @@ _actionInProgress = false; } + private void ShowDetail(NotificationSummary n) => _detailNotification = n; + + private void CloseDetail() => _detailNotification = null; + + private async Task RetryFromDetail(NotificationSummary n) + { + await RetryNotification(n); + // RefreshAll replaces the row list; close the modal so the user sees the + // refreshed grid rather than a now-stale detail snapshot. + CloseDetail(); + } + + private async Task DiscardFromDetail(NotificationSummary n) + { + await DiscardNotification(n); + CloseDetail(); + } + private void ClearFilters() { _statusFilter = string.Empty; diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs new file mode 100644 index 0000000..bbcd036 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs @@ -0,0 +1,182 @@ +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 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); + }); + } + + 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(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); + } +}