feat(central-ui): Notification KPIs page with per-site breakdown
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the Notification KPIs page.
|
||||
///
|
||||
/// Testability note: <see cref="CommunicationService"/> 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 <see cref="IActorRef"/> (the
|
||||
/// notification-outbox proxy), so the tests wire a real, lightweight
|
||||
/// <see cref="ActorSystem"/> with a scripted <see cref="ReceiveActor"/> that
|
||||
/// answers both <see cref="NotificationKpiRequest"/> and
|
||||
/// <see cref="PerSiteNotificationKpiRequest"/> — the same seam
|
||||
/// <c>SetNotificationOutbox</c> exists for.
|
||||
/// </summary>
|
||||
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<SiteNotificationKpiSnapshot>
|
||||
{
|
||||
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<CommunicationService>.Instance);
|
||||
|
||||
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
|
||||
_comms.SetNotificationOutbox(outbox);
|
||||
|
||||
Services.AddSingleton(_comms);
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||
{
|
||||
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<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_RequiresDeploymentPolicy()
|
||||
{
|
||||
var attr = typeof(NotificationKpisPage)
|
||||
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
|
||||
.Cast<AuthorizeAttribute>()
|
||||
.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(attr);
|
||||
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RendersGlobalTilesAndPerSiteRows()
|
||||
{
|
||||
var cut = Render<NotificationKpisPage>();
|
||||
|
||||
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<NotificationKpisPage>();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("kpi down", cut.Markup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowsPerSiteEmptyState_WhenNoSites()
|
||||
{
|
||||
_perSiteReply = new PerSiteNotificationKpiResponse(
|
||||
"p", true, null, new List<SiteNotificationKpiSnapshot>());
|
||||
|
||||
var cut = Render<NotificationKpisPage>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the notification-outbox actor. Replies to each KPI message
|
||||
/// type with the test's currently-scripted response.
|
||||
/// </summary>
|
||||
private sealed class ScriptedOutboxActor : ReceiveActor
|
||||
{
|
||||
public ScriptedOutboxActor(NotificationKpisPageTests test)
|
||||
{
|
||||
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
|
||||
Receive<PerSiteNotificationKpiRequest>(_ => Sender.Tell(test._perSiteReply));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user