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 NSubstitute; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Types.Notifications; using ScadaLink.Communication; using ScadaLink.Security; using NotificationKpisPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationKpis; namespace ScadaLink.CentralUI.Tests.Pages; /// /// bUnit rendering tests for the Notification KPIs page. /// /// Testability note: is a concrete class with /// non-virtual methods, so NSubstitute cannot intercept it. Both the global and /// per-site KPI calls route through an injected (the /// notification-outbox proxy), so the tests wire a real, lightweight /// with a scripted that /// answers both and /// — the same seam /// SetNotificationOutbox exists for. /// public class NotificationKpisPageTests : BunitContext { private readonly ActorSystem _system = ActorSystem.Create("notif-kpis-tests"); private readonly CommunicationService _comms; // Mutable scripted replies — individual tests can override before rendering. private NotificationKpiResponse _kpiReply = new("k", true, null, QueueDepth: 7, StuckCount: 2, ParkedCount: 1, DeliveredLastInterval: 42, OldestPendingAge: TimeSpan.FromMinutes(9)); private PerSiteNotificationKpiResponse _perSiteReply = new("p", true, null, new List { new("plant-a", QueueDepth: 4, StuckCount: 1, ParkedCount: 0, DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)), }); public NotificationKpisPageTests() { _comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))); _comms.SetNotificationOutbox(outbox); Services.AddSingleton(_comms); 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(NotificationKpisPage) .GetCustomAttributes(typeof(AuthorizeAttribute), true) .Cast() .FirstOrDefault(); Assert.NotNull(attr); Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); } [Fact] public void RendersGlobalTilesAndPerSiteRows() { var cut = Render(); cut.WaitForAssertion(() => { Assert.Contains("Queue Depth", cut.Markup); Assert.Contains("7", cut.Markup); // global tile value // Per-site row — site identifier "plant-a" resolves to its friendly name. Assert.Contains("Plant A", cut.Markup); }); } [Fact] public void ShowsKpiError_WhenGlobalKpiQueryFails() { _kpiReply = new NotificationKpiResponse( "k", false, "kpi down", 0, 0, 0, 0, null); var cut = Render(); cut.WaitForAssertion(() => Assert.Contains("kpi down", cut.Markup)); } [Fact] public void ShowsPerSiteError_WhenPerSiteKpiQueryFails() { // Only the per-site path errors — the global KPI reply stays successful. _perSiteReply = new PerSiteNotificationKpiResponse( "p", false, "per-site down", new List()); var cut = Render(); cut.WaitForAssertion(() => { Assert.Contains("Per-site KPIs unavailable: per-site down", cut.Markup); // The two error paths are isolated — the global KPI alert (whose markup // opens ">KPIs unavailable:", without the "Per-site " prefix) must not appear. Assert.DoesNotContain(">KPIs unavailable:", cut.Markup); }); } [Fact] public void ShowsPerSiteEmptyState_WhenNoSites() { _perSiteReply = new PerSiteNotificationKpiResponse( "p", true, null, new List()); var cut = Render(); cut.WaitForAssertion(() => Assert.Contains("No per-site activity", cut.Markup)); } 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 KPI message /// type with the test's currently-scripted response. /// private sealed class ScriptedOutboxActor : ReceiveActor { public ScriptedOutboxActor(NotificationKpisPageTests test) { Receive(_ => Sender.Tell(test._kpiReply)); Receive(_ => Sender.Tell(test._perSiteReply)); } } }