Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/PendingUpdatesHealthTilesTests.cs
T
Joseph Doherty d50433410d feat(health): surface pending-updates counts on the Health dashboard and CLI (ScadaBridge#63)
Health page gains PendingUpdatesHealthTiles (stale instances / sites with stale
artifacts, Deployer+Administrator only, site-scope narrowed, shared cache). New
GetPendingUpdates management command (Deployer) and 'health pending-updates' CLI
leaf. Flatten/cache computation untouched; the command reads
IPendingUpdatesEvaluator directly because the UI cache is not reachable from
ManagementService.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:18:54 -05:00

110 lines
4.7 KiB
C#

using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>Gitea #63: pending-updates tiles on the Health dashboard.</summary>
public class PendingUpdatesHealthTilesTests : BunitContext
{
private static SitePendingUpdates Row(int id, int stale, ArtifactDeploymentState a = ArtifactDeploymentState.Current) =>
new(id, $"S{id}", $"Site {id}", a, DateTimeOffset.UtcNow, 10, stale, 0);
private IRenderedComponent<PendingUpdatesHealthTiles> RenderFor(IPendingUpdatesCache cache, params Claim[] claims)
{
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
return Render<PendingUpdatesHealthTiles>(ps =>
ps.AddCascadingValue(Task.FromResult(new AuthenticationState(user))));
}
private static IPendingUpdatesCache CacheReturning(params SitePendingUpdates[] rows)
{
var cache = Substitute.For<IPendingUpdatesCache>();
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, rows)));
return cache;
}
private static Claim Role(string r) => new(JwtTokenService.RoleClaimType, r);
/// <summary>A Deployer sees both counts, highlighted when non-zero.</summary>
[Fact]
public void Deployer_SeesBothCounts()
{
var cut = RenderFor(
CacheReturning(Row(1, 4), Row(2, 3, ArtifactDeploymentState.OutOfDate)),
Role(Roles.Deployer));
cut.WaitForAssertion(() =>
{
Assert.Contains("7", cut.Find("[data-test='pending-updates-instances']").TextContent);
Assert.Contains("1", cut.Find("[data-test='pending-updates-artifact-sites']").TextContent);
Assert.Contains("border-warning", cut.Find("[data-test='pending-updates-instances']").ClassName);
});
}
/// <summary>A Viewer sees nothing and triggers no fleet read.</summary>
[Fact]
public void Viewer_SeesNothing_AndNoFleetRead()
{
var cache = CacheReturning(Row(1, 4));
var cut = RenderFor(cache, Role("Viewer"));
Assert.True(string.IsNullOrWhiteSpace(cut.Markup));
cache.DidNotReceive().GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
/// <summary>A site-scoped Deployer's totals exclude sites outside the scope.</summary>
[Fact]
public void SiteScopedDeployer_CountsOnlyPermittedSites()
{
var cut = RenderFor(
CacheReturning(Row(1, 4), Row(2, 9)),
Role(Roles.Deployer), new Claim(JwtTokenService.SiteIdClaimType, "1"));
cut.WaitForAssertion(() =>
Assert.Equal("4", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim()));
}
/// <summary>A failed read is shown as unavailable, never as zero.</summary>
[Fact]
public void ReadFailure_ShowsUnavailable_NotZero()
{
var cache = Substitute.For<IPendingUpdatesCache>();
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<PendingUpdatesSnapshot>(new InvalidOperationException("boom")));
var cut = RenderFor(cache, Role(Roles.Administrator));
cut.WaitForAssertion(() =>
{
Assert.Contains("boom", cut.Find("[data-test='pending-updates-error']").TextContent);
Assert.Equal("—", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim());
});
}
/// <summary>An invalidation re-reads the cache.</summary>
[Fact]
public void Invalidated_Rereads()
{
var cache = Substitute.For<IPendingUpdatesCache>();
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(
Task.FromResult(new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, [Row(1, 5)])),
Task.FromResult(new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, [Row(1, 0)])));
var cut = RenderFor(cache, Role(Roles.Deployer));
cut.WaitForAssertion(() => Assert.Equal("5", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim()));
cache.Invalidated += NSubstitute.Raise.Event<Action>();
cut.WaitForAssertion(() => Assert.Equal("0", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim()));
}
}