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 ScadaLink.CentralUI.Auth; using ScadaLink.Commons.Entities.Deployment; using ScadaLink.Commons.Entities.Instances; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Types.Enums; using ScadaLink.DeploymentManager; using DeploymentsPage = ScadaLink.CentralUI.Components.Pages.Deployment.Deployments; namespace ScadaLink.CentralUI.Tests.Deployment; /// /// Regression tests for CentralUI-006. Component-CentralUI "Real-Time Updates" /// states deployment status transitions push to the UI immediately via SignalR /// with no polling. The page previously ran a 10-second Timer that /// reloaded every deployment record + instance map per tick. The fix removes /// the timer and subscribes to , which /// DeploymentService raises on every deployment-record status write; /// Blazor Server then pushes the re-render over its SignalR circuit. /// public class DeploymentsPushUpdateTests : 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 Deployments_DoesNotPoll_HasNoRefreshTimer() { // The 10-second polling Timer must be gone — push replaces polling. var timerField = typeof(DeploymentsPage).GetField( "_refreshTimer", BindingFlags.Instance | BindingFlags.NonPublic); Assert.Null(timerField); } [Fact] public void Deployments_StatusChange_TriggersReload() { RegisterServices(); var cut = Render(); // Initial load: instances + records each fetched once. _deployRepo.ClearReceivedCalls(); _templateRepo.ClearReceivedCalls(); // A deployment status write in DeploymentManager raises the notifier; // the page must reload in response (no polling timer involved). _notifier.NotifyStatusChanged( new DeploymentStatusChange("dep-1", 1, DeploymentStatus.Success)); cut.WaitForAssertion(() => _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); } [Fact] public void Deployments_Dispose_UnsubscribesFromNotifier() { RegisterServices(); var cut = Render(); cut.Instance.Dispose(); _deployRepo.ClearReceivedCalls(); // After disposal, a status change must NOT touch the disposed component. _notifier.NotifyStatusChanged( new DeploymentStatusChange("dep-2", 1, DeploymentStatus.Failed)); _deployRepo.DidNotReceive() .GetAllDeploymentRecordsAsync(Arg.Any()); } }