513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
278 lines
12 KiB
C#
278 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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 <c>Timer</c> that
|
|
/// reloaded every deployment record + instance map per tick. The fix removes
|
|
/// the timer and subscribes to <see cref="IDeploymentStatusNotifier"/>, which
|
|
/// <c>DeploymentService</c> raises on every deployment-record status write;
|
|
/// Blazor Server then pushes the re-render over its SignalR circuit.
|
|
///
|
|
/// <para>
|
|
/// The "did it reload?" assertions moved from <c>GetAllDeploymentRecordsAsync</c>
|
|
/// to <c>QueryDeploymentListPageAsync</c> when the page's read path went
|
|
/// server-paged (residual R3) — the push mechanism under test is unchanged, only
|
|
/// the query it drives.
|
|
/// </para>
|
|
/// </summary>
|
|
public class DeploymentsPushUpdateTests : BunitContext
|
|
{
|
|
private IDeploymentManagerRepository _deployRepo = null!;
|
|
private DeploymentStatusNotifier _notifier = null!;
|
|
|
|
private void RegisterServices()
|
|
{
|
|
_deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
_notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
|
|
|
_deployRepo.QueryDeploymentListPageAsync(
|
|
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(DeploymentListPage.Empty);
|
|
_deployRepo.GetDeploymentStatusCountsAsync(
|
|
Arg.Any<DeploymentListFilter>(), Arg.Any<CancellationToken>())
|
|
.Returns(DeploymentStatusCounts.Empty);
|
|
|
|
Services.AddSingleton(_deployRepo);
|
|
Services.AddSingleton<IDeploymentStatusNotifier>(_notifier);
|
|
|
|
var identity = new ClaimsIdentity(
|
|
new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
|
|
var stubAuth = new StubAuthStateProvider(
|
|
new AuthenticationState(new ClaimsPrincipal(identity)));
|
|
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
|
|
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
|
}
|
|
|
|
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
|
{
|
|
private readonly AuthenticationState _state;
|
|
/// <summary>Initializes the provider with the authentication state it returns.</summary>
|
|
/// <param name="state">The authentication state to return.</param>
|
|
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
|
/// <inheritdoc />
|
|
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
=> Task.FromResult(_state);
|
|
}
|
|
|
|
/// <summary>Verifies that the Deployments page does not poll and has no refresh timer.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a deployment status change notification triggers a reload of the page.</summary>
|
|
[Fact]
|
|
public void Deployments_StatusChange_TriggersReload()
|
|
{
|
|
RegisterServices();
|
|
var cut = Render<DeploymentsPage>();
|
|
|
|
// 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<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
|
|
}
|
|
|
|
/// <summary>Verifies that disposing the page unsubscribes it from the status notifier.</summary>
|
|
[Fact]
|
|
public void Deployments_Dispose_UnsubscribesFromNotifier()
|
|
{
|
|
RegisterServices();
|
|
var cut = Render<DeploymentsPage>();
|
|
|
|
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<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// <para>
|
|
/// The sibling <see cref="Deployments_Dispose_UnsubscribesFromNotifier"/> pins the
|
|
/// observable behaviour, but it is satisfied by the <c>_disposed</c> guard ALONE —
|
|
/// delete the <c>-=</c> from <c>Dispose</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void Deployments_Dispose_ActuallyDetachesTheDelegate_NotJustGuardsIt()
|
|
{
|
|
RegisterServices();
|
|
var cut = Render<DeploymentsPage>();
|
|
|
|
Assert.Equal(1, SubscriberCount(_notifier));
|
|
|
|
cut.Instance.Dispose();
|
|
|
|
Assert.Equal(0, SubscriberCount(_notifier));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the compiler-generated backing field of the field-like
|
|
/// <c>StatusChanged</c> 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".
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deferred-work register row 58's TRIGGER, pinned: "a second page subscribes, or any
|
|
/// subscriber lands without a matching <c>Dispose</c> 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.
|
|
/// <para>
|
|
/// A source scan rather than reflection: the subscription is a <c>+=</c> operator
|
|
/// inside a method body, which is not visible on the type's reflected surface.
|
|
/// Mirrors the source-scan convention in <c>Commons.Tests/ArchitecturalConstraintTests</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
[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<string>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>Walks up from the test output directory to the CentralUI project source root.</summary>
|
|
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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression test for the dispose race. The notifier is a process singleton:
|
|
/// it can read its subscriber list and begin invoking
|
|
/// <c>OnDeploymentStatusChanged</c> on the DeploymentManager thread an
|
|
/// instant before the component is disposed. The handler must no-op against
|
|
/// a disposed component rather than letting <c>InvokeAsync</c> throw an
|
|
/// unobserved <see cref="ObjectDisposedException"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Deployments_HasDisposalGuardField()
|
|
{
|
|
var field = typeof(DeploymentsPage).GetField(
|
|
"_disposed", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
|
|
Assert.NotNull(field);
|
|
Assert.Equal(typeof(bool), field!.FieldType);
|
|
}
|
|
|
|
/// <summary>Verifies that a status change delivered after disposal neither throws nor reloads the page.</summary>
|
|
[Fact]
|
|
public void Deployments_StatusChangeAfterDispose_DoesNotThrowOrReload()
|
|
{
|
|
RegisterServices();
|
|
var cut = Render<DeploymentsPage>();
|
|
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<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
|
}
|
|
}
|