using System.Security.Claims; using Akka.Actor; using Bunit; using Microsoft.AspNetCore.Authorization; 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); }); } protected override void Dispose(bool disposing) { if (disposing) { _system.Terminate().Wait(TimeSpan.FromSeconds(5)); } base.Dispose(disposing); } /// /// Stand-in for the Site Call Audit actor. Replies to each message type with /// the test's currently-scripted response. /// private sealed class ScriptedSiteCallAuditActor : ReceiveActor { public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test) { Receive(r => { test._queryRequests.Add(r); Sender.Tell(test._queryReply); }); Receive(r => { test._retryRequests.Add(r); Sender.Tell(test._retryReply); }); Receive(r => { test._discardRequests.Add(r); Sender.Tell(test._discardReply); }); } } /// A dialog service that auto-confirms, so action paths run end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { public Task ConfirmAsync(string title, string message, bool danger = false) => Task.FromResult(true); public Task PromptAsync( string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult(null); } }