2807b5a3ea
Row 65 — trigger FIRED, fixed. BundleUnlockAttempt rows were pruned only by
nodes actively serving unlocks, so a cluster that stopped importing entirely
left its last rows in place forever. The row's own trigger was "Transport gains
a central scheduler a prune tick could ride" — it has one now:
BundleSessionEvictionService, the central-only 1-minute session sweep added by
the transport-session fixes. It now also ticks the new
IBundleUnlockAttemptWindow.PruneAsync. Both drivers share the ONE 15-minute CAS
cadence claim, so ticking on a 1-minute sweep costs at most one bounded DELETE
per quarter hour and the two can never prune concurrently. PruneAsync is
contractually non-throwing (unlike TryRegisterAttemptAsync, which throws and
lets the limiter own the fail-open decision) — a background sweep has no caller
to report to and must survive a database blip. Both Transport docs updated;
three tests in FleetWideUnlockThrottleTests, incl. the retention sweep running
with no unlock traffic anywhere in the test, which is the whole point.
Row 58 — closed with a pin, and the OLD pin was found blind. The existing
Deployments_Dispose_UnsubscribesFromNotifier is behavioural: it is satisfied by
the _disposed guard alone, so deleting the `-=` from Dispose leaves it green
while the singleton notifier keeps a delegate pinning the circuit-scoped
component. Verified by mutation — with the `-=` removed the old test still
passed and only the two new pins failed. Added:
* a structural retention pin asserting the notifier's invocation list actually
shrinks to 0 on Dispose;
* a source scan pinning the row's stated TRIGGER ("a second page subscribes,
or any subscriber lands without a matching Dispose unsubscribe") — every
CentralUI file that subscribes must also unsubscribe and be IDisposable,
with a non-empty guard so the scan cannot pass vacuously.
Row 60 — closed with a pin, and the register's premise corrected.
GlobalArtifactSnapshot does NOT hold EF-materialised entities: it is projected
into pure Commons DTOs before the fan-out. The EF state that genuinely crosses
into the parallel send is the tracked Site entities the lambda closes over, and
that is safe only while lazy loading is off. Pinned in ConfigurationDatabase
.Tests two ways — no proxies extension on the built DbContextOptions, and no
EntityFrameworkCore.Proxies package reference anywhere in the repo (the
structurally stronger half: UseLazyLoadingProxies() is not a compilable call
without it). The real invariant is now spelled out at the fan-out itself.
Row 59 — no product change; added the missing tripwire remark stating the
constraint the safety argument rests on (serial by design, one scoped DbContext,
do not Task.WhenAll; if it gets slow the fix is a scope PER SITE, not a shared
scope under fan-out). Line numbers in the register have drifted to :528-545.
Rows 36, 37, 64: verification only, no change — see the sweep report.
271 lines
12 KiB
C#
271 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 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 <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;
|
|
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
|
public override Task<AuthenticationState> 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<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>()));
|
|
}
|
|
|
|
[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 CentralUI-022. 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);
|
|
}
|
|
|
|
[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>());
|
|
}
|
|
}
|