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.Entities.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; 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). One /// notifier callback reloads EVERY deployment record plus EVERY instance, and the notifier /// fires per status write — so a multi-instance deploy produced a stampede of full reloads /// per circuit. The reload is now leading-edge debounced: the first push after an idle gap /// is still immediate, and a burst behind it collapses into one trailing reload. /// public class DeploymentsReloadDebounceTests : BunitContext { private IDeploymentManagerRepository _deployRepo = null!; private ITemplateEngineRepository _templateRepo = null!; private DeploymentStatusNotifier _notifier = null!; private void RegisterServices() { _deployRepo = Substitute.For(); _templateRepo = Substitute.For(); _notifier = new DeploymentStatusNotifier(NullLogger.Instance); _templateRepo.GetAllInstancesAsync(Arg.Any()) .Returns(new List { new("Inst-1") { Id = 1, SiteId = 1 } }); _deployRepo.GetAllDeploymentRecordsAsync(Arg.Any()) .Returns(new List()); Services.AddSingleton(_deployRepo); Services.AddSingleton(_templateRepo); 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); } [Fact] public void BurstOfStatusWrites_CollapsesIntoFarFewerReloads() { RegisterServices(); var cut = Render(); cut.WaitForAssertion(() => _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); _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. cut.WaitForAssertion(() => _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); // Let the trailing window close before counting. Thread.Sleep(900); var reloads = _deployRepo.ReceivedCalls() .Count(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.GetAllDeploymentRecordsAsync)); Assert.InRange(reloads, 1, 4); } [Fact] public void FirstPushAfterIdle_ReloadsImmediately() { RegisterServices(); var cut = Render(); cut.WaitForAssertion(() => _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); _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().GetAllDeploymentRecordsAsync(Arg.Any()), TimeSpan.FromMilliseconds(400)); } [Fact] public void DisposeDuringACoalesceWindow_DoesNotReload() { RegisterServices(); var cut = Render(); cut.WaitForAssertion(() => _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); // 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().GetAllDeploymentRecordsAsync(Arg.Any()); } }