using System.Security.Claims; using ZB.MOM.WW.ScadaBridge.Security; using Akka.Actor; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using HealthPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.Health; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// /// bUnit rendering tests for the Health Monitoring dashboard (Task 24). /// /// Scope: the Notification Outbox KPI tile row added to the Health dashboard. /// is an interface (mockable), but /// is a concrete class whose outbox calls /// route through an injected notification-outbox ; the /// tests reuse the scripted-actor seam established by the Notification Report /// page tests (see NotificationReportPageTests). /// public class HealthPageTests : BunitContext { private readonly ActorSystem _system = ActorSystem.Create("health-page-tests"); private readonly CommunicationService _comms; // Mutable scripted reply — individual tests can override before rendering. private NotificationKpiResponse _kpiReply = new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3, DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6)); // Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests // that target the Site Call tiles override this before rendering. private SiteCallKpiResponse _siteCallKpiReply = new("k", true, null, BufferedCount: 9, ParkedCount: 2, FailedLastInterval: 1, DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(3), StuckCount: 5); public HealthPageTests() { _comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))); _comms.SetNotificationOutbox(outbox); var siteCallAudit = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))); _comms.SetSiteCallAudit(siteCallAudit); Services.AddSingleton(_comms); var aggregator = Substitute.For(); aggregator.GetAllSiteStates() .Returns(new Dictionary()); Services.AddSingleton(aggregator); // M6 K16 — the Health page now injects IKpiHistoryQueryService to feed the // per-site Site Health Trends panel. Stub it with a known non-empty series // so the page resolves the dependency and the trend charts have data; the // dedicated trend tests below seed sites / override behaviour. var kpiHistory = Substitute.For(); kpiHistory.GetSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(SampleSeries())); Services.AddSingleton(kpiHistory); var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(Task.FromResult>(new List())); Services.AddSingleton(siteRepo); // Audit Log (#23) M7 Bundle E — the Health page now also fetches the // Audit KPI snapshot. Stub it with an empty point-in-time reading so // the existing assertions (Notification Outbox tiles, Online/Offline // counts) keep passing; tests that target the Audit tiles set their // own substitute. var auditService = Substitute.For(); auditService.GetKpiSnapshotAsync(Arg.Any()) .Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow))); Services.AddSingleton(auditService); var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.RoleClaimType, "Administrator"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); } [Fact] public void Renders_OutboxKpiTiles_WithValues() { var cut = Render(); // KPI data arrives via an async actor Ask after first render. cut.WaitForAssertion(() => { Assert.Contains("Notification Outbox", cut.Markup); Assert.Contains("Queue Depth", cut.Markup); Assert.Contains("Stuck", cut.Markup); Assert.Contains("Parked", cut.Markup); // KPI numeric values surface in the tiles. Assert.Contains(">12<", cut.Markup); // QueueDepth Assert.Contains(">4<", cut.Markup); // StuckCount Assert.Contains(">3<", cut.Markup); // ParkedCount }); } [Fact] public void RendersLinkToTheNotificationKpisPage() { var cut = Render(); var link = cut.Find("a[href='/notifications/kpis']"); Assert.Contains("View details", link.TextContent); } [Fact] public void Renders_AuditKpiTiles_WithValues() { // Override the default empty snapshot — this test wants concrete values // to land in the three Audit tiles. var auditService = Substitute.For(); auditService.GetKpiSnapshotAsync(Arg.Any()) .Returns(Task.FromResult(new AuditLogKpiSnapshot( TotalEventsLastHour: 250, ErrorEventsLastHour: 5, BacklogTotal: 17, AsOfUtc: DateTime.UtcNow))); Services.AddSingleton(auditService); var cut = Render(); cut.WaitForAssertion(() => { // The three audit tiles render at the documented data-test selectors. Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup); Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup); Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup); // Volume shows the formatted thousand-separator value. Assert.Contains("250", cut.Markup); // Backlog renders 17. Assert.Contains("17", cut.Markup); }); } [Fact] public void Renders_SiteCallKpiTiles_WithValues() { var cut = Render(); // KPI data arrives via an async actor Ask after first render. cut.WaitForAssertion(() => { Assert.Contains("Site Calls", cut.Markup); // The three Site Call tiles render at the documented data-test selectors. Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup); Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup); Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup); // KPI numeric values surface in the tiles. Assert.Contains(">9<", cut.Markup); // BufferedCount Assert.Contains(">5<", cut.Markup); // StuckCount Assert.Contains(">2<", cut.Markup); // ParkedCount }); } [Fact] public void RendersLinkToTheSiteCallsReportPage() { var cut = Render(); var link = cut.Find("a[href='/site-calls/report']"); Assert.Contains("View details", link.TextContent); } [Fact] public void SiteCallKpiFailure_ShowsGracefulFallback() { _siteCallKpiReply = new SiteCallKpiResponse( "k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0); var cut = Render(); cut.WaitForAssertion(() => { // Failure must not crash the page; tiles fall back to a dash and the // inline error message surfaces. Assert.Contains("Site Calls", cut.Markup); Assert.Contains("Site Call KPIs unavailable", cut.Markup); Assert.Contains("site call repository unavailable", cut.Markup); Assert.Contains(">—<", cut.Markup); }); } [Fact] public void OutboxKpiFailure_ShowsGracefulFallback() { _kpiReply = new NotificationKpiResponse( "k", false, "outbox repository unavailable", 0, 0, 0, 0, null); var cut = Render(); cut.WaitForAssertion(() => { // Failure must not crash the page; tiles fall back to a dash. Assert.Contains("Notification Outbox", cut.Markup); Assert.Contains("Queue Depth", cut.Markup); Assert.Contains(">—<", cut.Markup); }); } [Fact] public void Renders_SiteHealthTrends_PanelAndChart_ForSelectedSite() { // Seed one site so the trend panel's selector has an option and the // default-site load produces charts. SeedSites("site-a"); var cut = Render(); cut.WaitForAssertion(() => { // The panel + its site selector render at the documented hooks. Assert.Contains("data-test=\"site-health-trends\"", cut.Markup); Assert.Contains("data-test=\"site-health-trends-site\"", cut.Markup); // The four metric charts render (the shared KpiTrendChart slug hook), // and the seeded non-empty series draws a polyline. The "S&F Buffer // Depth" title slugifies to "s-f-buffer-depth" (the & and the spaces // each collapse to a dash) — see KpiTrendChart.Slugify. Assert.Contains("kpi-trend-connections-down", cut.Markup); Assert.Contains("kpi-trend-dead-letters", cut.Markup); Assert.Contains("kpi-trend-script-errors", cut.Markup); Assert.Contains("kpi-trend-s-f-buffer-depth", cut.Markup); Assert.Contains("(); faulting.GetSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns>>(_ => throw new InvalidOperationException("kpi history unavailable")); Services.AddSingleton(faulting); var cut = Render(); cut.WaitForAssertion(() => { // No unhandled exception: the core dashboard tiles still render, and // the panel falls back to the per-chart unavailable placeholder. Assert.Contains("Notification Outbox", cut.Markup); Assert.Contains("data-test=\"site-health-trends\"", cut.Markup); Assert.Contains("Trend data unavailable.", cut.Markup); }); } // Re-seeds the aggregator substitute so the trend panel's site selector has // options. Each site id maps to a minimal online SiteHealthState (a null // report is fine — the trend panel keys off the site ids, not the report). private void SeedSites(params string[] siteIds) { var aggregator = Substitute.For(); var states = siteIds.ToDictionary( id => id, id => new SiteHealthState { SiteId = id, IsOnline = true, LastHeartbeatAt = DateTimeOffset.UtcNow, }); aggregator.GetAllSiteStates() .Returns(new Dictionary(states)); Services.AddSingleton(aggregator); } // A known non-empty (≥2-point) series so KpiTrendChart renders a polyline // rather than the single-sample / unavailable placeholder. private static IReadOnlyList SampleSeries() { var baseUtc = DateTime.UtcNow.AddHours(-24); return new List { new(baseUtc, 1), new(baseUtc.AddHours(6), 3), new(baseUtc.AddHours(12), 2), new(baseUtc.AddHours(18), 5), }; } 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 the KPI request /// with the test's currently-scripted response. /// private sealed class ScriptedOutboxActor : ReceiveActor { public ScriptedOutboxActor(HealthPageTests test) { Receive(_ => Sender.Tell(test._kpiReply)); } } /// /// Stand-in for the Site Call Audit actor. Replies to the KPI request 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 Health page /// can complete initialization without a 30-second Ask timeout. /// private sealed class ScriptedSiteCallAuditActor : ReceiveActor { public ScriptedSiteCallAuditActor(HealthPageTests test) { Receive(_ => Sender.Tell(test._siteCallKpiReply)); Receive(req => Sender.Tell( new PerNodeSiteCallKpiResponse(req.CorrelationId, Success: true, ErrorMessage: null, Nodes: Array.Empty()))); } } }