using System.Reflection; 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 DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Deployments; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// /// Regression tests for arch-review 2026-08-19 P6-11. Three independent initiators — /// search, paging, and the push/coalesce reload — can each have a /// LoadDataAsync flight in flight on the Deployment Status page at once. Every /// flight awaits its own round-trip to central MS SQL, so COMPLETION order is not /// CALL order: a slow flight that started first can resolve LAST and overwrite a /// newer grid the operator is already looking at. /// /// /// Separately, the filter-debounce timer used to write _currentPage = 1 /// directly on its raw callback thread — a /// Blazor threading violation, since component state may only be mutated on the /// renderer's synchronization context — and that write was NOT covered by the /// request-generation guard at all, so even a correctly-marshaled version of it /// could still reset the operator's page out from under a newer navigation. The fix /// resolves the target page into a local at fetch start and applies it only behind /// the same generation check as every other piece of state /// (LoadDataAsync(resetToFirstPage:)), so a stale filter-debounce flight is /// discarded exactly like a stale flight on any other initiator. /// /// /// /// Both races are driven deterministically with /// -backed repository stubs — never sleep-then-assert timing. Both tests reference /// methods/fields the fix introduces (_requestGeneration, /// DispatchFilterReloadAsync), so they fail — by compile error or reflection /// NRE — against the unfixed source, in addition to failing behaviourally once a /// same-shaped unfixed build is exercised; verified via a saved pre-fix copy of /// Deployments.razor, not git stash (repo-wide, unsafe to use in this /// worktree wave). /// /// public class DeploymentsRequestGenerationTests : BunitContext { private IDeploymentManagerRepository _deployRepo = null!; private DeploymentStatusNotifier _notifier = null!; /// /// One controllable response per call to QueryDeploymentListPageAsync, in /// call order. A queued lets the test choose /// exactly when each flight's fetch resolves, regardless of when it started. /// private readonly List> _pageResponses = new(); private int _pageCallIndex; private void RegisterServices() { _deployRepo = Substitute.For(); _notifier = new DeploymentStatusNotifier(NullLogger.Instance); _deployRepo.QueryDeploymentListPageAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => { var index = Interlocked.Increment(ref _pageCallIndex) - 1; return index < _pageResponses.Count ? _pageResponses[index].Task : Task.FromResult(DeploymentListPage.Empty); }); _deployRepo.GetDeploymentStatusCountsAsync( Arg.Any(), Arg.Any()) .Returns(DeploymentStatusCounts.Empty); Services.AddSingleton(_deployRepo); Services.AddSingleton(_notifier); var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.Name, "deployer") }, "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 TaskCompletionSource Gate() => new(TaskCreationOptions.RunContinuationsAsynchronously); private static TaskCompletionSource CompletedGate(DeploymentListPage page) { var tcs = Gate(); tcs.SetResult(page); return tcs; } private static DeploymentListRow Row(int id, string instance) => new( Id: id, DeploymentId: $"dep-{id:D4}", InstanceId: id, InstanceUniqueName: instance, SiteId: 1, Status: DeploymentStatus.Success, RevisionHash: $"rev{id:D4}hash", DeployedBy: "deployer", DeployedAt: DateTimeOffset.UtcNow.AddMinutes(-id), CompletedAt: DateTimeOffset.UtcNow, ErrorMessage: null); /// /// Invokes a private async method on the component via the renderer's dispatcher /// (mirroring the pattern in DebugViewConnectDisposeRaceTests) and hands /// back the resulting without awaiting it, so the caller can /// act while the flight is still in progress. /// private static async Task InvokePrivateAsync( IRenderedComponent cut, string methodName, params object?[] args) { var method = typeof(DeploymentsPage).GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic)!; Task? task = null; await cut.InvokeAsync(() => { task = (Task)method.Invoke(cut.Instance, args)!; }); return task!; } private static IReadOnlyList GetRows(DeploymentsPage page) => (IReadOnlyList)typeof(DeploymentsPage) .GetField("_rows", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(page)!; private static int GetCurrentPage(DeploymentsPage page) => (int)typeof(DeploymentsPage) .GetField("_currentPage", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(page)!; [Fact] public async Task StaleFlightArrivingAfterANewerOne_DoesNotOverwriteTheGrid() { RegisterServices(); var initialRows = Enumerable.Range(1, 25).Select(i => Row(i, $"Init-{i}")).ToList(); _pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // call #1: initial render var stale = Gate(); // call #2: page-2 flight — held open, the SLOW, STALE flight _pageResponses.Add(stale); // call #3: status-filter flight — resolves immediately, the FAST, NEWER flight _pageResponses.Add(CompletedGate( new DeploymentListPage(new[] { Row(999, "TileFilteredFreshRow") }, TotalCount: 1))); var cut = Render(); cut.WaitForAssertion(() => Assert.Contains(GetRows(cut.Instance), r => r.InstanceUniqueName == "Init-1")); // Flight A starts FIRST (a page click) and blocks awaiting `stale`. var flightA = await InvokePrivateAsync(cut, "GoToPageAsync", 2); Assert.False(flightA.IsCompleted); // Flight B starts SECOND (a status-tile click) while A is still in flight, and // resolves immediately — it is the newer, correct result. var flightB = await InvokePrivateAsync(cut, "SetStatusFilterAsync", DeploymentStatus.Failed); await flightB.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Contains(GetRows(cut.Instance), r => r.InstanceUniqueName == "TileFilteredFreshRow"); // Now let the STALE flight (page 2) resolve, arriving AFTER the newer one has // already applied. Awaiting flightA guarantees its continuation — including // the apply-time generation check under test — has fully run before we // assert, rather than racing bUnit's render pump. stale.SetResult(new DeploymentListPage(new[] { Row(2, "PageTwoStaleRow") }, TotalCount: 100)); await flightA.WaitAsync(TimeSpan.FromSeconds(5)); // Pre-fix, flight A's late arrival clobbers _rows with the stale page-2 data. // Post-fix, its generation is no longer current, so it discards its own // results and the newer grid survives untouched. Assert.DoesNotContain(GetRows(cut.Instance), r => r.InstanceUniqueName == "PageTwoStaleRow"); Assert.Contains(GetRows(cut.Instance), r => r.InstanceUniqueName == "TileFilteredFreshRow"); } /// /// The "does the timer refresh reset the page mid-search" half of P6-11. The /// filter-debounce ("timer") reload fires first, as if 500ms had elapsed after a /// keystroke, and is held open. While it is still in flight the operator /// navigates to page 2 by other means (a fresh, faster flight). The debounce /// flight — which intends to reset to page 1 — must not resolve later and /// clobber a page the operator has since, and more recently, navigated to. /// /// This is driven through DispatchFilterReloadAsync directly (rather than /// the outer OnFilterDebounceElapsed timer callback, which is fire-and- /// forget and gives no handle to await) so completion of the /// apply step under test — including the generation check — can be awaited /// deterministically instead of racing bUnit's render pump. /// /// [Fact] public async Task StaleFilterDebounceFlight_DoesNotResetThePageAfterANewerNavigation() { RegisterServices(); var initialRows = Enumerable.Range(1, 25).Select(i => Row(i, $"Init-{i}")).ToList(); _pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // call #1: initial render var staleSearch = Gate(); // call #2: the debounce-triggered ("timer") flight — held open, STALE _pageResponses.Add(staleSearch); // call #3: page-2 click — resolves immediately, NEWER. _pageResponses.Add(CompletedGate( new DeploymentListPage(new[] { Row(2, "PageTwoFreshRow") }, TotalCount: 100))); var cut = Render(); cut.WaitForAssertion(() => Assert.Contains(GetRows(cut.Instance), r => r.InstanceUniqueName == "Init-1")); // The filter-debounce ("timer") reload starts FIRST and blocks on `staleSearch`. var staleFlight = await InvokePrivateAsync(cut, "DispatchFilterReloadAsync"); Assert.False(staleFlight.IsCompleted); // While it is still in flight, the operator navigates to page 2 — a fresh // action that resolves immediately. var nav = await InvokePrivateAsync(cut, "GoToPageAsync", 2); await nav.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(2, GetCurrentPage(cut.Instance)); Assert.Contains(GetRows(cut.Instance), r => r.InstanceUniqueName == "PageTwoFreshRow"); // Now let the STALE debounce flight resolve, arriving after the navigation // already applied. Awaiting staleFlight guarantees its continuation — // including the apply-time generation check under test — has fully run // before we assert, rather than racing bUnit's render pump. staleSearch.SetResult( new DeploymentListPage(new[] { Row(1, "SearchPageOneStaleRow") }, TotalCount: 1)); await staleFlight.WaitAsync(TimeSpan.FromSeconds(5)); // Pre-fix, the stale flight unconditionally wrote _currentPage = 1 up front // and then overwrote the grid with page-1 data once it resolved — resetting // the operator's page mid an already-completed navigation. Post-fix, its // generation is no longer current, so both the page and the grid are left // untouched. Assert.Equal(2, GetCurrentPage(cut.Instance)); Assert.Contains(GetRows(cut.Instance), r => r.InstanceUniqueName == "PageTwoFreshRow"); Assert.DoesNotContain(GetRows(cut.Instance), r => r.InstanceUniqueName == "SearchPageOneStaleRow"); } }