using System.Security.Claims; using Akka.Actor; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.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 ScadaLink.Security; using NotificationReportPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport; namespace ScadaLink.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 the most recent retry/discard requests the actor received. 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("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 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); } 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(_ => Sender.Tell(test._queryReply)); 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) => Task.FromResult(true); public Task PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult(null); } }