using System.Security.Claims; using Akka.Actor; using Bunit; using Bunit.TestDoubles; using Microsoft.AspNetCore.Components; 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; /// /// bUnit rendering tests for the Notification Report page. /// /// Testability note: is a concrete class with /// non-virtual methods, so NSubstitute cannot intercept it. The report calls all /// route through an injected (the notification-outbox /// proxy), so the tests wire a real, lightweight with a /// scripted that replies with fixed responses — the /// same seam SetNotificationOutbox exists for. /// 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 { 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 every outbox query the page issued (filter assertions) and the // most recent retry/discard requests the actor received. private readonly List _queryRequests = new(); private readonly List _retryRequests = new(); private readonly List _discardRequests = new(); public NotificationReportPageTests() { _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(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.RoleClaimType, "Deployer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(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(); } [Fact] public void Page_RequiresDeploymentPolicy() { var attr = typeof(NotificationReportPage) .GetCustomAttributes(typeof(AuthorizeAttribute), true) .Cast() .FirstOrDefault(); Assert.NotNull(attr); Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); } [Fact] public void Renders_NotificationRows() { var cut = Render(); 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(); 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(); 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(); 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(), TotalCount: 0); var cut = Render(); 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(); 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 — 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 markup.) var cut = Render(); 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); } // ───────────────────────────────────────────────────────────────────────── // Flow Debugger follow-ups (register row 111, plan D2): ?correlationId= // seeds a read-only Notification pill filter, rides the query as // NotificationIdFilter, and auto-opens the detail when exactly one row // with that id comes back. // ───────────────────────────────────────────────────────────────────────── [Fact] public void NotificationReport_CorrelationIdQuery_DashedGuid_IsNormalisedToTheStoredNForm() { // Notify.Send mints NotificationId as Guid "N"; the Flow Debugger / Audit Log hold the // same value as a Guid and may render it "D". Either spelling must filter to the row. var guid = Guid.Parse("a72bc17f-b681-43eb-a364-e136c54d51c0"); var nav = (BunitNavigationManager)Services.GetRequiredService(); nav.NavigateTo($"/notifications/report?correlationId={guid:D}"); var cut = Render(); cut.WaitForAssertion(() => { Assert.Single(_queryRequests); Assert.Equal(guid.ToString("N"), _queryRequests[0].NotificationIdFilter); Assert.Contains(guid.ToString("N")[..12], cut.Find("[data-test='notif-filter-notification']").TextContent); }); } [Fact] public void NotificationReport_CorrelationIdQuery_SeedsTheNotificationFilterAndAutoOpensDetail() { const string id = "notif-aaaaaaaa-1111"; _queryReply = new NotificationOutboxQueryResponse( "q", true, null, new List { new(id, "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), }, TotalCount: 1); var nav = (BunitNavigationManager)Services.GetRequiredService(); nav.NavigateTo($"/notifications/report?correlationId={id}"); var cut = Render(); cut.WaitForAssertion(() => { // The very first fetch already carries the exact-match filter. Assert.Single(_queryRequests); Assert.Equal(id, _queryRequests[0].NotificationIdFilter); // The read-only pill names the notification by its short id. var pill = cut.Find("[data-test='notif-filter-notification']"); Assert.Contains("Notification", pill.TextContent); Assert.Contains(id[..12], pill.TextContent); // Exactly one matching row came back → the detail modal is open on it. var modal = cut.Find(".modal.show"); Assert.Contains("Pump fault at Plant-A", modal.TextContent); }); } [Fact] public void ClearFilters_DropsTheNotificationFilter() { var nav = (BunitNavigationManager)Services.GetRequiredService(); nav.NavigateTo("/notifications/report?correlationId=notif-aaaaaaaa-1111"); var cut = Render(); cut.WaitForState(() => cut.FindAll("[data-test='notif-filter-notification']").Count == 1); // The pill alone makes the filter set "active" — Clear is enabled. var clear = cut.FindAll("button").First(b => b.TextContent.Trim() == "Clear"); Assert.False(clear.HasAttribute("disabled")); clear.Click(); cut.WaitForAssertion(() => { Assert.Empty(cut.FindAll("[data-test='notif-filter-notification']")); var clearAfter = cut.FindAll("button").First(b => b.TextContent.Trim() == "Clear"); Assert.True(clearAfter.HasAttribute("disabled")); }); } [Fact] public void NotificationFilterPill_Clear_DropsTheFilterAndRequeries() { var nav = (BunitNavigationManager)Services.GetRequiredService(); nav.NavigateTo("/notifications/report?correlationId=notif-aaaaaaaa-1111"); var cut = Render(); cut.WaitForState(() => cut.FindAll("[data-test='notif-filter-notification-clear']").Count == 1); cut.Find("[data-test='notif-filter-notification-clear']").Click(); cut.WaitForAssertion(() => { Assert.Empty(cut.FindAll("[data-test='notif-filter-notification']")); // The × re-runs the query without the id filter. Assert.Equal(2, _queryRequests.Count); Assert.Null(_queryRequests[1].NotificationIdFilter); }); } protected override void Dispose(bool disposing) { if (disposing) { _system.Terminate().Wait(TimeSpan.FromSeconds(5)); } base.Dispose(disposing); } /// /// Stand-in for the notification-outbox actor. Replies to each outbox message /// type with the test's currently-scripted response. /// private sealed class ScriptedOutboxActor : ReceiveActor { public ScriptedOutboxActor(NotificationReportPageTests test) { Receive(req => { test._queryRequests.Add(req); Sender.Tell(test._queryReply with { CorrelationId = req.CorrelationId }); }); // The ?correlationId= auto-open path fetches the row's full detail; // reply with a minimal detail so the modal settles instead of the // Ask timing out. Receive(r => Sender.Tell(new NotificationDetailResponse( r.CorrelationId, true, null, new NotificationDetail( r.NotificationId, "Email", "Ops On-Call", "Pump fault at Plant-A", "Body", "Parked", RetryCount: 3, LastError: "SMTP timeout", ResolvedTargets: null, TypeData: null, SourceSiteId: "plant-a", SourceInstanceId: "Pump-001", SourceScript: null, SiteEnqueuedAt: DateTimeOffset.UtcNow.AddMinutes(-31), CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30), LastAttemptAt: null, NextAttemptAt: null, DeliveredAt: null)))); Receive(r => { test._retryRequests.Add(r); Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)); }); Receive(r => { test._discardRequests.Add(r); Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null)); }); } } /// A dialog service that auto-confirms, so action paths run end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { public Task 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 ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult(null); public Task ShowAsync( string title, Microsoft.AspNetCore.Components.RenderFragment> body, string? size = null) => Task.FromResult(default); } }