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());
}
}