271 lines
11 KiB
C#
271 lines
11 KiB
C#
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 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.Commons.Types.Kpi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using NotificationKpisPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationKpis;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.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)),
|
|
});
|
|
|
|
// KPI-history trend service (K13 / T11). Default substitute returns a
|
|
// 3-point series for any (source, metric, scope, …) tuple so every chart
|
|
// renders a polyline; individual tests can re-stub it (e.g. to throw).
|
|
private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For<IKpiHistoryQueryService>();
|
|
|
|
private static IReadOnlyList<KpiSeriesPoint> SampleSeries() => new List<KpiSeriesPoint>
|
|
{
|
|
new(DateTime.UtcNow.AddHours(-3), 3),
|
|
new(DateTime.UtcNow.AddHours(-2), 5),
|
|
new(DateTime.UtcNow.AddHours(-1), 4),
|
|
};
|
|
|
|
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);
|
|
|
|
_kpiHistory.GetSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
|
.Returns(_ => Task.FromResult(SampleSeries()));
|
|
Services.AddSingleton(_kpiHistory);
|
|
|
|
var claims = new[]
|
|
{
|
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
|
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
|
};
|
|
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 ShowsPerSiteError_WhenPerSiteKpiQueryFails()
|
|
{
|
|
// Only the per-site path errors — the global KPI reply stays successful.
|
|
_perSiteReply = new PerSiteNotificationKpiResponse(
|
|
"p", false, "per-site down", new List<SiteNotificationKpiSnapshot>());
|
|
|
|
var cut = Render<NotificationKpisPage>();
|
|
|
|
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<SiteNotificationKpiSnapshot>());
|
|
|
|
var cut = Render<NotificationKpisPage>();
|
|
|
|
cut.WaitForAssertion(() => Assert.Contains("No per-site activity", cut.Markup));
|
|
}
|
|
|
|
[Fact]
|
|
public void RendersTrendCharts_WhenSeriesAvailable()
|
|
{
|
|
var cut = Render<NotificationKpisPage>();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
// The Trends section container is present …
|
|
Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]"));
|
|
// … and at least one trend chart renders an actual polyline (a chart
|
|
// needs IsAvailable + >= 2 points, which the substitute supplies).
|
|
Assert.NotEmpty(cut.FindAll("[data-test^=\"kpi-trend-\"]"));
|
|
Assert.Contains("<polyline", cut.Markup);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void PartialTrendFailure_IsolatesToTheFailingMetric()
|
|
{
|
|
// Per-metric isolation (K13 fixup): the substitute returns a known series
|
|
// for two metrics but THROWS for "parkedCount". The two surviving charts
|
|
// must still draw their polylines while only the failing chart degrades to
|
|
// the unavailable placeholder — one metric's failure no longer blanks all
|
|
// three (and does not discard the already-fetched siblings).
|
|
_kpiHistory.GetSeriesAsync(
|
|
Arg.Any<string>(), Arg.Is<string>(m => m == "parkedCount"), Arg.Any<string>(),
|
|
Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<DateTime>(),
|
|
Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
|
.Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ => throw new InvalidOperationException("parked metric down"));
|
|
|
|
var cut = Render<NotificationKpisPage>();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
// The page is still alive — KPI tiles render and the trends section
|
|
// never went fully blank.
|
|
Assert.Contains("Queue Depth", cut.Markup);
|
|
Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]"));
|
|
|
|
// The two surviving metrics (queueDepth, deliveredLastInterval) drew
|
|
// their polylines …
|
|
var queueChart = cut.Find("[data-test=\"kpi-trend-queue-depth\"]");
|
|
Assert.Contains("<polyline", queueChart.InnerHtml);
|
|
var deliveredChart = cut.Find("[data-test=\"kpi-trend-delivered-interval\"]");
|
|
Assert.Contains("<polyline", deliveredChart.InnerHtml);
|
|
|
|
// … while only the failing "Parked" chart degraded to the unavailable
|
|
// placeholder (no polyline, the short error message instead).
|
|
var parkedChart = cut.Find("[data-test=\"kpi-trend-parked\"]");
|
|
Assert.DoesNotContain("<polyline", parkedChart.InnerHtml);
|
|
Assert.Contains("Trend data unavailable.", parkedChart.InnerHtml);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TrendQueryFailure_DoesNotBreakPage()
|
|
{
|
|
// Best-effort contract: a throwing trend query must not surface an
|
|
// unhandled exception — the KPI tiles still render and the charts fall
|
|
// back to their unavailable placeholders.
|
|
_kpiHistory.GetSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
|
.Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ => throw new InvalidOperationException("kpi history down"));
|
|
|
|
var cut = Render<NotificationKpisPage>();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
// Tiles still render.
|
|
Assert.Contains("Queue Depth", cut.Markup);
|
|
Assert.Contains("7", cut.Markup);
|
|
// Trends section + chart placeholders are present; no polyline drawn.
|
|
Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]"));
|
|
Assert.NotEmpty(cut.FindAll("[data-test^=\"kpi-trend-\"]"));
|
|
Assert.DoesNotContain("<polyline", 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. Also handles the per-node
|
|
/// KPI request (T6: M5.2) with an empty-nodes success reply so the page can
|
|
/// complete initialization without a 30-second Ask timeout.
|
|
/// </summary>
|
|
private sealed class ScriptedOutboxActor : ReceiveActor
|
|
{
|
|
public ScriptedOutboxActor(NotificationKpisPageTests test)
|
|
{
|
|
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
|
|
Receive<PerSiteNotificationKpiRequest>(_ => Sender.Tell(test._perSiteReply));
|
|
Receive<PerNodeNotificationKpiRequest>(req => Sender.Tell(
|
|
new PerNodeNotificationKpiResponse(req.CorrelationId, Success: true, ErrorMessage: null,
|
|
Nodes: Array.Empty<NodeNotificationKpiSnapshot>())));
|
|
}
|
|
}
|
|
}
|