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()); } /// /// Regression test for CentralUI-022. The notifier is a process singleton: /// it can read its subscriber list and begin invoking /// OnDeploymentStatusChanged on the DeploymentManager thread an /// instant before the component is disposed. The handler must no-op against /// a disposed component rather than letting InvokeAsync throw an /// unobserved . /// [Fact] public void Deployments_HasDisposalGuardField() { var field = typeof(DeploymentsPage).GetField( "_disposed", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(field); Assert.Equal(typeof(bool), field!.FieldType); } [Fact] public void Deployments_StatusChangeAfterDispose_DoesNotThrowOrReload() { RegisterServices(); var cut = Render(); var component = cut.Instance; component.Dispose(); _deployRepo.ClearReceivedCalls(); // Simulate the race: the notifier captured the handler before the // Dispose() unsubscribe and invokes it directly against the now-disposed // component. Pre-fix this dispatched InvokeAsync against a dead circuit // and threw ObjectDisposedException on a fire-and-forget task. var handler = typeof(DeploymentsPage).GetMethod( "OnDeploymentStatusChanged", BindingFlags.Instance | BindingFlags.NonPublic)!; var ex = Record.Exception(() => handler.Invoke(component, new object[] { new DeploymentStatusChange("dep-9", 1, DeploymentStatus.Success) })); Assert.Null(ex); // The guard short-circuits before any reload is attempted. _deployRepo.DidNotReceive() .GetAllDeploymentRecordsAsync(Arg.Any()); } }