using System.Security.Claims; using Akka.Actor; using Bunit; using Bunit.TestDoubles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ScadaLink.CentralUI.Components.Shared; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Messages.Audit; using ScadaLink.Communication; using ScadaLink.Security; using SiteCallsReportPage = ScadaLink.CentralUI.Components.Pages.SiteCalls.SiteCallsReport; namespace ScadaLink.CentralUI.Tests.Pages; /// /// bUnit rendering tests for the Site Calls report page (Site Call Audit #22). /// /// Testability note: is a concrete class with /// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all /// route through an injected (the Site Call Audit proxy), /// so the tests wire a real, lightweight with a scripted /// that replies with fixed responses — the same seam /// SetSiteCallAudit exists for. Mirrors . /// public class SiteCallsReportPageTests : BunitContext { private readonly ActorSystem _system = ActorSystem.Create("site-calls-report-tests"); private readonly CommunicationService _comms; private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid FailedId = Guid.Parse("22222222-2222-2222-2222-222222222222"); // Mutable scripted reply — individual tests can override before rendering. private SiteCallQueryResponse _queryReply = new( "q", true, null, new List { new(ParkedId, "plant-a", "ApiOutbound", "ERP.GetOrder", "Parked", RetryCount: 3, LastError: "HTTP 503 from ERP", HttpStatus: 503, CreatedAtUtc: DateTime.UtcNow.AddMinutes(-30), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-5), TerminalAtUtc: null, IsStuck: true), new(FailedId, "plant-b", "DbOutbound", "Historian.Write", "Failed", RetryCount: 1, LastError: "constraint violation", HttpStatus: null, CreatedAtUtc: DateTime.UtcNow.AddHours(-2), UpdatedAtUtc: DateTime.UtcNow.AddHours(-2), TerminalAtUtc: DateTime.UtcNow.AddHours(-2), IsStuck: false), }, NextAfterCreatedAtUtc: null, NextAfterId: null); // Records the most recent retry/discard requests the actor received. private readonly List _queryRequests = new(); private readonly List _retryRequests = new(); private readonly List _discardRequests = new(); // Scripted relay responses — overridable per test. private RetrySiteCallResponse _retryReply = new("q", SiteCallRelayOutcome.Applied, true, true, null); private DiscardSiteCallResponse _discardReply = new("q", SiteCallRelayOutcome.Applied, true, true, null); public SiteCallsReportPageTests() { _comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))); _comms.SetSiteCallAudit(auditProxy); 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(SiteCallsReportPage) .GetCustomAttributes(typeof(AuthorizeAttribute), true) .Cast() .FirstOrDefault(); Assert.NotNull(attr); Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); } [Fact] public void Renders_SiteCallRows() { var cut = Render(); cut.WaitForAssertion(() => { Assert.Contains("ERP.GetOrder", cut.Markup); Assert.Contains("Historian.Write", cut.Markup); }); } [Fact] public void StuckRow_IsBadged() { var cut = Render(); cut.WaitForAssertion(() => { var stuckRow = cut.FindAll("tbody tr") .First(r => r.TextContent.Contains("ERP.GetOrder")); Assert.Contains("badge", stuckRow.InnerHtml); Assert.Contains("Stuck", stuckRow.TextContent); }); } [Fact] public void RetryDiscardButtons_ShownOnlyOnParkedRows() { var cut = Render(); cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); var parkedRow = cut.FindAll("tbody tr") .First(r => r.TextContent.Contains("ERP.GetOrder")); var failedRow = cut.FindAll("tbody tr") .First(r => r.TextContent.Contains("Historian.Write")); // The Parked row carries Retry + Discard buttons. Assert.Contains(parkedRow.QuerySelectorAll("button"), b => b.TextContent.Contains("Retry")); Assert.Contains(parkedRow.QuerySelectorAll("button"), b => b.TextContent.Contains("Discard")); // The Failed row carries neither — Retry/Discard are Parked-only. Assert.DoesNotContain(failedRow.QuerySelectorAll("button"), b => b.TextContent.Contains("Retry")); Assert.DoesNotContain(failedRow.QuerySelectorAll("button"), b => b.TextContent.Contains("Discard")); } [Fact] public void ClickRetry_OnParkedRow_RelaysRetryToOwningSite() { var cut = Render(); cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); var parkedRow = cut.FindAll("tbody tr") .First(r => r.TextContent.Contains("ERP.GetOrder")); var retryButton = parkedRow.QuerySelectorAll("button") .First(b => b.TextContent.Contains("Retry")); retryButton.Click(); cut.WaitForAssertion(() => { Assert.Single(_retryRequests); Assert.Equal(ParkedId, _retryRequests[0].TrackedOperationId); // The relay carries the owning site so central can route it. Assert.Equal("plant-a", _retryRequests[0].SourceSite); }); } [Fact] public void ClickDiscard_OnParkedRow_RelaysDiscardToOwningSite() { var cut = Render(); cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); var parkedRow = cut.FindAll("tbody tr") .First(r => r.TextContent.Contains("ERP.GetOrder")); var discardButton = parkedRow.QuerySelectorAll("button") .First(b => b.TextContent.Contains("Discard")); discardButton.Click(); cut.WaitForAssertion(() => { Assert.Single(_discardRequests); Assert.Equal(ParkedId, _discardRequests[0].TrackedOperationId); Assert.Equal("plant-a", _discardRequests[0].SourceSite); }); } [Fact] public void RetryRelay_SiteUnreachable_ShowsDistinctMessage() { // The relay never reached the owning site — a transient transport // condition, surfaced distinctly from a generic failure. _retryReply = new RetrySiteCallResponse( "q", SiteCallRelayOutcome.SiteUnreachable, Success: false, SiteReachable: false, ErrorMessage: "Site plant-a is offline — relay not delivered."); var cut = Render(); cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); var parkedRow = cut.FindAll("tbody tr") .First(r => r.TextContent.Contains("ERP.GetOrder")); parkedRow.QuerySelectorAll("button") .First(b => b.TextContent.Contains("Retry")) .Click(); cut.WaitForAssertion(() => Assert.Contains("offline", cut.Markup)); } [Fact] public void QueryFailure_ShowsErrorMessage() { _queryReply = new SiteCallQueryResponse( "q", false, "site call query backend unavailable", new List(), null, null); var cut = Render(); cut.WaitForAssertion(() => Assert.Contains("site call query backend unavailable", cut.Markup)); } // ───────────────────────────────────────────────────────────────────────── // Drill-in — every row carries a "View audit history" link to // /audit/log?correlationId={TrackedOperationId}. // ───────────────────────────────────────────────────────────────────────── [Fact] public void SiteCallRow_ViewAuditHistory_Link_HasCorrectHref() { var cut = Render(); cut.WaitForAssertion(() => { // Both rows (Parked + Failed) surface the link — the drill-in is // row-scope, not status-scope. var parkedRow = cut.FindAll("tbody tr") .First(r => r.TextContent.Contains("ERP.GetOrder")); var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]"); Assert.NotNull(link); Assert.Equal( $"/audit/log?correlationId={ParkedId}", link!.GetAttribute("href")); Assert.Contains("View audit history", link.TextContent); var failedRow = cut.FindAll("tbody tr") .First(r => r.TextContent.Contains("Historian.Write")); var failedLink = failedRow.QuerySelector("a[data-test^=\"audit-link-\"]"); Assert.NotNull(failedLink); Assert.Equal( $"/audit/log?correlationId={FailedId}", failedLink!.GetAttribute("href")); }); } // ───────────────────────────────────────────────────────────────────────── // Keyset paging — Next is driven by the response's NextAfter* cursor, not by // page numbers; the request echoes the cursor back to the actor. // ───────────────────────────────────────────────────────────────────────── [Fact] public void Paging_NextButton_HiddenWhenNoFurtherPage() { // The default reply returns 2 rows and no NextAfter* cursor — there is no // further page, so Next is disabled. var cut = Render(); cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); var next = cut.Find("[data-test='site-calls-next']"); Assert.True(next.HasAttribute("disabled")); var prev = cut.Find("[data-test='site-calls-prev']"); Assert.True(prev.HasAttribute("disabled")); } [Fact] public void Paging_NextButton_AdvancesUsingKeysetCursor() { // A full page (PageSize=50 rows) plus a NextAfter* cursor: Next is live // and, when clicked, the follow-up query carries that cursor. var firstPage = new List(); for (var i = 0; i < 50; i++) { firstPage.Add(new SiteCallSummary( Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered", RetryCount: 0, LastError: null, HttpStatus: 200, CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i), TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false)); } var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999"); _queryReply = new SiteCallQueryResponse( "q", true, null, firstPage, NextAfterCreatedAtUtc: cursorCreated, NextAfterId: cursorId); var cut = Render(); cut.WaitForState(() => cut.Markup.Contains("ERP.Op0")); var next = cut.Find("[data-test='site-calls-next']"); Assert.False(next.HasAttribute("disabled")); next.Click(); cut.WaitForAssertion(() => { // Two queries fired: the initial load and the Next click. The second // carries the keyset cursor echoed by the first response. Assert.Equal(2, _queryRequests.Count); Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc); Assert.Equal(cursorId, _queryRequests[1].AfterId); }); } [Fact] public void Paging_PrevButton_PopsBackStackAndRefetchesPriorCursor() { // The keyset back-stack is the trickiest paging path: Next pushes the // current cursor, Prev pops it and refetches that prior page. Page 1 is // opened with the empty (null, null) cursor, so after Next→Previous the // follow-up query must carry (null, null) again. var firstPage = new List(); for (var i = 0; i < 50; i++) { firstPage.Add(new SiteCallSummary( Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered", RetryCount: 0, LastError: null, HttpStatus: 200, CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i), TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false)); } var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999"); _queryReply = new SiteCallQueryResponse( "q", true, null, firstPage, NextAfterCreatedAtUtc: cursorCreated, NextAfterId: cursorId); var cut = Render(); cut.WaitForState(() => cut.Markup.Contains("ERP.Op0")); // Step forward — query 2 carries the keyset cursor. var next = cut.Find("[data-test='site-calls-next']"); next.Click(); cut.WaitForAssertion(() => { Assert.Equal(2, _queryRequests.Count); Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc); }); // Previous is now live (the back-stack has one entry); click it. var prev = cut.Find("[data-test='site-calls-prev']"); Assert.False(prev.HasAttribute("disabled")); prev.Click(); cut.WaitForAssertion(() => { // Query 3 is the Previous refetch — the back-stack popped the page-1 // cursor, which is the empty (null, null) first-page cursor. Assert.Equal(3, _queryRequests.Count); Assert.Null(_queryRequests[2].AfterCreatedAtUtc); Assert.Null(_queryRequests[2].AfterId); // Back on page 1, the back-stack is empty again so Previous re-disables. Assert.True(cut.Find("[data-test='site-calls-prev']").HasAttribute("disabled")); }); } [Fact] public void RetryRelay_NotParked_ShowsInfoMessage_AndExactlyOneToast() { // NotParked is a definitive answer from the site (nothing to do), not a // failure — it surfaces as a single info toast, never an error. This // also guards the single-toast contract: a non-Applied outcome must // produce exactly one toast. _retryReply = new RetrySiteCallResponse( "q", SiteCallRelayOutcome.NotParked, Success: false, SiteReachable: true, ErrorMessage: "The cached call is no longer parked."); var cut = Render(); cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); var parkedRow = cut.FindAll("tbody tr") .First(r => r.TextContent.Contains("ERP.GetOrder")); parkedRow.QuerySelectorAll("button") .First(b => b.TextContent.Contains("Retry")) .Click(); cut.WaitForAssertion(() => { Assert.Contains("no longer parked", cut.Markup); // Exactly one toast — the ShowRelayOutcome switch owns the single // toast; no second (error) toast piggybacks on the same response. Assert.Single(cut.FindAll(".toast")); }); } // ───────────────────────────────────────────────────────────────────────── // Query-string drill-in — the Health-dashboard Site Call KPI tiles deep-link // here with ?status=Parked (Parked tile) and ?stuck=true (Stuck tile). The // params must seed the filter BEFORE the first query so the initial grid load // is already filtered, and the filter card controls must reflect the values. // ───────────────────────────────────────────────────────────────────────── [Fact] public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked() { // The Parked KPI tile emits ?status=Parked — set the URI before render. var nav = (BunitNavigationManager)Services.GetRequiredService(); nav.NavigateTo("/site-calls/report?status=Parked"); var cut = Render(); cut.WaitForAssertion(() => { // The first (and only) query the page issues carries the Parked // status filter — the grid load is pre-filtered, not unfiltered. Assert.Single(_queryRequests); Assert.Equal("Parked", _queryRequests[0].StatusFilter); // The Status