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 deployment status push updates. 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. /// /// /// The "did it reload?" assertions moved from GetAllDeploymentRecordsAsync /// to QueryDeploymentListPageAsync when the page's read path went /// server-paged (residual R3) — the push mechanism under test is unchanged, only /// the query it drives. /// /// public class DeploymentsPushUpdateTests : 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; /// Initializes the provider with the authentication state it returns. /// The authentication state to return. public StubAuthStateProvider(AuthenticationState state) => _state = state; /// public override Task GetAuthenticationStateAsync() => Task.FromResult(_state); } /// Verifies that the Deployments page does not poll and has no refresh timer. [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); } /// Verifies that a deployment status change notification triggers a reload of the page. [Fact] public void Deployments_StatusChange_TriggersReload() { RegisterServices(); var cut = Render(); // Initial load: the paged query is issued once. _deployRepo.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().QueryDeploymentListPageAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())); } /// Verifies that disposing the page unsubscribes it from the status notifier. [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().QueryDeploymentListPageAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } /// /// Deferred-work register row 58: the notifier is a process SINGLETON that retains /// delegates targeting circuit-scoped components, so an un-detached handler leaks the /// whole component graph (and its circuit) for the process lifetime. /// /// The sibling pins the /// observable behaviour, but it is satisfied by the _disposed guard ALONE — /// delete the -= from Dispose and it still passes while the leak is /// wide open. This test pins the retention property structurally instead: the /// notifier's invocation list must actually shrink back to empty. /// /// [Fact] public void Deployments_Dispose_ActuallyDetachesTheDelegate_NotJustGuardsIt() { RegisterServices(); var cut = Render(); Assert.Equal(1, SubscriberCount(_notifier)); cut.Instance.Dispose(); Assert.Equal(0, SubscriberCount(_notifier)); } /// /// Reads the compiler-generated backing field of the field-like /// StatusChanged event and counts its invocation list. Reflection is the only /// way to observe retention — the notifier deliberately exposes no subscriber count, /// and a behavioural probe cannot distinguish "detached" from "attached but guarded". /// private static int SubscriberCount(DeploymentStatusNotifier notifier) { var field = typeof(DeploymentStatusNotifier).GetField( "StatusChanged", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(field); var handlers = (Delegate?)field!.GetValue(notifier); return handlers?.GetInvocationList().Length ?? 0; } /// /// Deferred-work register row 58's TRIGGER, pinned: "a second page subscribes, or any /// subscriber lands without a matching Dispose unsubscribe". Row 58 is /// acceptable only while every subscriber detaches itself; a new page that subscribes /// and forgets is a silent circuit leak that no existing test would catch, because /// every other pin is written against the one known subscriber. /// /// A source scan rather than reflection: the subscription is a += operator /// inside a method body, which is not visible on the type's reflected surface. /// Mirrors the source-scan convention in Commons.Tests/ArchitecturalConstraintTests. /// /// [Fact] public void EveryCentralUiSubscriberToTheNotifier_UnsubscribesAndIsDisposable() { var sourceDir = GetCentralUiSourceDirectory(); var sourceFiles = Directory .GetFiles(sourceDir, "*.razor", SearchOption.AllDirectories) .Concat(Directory.GetFiles(sourceDir, "*.cs", SearchOption.AllDirectories)) .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)); var subscribers = new List(); foreach (var file in sourceFiles) { var content = File.ReadAllText(file); if (!content.Contains("StatusChanged +=", StringComparison.Ordinal)) { continue; } subscribers.Add(file); Assert.True( content.Contains("StatusChanged -=", StringComparison.Ordinal), $"{Path.GetFileName(file)} subscribes to IDeploymentStatusNotifier.StatusChanged but never " + "unsubscribes. The notifier is a singleton, so the delegate pins this circuit-scoped " + "component for the process lifetime (deferred-work register row 58)."); Assert.True( content.Contains("IDisposable", StringComparison.Ordinal) || content.Contains("IAsyncDisposable", StringComparison.Ordinal), $"{Path.GetFileName(file)} subscribes to IDeploymentStatusNotifier.StatusChanged but does not " + "implement IDisposable/IAsyncDisposable, so its unsubscribe can never run."); } // Guard the guard: if the scan silently stops matching (file moved, API renamed) // the loop above would vacuously pass and the trigger would go unwatched. Assert.NotEmpty(subscribers); } /// Walks up from the test output directory to the CentralUI project source root. private static string GetCentralUiSourceDirectory() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.CentralUI"); if (Directory.Exists(candidate)) { return candidate; } dir = dir.Parent; } throw new InvalidOperationException("Could not locate the ZB.MOM.WW.ScadaBridge.CentralUI source directory."); } /// /// Regression test for the dispose race. 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); } /// Verifies that a status change delivered after disposal neither throws nor reloads the page. [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().QueryDeploymentListPageAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } }