using System.Security.Claims; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.Security; using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Deployments; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// /// Coverage for the Deployment Status page's server-side paging, server-computed /// status tiles and debounced filter input (residual R3). /// /// /// Before this change the page read EVERY deployment record and EVERY instance on /// each load, then site-scoped, sorted, counted the four tiles and sliced a 25-row /// page in the Blazor circuit's memory. These tests pin the replacement contract: /// the page asks the repository for ONE page plus the filtered total, asks for the /// tile counts separately, pushes the site-scope grant into the query rather than /// intersecting in memory, and never touches the instance repository. /// /// public class DeploymentsServerPagingTests : BunitContext { private IDeploymentManagerRepository _deployRepo = null!; private ITemplateEngineRepository _templateRepo = null!; private DeploymentStatusNotifier _notifier = null!; /// Records every filter/page/pageSize triple the page queried with. private readonly List<(DeploymentListFilter Filter, int Page, int PageSize)> _pageCalls = new(); private void RegisterServices( DeploymentListPage? page = null, DeploymentStatusCounts? counts = null, string[]? siteClaims = null) { _deployRepo = Substitute.For(); _templateRepo = Substitute.For(); _notifier = new DeploymentStatusNotifier(NullLogger.Instance); var resultPage = page ?? DeploymentListPage.Empty; _deployRepo.QueryDeploymentListPageAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ci => { _pageCalls.Add((ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2))); return Task.FromResult(resultPage); }); _deployRepo.GetDeploymentStatusCountsAsync( Arg.Any(), Arg.Any()) .Returns(counts ?? DeploymentStatusCounts.Empty); Services.AddSingleton(_deployRepo); Services.AddSingleton(_templateRepo); Services.AddSingleton(_notifier); var claims = new List { new(ClaimTypes.Name, "deployer") }; foreach (var siteId in siteClaims ?? Array.Empty()) { claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId)); } var identity = new ClaimsIdentity(claims, "TestCookie"); var stubAuth = new StubAuthStateProvider( new AuthenticationState(new ClaimsPrincipal(identity))); Services.AddSingleton(stubAuth); Services.AddScoped(_ => new SiteScopeService(stubAuth)); } private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; public StubAuthStateProvider(AuthenticationState state) => _state = state; public override Task GetAuthenticationStateAsync() => Task.FromResult(_state); } private static DeploymentListRow Row(int id, string instance, DeploymentStatus status = DeploymentStatus.Success) => new( Id: id, DeploymentId: $"dep-{id:D4}", InstanceId: id, InstanceUniqueName: instance, SiteId: 1, Status: status, RevisionHash: $"rev{id:D4}hash", DeployedBy: "deployer", DeployedAt: DateTimeOffset.UtcNow.AddMinutes(-id), CompletedAt: DateTimeOffset.UtcNow, ErrorMessage: null); // ── Paging ──────────────────────────────────────────────────────────────── [Fact] public void InitialLoad_RequestsPageOneFromTheServer_AndNeverReadsEveryInstance() { RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); Render(); var call = Assert.Single(_pageCalls); Assert.Equal(1, call.Page); Assert.Equal(25, call.PageSize); // The full-instance-table read that used to build the name/site maps is gone. _templateRepo.DidNotReceiveWithAnyArgs().GetAllInstancesAsync(); } [Fact] public void TotalCountFromServer_DrivesThePager_NotTheRowsOnScreen() { // One page of 25 rows out of 130 total => 6 pages. Under the old // client-materialized model the pager could only ever see the rows it held. var rows = Enumerable.Range(1, 25).Select(i => Row(i, $"Inst-{i}")).ToList(); RegisterServices(new DeploymentListPage(rows, TotalCount: 130)); var cut = Render(); Assert.Contains("130 matching deployments", cut.Find("[data-test=\"deploy-result-count\"]").TextContent); // Windowed pager: first, last and a radius around the current page. var pageButtons = cut.FindAll("ul.pagination li.page-item").Count; Assert.True(pageButtons > 2, "Pager must render page buttons, not just Previous/Next."); Assert.Contains("6", cut.Find("ul.pagination").TextContent); } [Fact] public void NextPage_RequeriesTheServerForPageTwo() { var rows = Enumerable.Range(1, 25).Select(i => Row(i, $"Inst-{i}")).ToList(); RegisterServices(new DeploymentListPage(rows, TotalCount: 130)); var cut = Render(); _pageCalls.Clear(); cut.Find("[data-test=\"deploy-page-next\"]").Click(); var call = Assert.Single(_pageCalls); Assert.Equal(2, call.Page); } [Fact] public void SinglePageResult_RendersNoPager() { RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); var cut = Render(); Assert.Empty(cut.FindAll("ul.pagination")); } // ── Status tiles ────────────────────────────────────────────────────────── [Fact] public void Tiles_RenderServerComputedCounts_NotCountsOfTheVisiblePage() { // The page holds ONE row, but the server reports fleet-wide counts. Under // the old model the tiles were Count() over the in-memory list, so paging // would have silently reduced them to per-page counts. RegisterServices( new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 400), new DeploymentStatusCounts(Pending: 7, InProgress: 3, Success: 380, Failed: 10)); var cut = Render(); Assert.Contains("7", cut.Find("[data-test=\"deploy-tile-pending\"]").TextContent); Assert.Contains("3", cut.Find("[data-test=\"deploy-tile-inprogress\"]").TextContent); Assert.Contains("380", cut.Find("[data-test=\"deploy-tile-success\"]").TextContent); Assert.Contains("10", cut.Find("[data-test=\"deploy-tile-failed\"]").TextContent); Assert.Contains("400", cut.Find("[data-test=\"deploy-tile-all\"]").TextContent); } [Fact] public void ClickingAStatusTile_AppliesTheFilterServerSide_AndResetsToPageOne() { var rows = Enumerable.Range(1, 25).Select(i => Row(i, $"Inst-{i}")).ToList(); RegisterServices(new DeploymentListPage(rows, TotalCount: 130)); var cut = Render(); cut.Find("[data-test=\"deploy-page-next\"]").Click(); _pageCalls.Clear(); cut.Find("[data-test=\"deploy-tile-failed\"]").Click(); var call = Assert.Single(_pageCalls); Assert.Equal(DeploymentStatus.Failed, call.Filter.Status); Assert.Equal(1, call.Page); Assert.Equal("true", cut.Find("[data-test=\"deploy-tile-failed\"]").GetAttribute("aria-pressed")); } [Fact] public void ClickingTheActiveTileAgain_ClearsTheStatusFilter() { RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); var cut = Render(); cut.Find("[data-test=\"deploy-tile-pending\"]").Click(); _pageCalls.Clear(); cut.Find("[data-test=\"deploy-tile-pending\"]").Click(); var call = Assert.Single(_pageCalls); Assert.Null(call.Filter.Status); Assert.Equal("true", cut.Find("[data-test=\"deploy-tile-all\"]").GetAttribute("aria-pressed")); } // ── Site scoping ────────────────────────────────────────────────────────── [Fact] public void SystemWideUser_QueriesWithNoSiteScope() { RegisterServices(); Render(); Assert.Null(Assert.Single(_pageCalls).Filter.SiteIdScope); } [Fact] public void SiteScopedUser_PushesThePermittedSiteIdsIntoTheQuery() { RegisterServices(siteClaims: new[] { "3", "7" }); Render(); var scope = Assert.Single(_pageCalls).Filter.SiteIdScope; Assert.NotNull(scope); Assert.Equal(new[] { 3, 7 }, scope!.OrderBy(x => x)); } // ── Filter debounce ─────────────────────────────────────────────────────── [Fact] public void SearchInput_IsDebounced_SoABurstOfKeystrokesIssuesOneQuery() { RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); var cut = Render(); _pageCalls.Clear(); // Type "Reactor" one character at a time, well inside the 500ms window. const string term = "Reactor"; for (var i = 1; i <= term.Length; i++) { cut.Find("[data-test=\"deploy-search\"]").Input(term[..i]); } // No query yet — the trailing debounce has not elapsed. Assert.Empty(_pageCalls); cut.WaitForAssertion(() => Assert.NotEmpty(_pageCalls), TimeSpan.FromSeconds(3)); Thread.Sleep(300); var call = Assert.Single(_pageCalls); Assert.Equal(term, call.Filter.Search); Assert.Equal(1, call.Page); } [Fact] public void ClearFilters_ResetsSearchAndStatus_AndRequeries() { RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); var cut = Render(); cut.Find("[data-test=\"deploy-tile-failed\"]").Click(); _pageCalls.Clear(); cut.Find("[data-test=\"deploy-clear-filters\"]").Click(); var call = Assert.Single(_pageCalls); Assert.Null(call.Filter.Status); Assert.Null(call.Filter.NormalizedSearch); Assert.Equal(1, call.Page); } [Fact] public void ClearFilters_IsEnabledAsSoonAsTheOperatorTypes_NotOnlyOnceTheDebounceFires() { RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); var cut = Render(); Assert.True(cut.Find("[data-test=\"deploy-clear-filters\"]").HasAttribute("disabled")); cut.Find("[data-test=\"deploy-search\"]").Input("Rea"); // Still inside the 500ms window — the escape hatch must not be dead. Assert.False(cut.Find("[data-test=\"deploy-clear-filters\"]").HasAttribute("disabled")); } [Fact] public void DisposeWithAnArmedFilterTimer_DoesNotQuery() { RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); var cut = Render(); cut.Find("[data-test=\"deploy-search\"]").Input("abc"); cut.Instance.Dispose(); _pageCalls.Clear(); Thread.Sleep(900); Assert.Empty(_pageCalls); } // ── Rendering ───────────────────────────────────────────────────────────── [Fact] public void InstanceName_ComesFromTheJoinedRow_NotAClientSideLookupTable() { RegisterServices(new DeploymentListPage(new[] { Row(42, "Line3.Filler") }, TotalCount: 1)); var cut = Render(); Assert.Contains("Line3.Filler", cut.Markup); } }