using System.Reflection; using System.Security.Claims; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ScadaLink.CentralUI.Auth; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Communication; using ScadaLink.Communication.Grpc; using DebugViewPage = ScadaLink.CentralUI.Components.Pages.Deployment.DebugView; namespace ScadaLink.CentralUI.Tests.Deployment; /// /// Regression tests for CentralUI-009. The DebugView stream callbacks /// (onEvent/onTerminated) run on an Akka/gRPC thread and capture /// this and _toast. If the user navigates away, an in-flight /// callback could still call _toast.ShowError(...) / /// InvokeAsync(StateHasChanged) on a disposed component. The fix adds a /// _disposed flag checked at the top of every callback, set in /// Dispose() before the stream is stopped. /// /// The Akka-thread timing race itself is not deterministically reproducible in /// a unit test ( is a non-virtual concrete /// class with no seam to inject and later fire the callbacks). These tests pin /// the observable parts of the fix: the component exposes a disposal guard, and /// disposal is clean and idempotent. /// /// public class DebugViewDisposalTests : BunitContext { private void RegisterServices() { // DebugView touches localStorage on render; let bUnit answer loosely. JSInterop.Mode = JSRuntimeMode.Loose; var repo = Substitute.For(); var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync().Returns(new List()); Services.AddSingleton(repo); Services.AddSingleton(siteRepo); var comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); Services.AddSingleton(comms); var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance); // An empty throwaway provider — these tests never call StartStreamAsync, // so the provider is unused. (Services.BuildServiceProvider() would leak // an undisposed provider.) var debugStream = new DebugStreamService( comms, new ServiceCollection().BuildServiceProvider(), grpcFactory, NullLogger.Instance); Services.AddSingleton(debugStream); var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie"); var authState = new AuthenticationState(new ClaimsPrincipal(identity)); var stubAuth = new StubAuthStateProvider(authState); 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 DebugView_HasDisposalGuardField() { // The fix introduces a `_disposed` flag that every stream callback // checks before touching component state. var field = typeof(DebugViewPage).GetField( "_disposed", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(field); Assert.Equal(typeof(bool), field!.FieldType); } [Fact] public void DebugView_Dispose_SetsDisposedFlag_AndIsIdempotent() { RegisterServices(); var cut = Render(); var field = typeof(DebugViewPage).GetField( "_disposed", BindingFlags.Instance | BindingFlags.NonPublic)!; Assert.False((bool)field.GetValue(cut.Instance)!); cut.Instance.Dispose(); Assert.True((bool)field.GetValue(cut.Instance)!, "Dispose() must set the guard so in-flight callbacks no-op."); // Disposing again must not throw (idempotent). var ex = Record.Exception(() => cut.Instance.Dispose()); Assert.Null(ex); } }