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 the Deployment Status push coalescing (arch-review WP2.4). The /// notifier fires per status write, so a multi-instance deploy produced a stampede of /// reloads per circuit. The reload is leading-edge debounced: the first push after an /// idle gap is still immediate, and a burst behind it collapses into one trailing reload. /// /// /// Server-side paging (residual R3) shrank what one reload costs but not how many /// arrive, so the coalescing still has to hold. The assertions now count /// QueryDeploymentListPageAsync calls instead of the deleted /// GetAllDeploymentRecordsAsync. /// /// public class DeploymentsReloadDebounceTests : BunitContext { private IDeploymentManagerRepository _deployRepo = null!; private DeploymentStatusNotifier _notifier = null!; private void RegisterServices() { _deployRepo = Substitute.For(); _notifier = new DeploymentStatusNotifier(NullLogger.Instance); _deployRepo.QueryDeploymentListPageAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(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 void AssertReloaded(IRenderedComponent cut) => cut.WaitForAssertion(() => _deployRepo.Received().QueryDeploymentListPageAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())); [Fact] public void BurstOfStatusWrites_CollapsesIntoFarFewerReloads() { RegisterServices(); var cut = Render(); AssertReloaded(cut); _deployRepo.ClearReceivedCalls(); // A 40-instance site deploy: every status write raises the notifier. for (var i = 0; i < 40; i++) { _notifier.NotifyStatusChanged( new DeploymentStatusChange($"dep-{i}", 1, DeploymentStatus.InProgress)); } // Leading edge fires at once; the rest ride one trailing reload. AssertReloaded(cut); // Let the trailing window close before counting. Thread.Sleep(900); var reloads = _deployRepo.ReceivedCalls() .Count(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync)); Assert.InRange(reloads, 1, 4); } [Fact] public void FirstPushAfterIdle_ReloadsImmediately() { RegisterServices(); var cut = Render(); AssertReloaded(cut); _deployRepo.ClearReceivedCalls(); // Idle since the initial load — the leading edge must not wait out the window. Thread.Sleep(600); _notifier.NotifyStatusChanged( new DeploymentStatusChange("dep-1", 1, DeploymentStatus.Success)); cut.WaitForAssertion( () => _deployRepo.Received().QueryDeploymentListPageAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()), TimeSpan.FromMilliseconds(400)); } [Fact] public void DisposeDuringACoalesceWindow_DoesNotReload() { RegisterServices(); var cut = Render(); AssertReloaded(cut); // Two pushes: the first takes the leading edge, the second arms the trailing timer. _notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-1", 1, DeploymentStatus.InProgress)); _notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-2", 1, DeploymentStatus.InProgress)); cut.Instance.Dispose(); _deployRepo.ClearReceivedCalls(); // The armed timer must be disposed with the component, not fire against it. Thread.Sleep(900); _deployRepo.DidNotReceive().QueryDeploymentListPageAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } }