Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs
T
Joseph Doherty 5d6bd03868 feat(central-ui): Sites page shows whether a site's deployed artifacts are current
Each site card carries an Artifacts badge (current / out of date / never
deployed) plus an "N of M instances out of date" line linking to Topology,
both read from the shared pending-updates snapshot. The snapshot loads after
the site list so a slow fleet evaluation never holds the cards, and a failure
degrades the badge to "unavailable" instead of taking the page down. Header
gains a Re-check button and an as-of caption; a successful Deploy Artifacts
forces a refresh so the badge flips immediately.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 13:59:54 -04:00

216 lines
8.3 KiB
C#

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 ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
using SitesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.Sites;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
/// <summary>
/// Regression tests for CentralUI-012. The Sites page loaded all sites and then
/// issued <c>GetDataConnectionsBySiteIdAsync</c> once per site (N+1 database
/// round-trips on every load and post-delete refresh). The fix fetches all
/// data connections in a single <c>GetAllDataConnectionsAsync</c> call and
/// groups them client-side.
/// </summary>
public class SitesPageTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IPendingUpdatesCache _pendingUpdates = Substitute.For<IPendingUpdatesCache>();
private void RegisterServices()
{
Services.AddSingleton(_siteRepo);
// Default: an empty snapshot, so the page's badges resolve without a stub.
// Individual tests re-stub GetAsync before rendering.
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot()));
Services.AddSingleton(_pendingUpdates);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
var artifactSvc = new ArtifactDeploymentService(
_siteRepo,
Substitute.For<IDeploymentManagerRepository>(),
Substitute.For<ITemplateEngineRepository>(),
Substitute.For<IExternalSystemRepository>(),
Substitute.For<INotificationRepository>(),
comms,
Substitute.For<IAuditService>(),
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance,
new ArtifactRevisionHashService());
Services.AddSingleton(artifactSvc);
Services.AddSingleton<IDialogService>(Substitute.For<IDialogService>());
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "admin") }, "TestCookie");
var authState = new AuthenticationState(new ClaimsPrincipal(identity));
Services.AddSingleton<AuthenticationStateProvider>(
new StubAuthStateProvider(authState));
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
private static List<Site> Sites(params int[] ids)
=> ids.Select(id => new Site($"Site{id}", $"SITE-{id}") { Id = id }).ToList();
private static DataConnection Conn(int siteId, string name)
=> new(name, "OpcUa", siteId);
private static PendingUpdatesSnapshot Snapshot(params SitePendingUpdates[] sites)
=> new(new DateTimeOffset(2026, 9, 14, 10, 30, 0, TimeSpan.Zero), sites);
private static SitePendingUpdates Pending(
int siteId,
ArtifactDeploymentState state,
int deployed = 0,
int stale = 0,
int unknown = 0)
=> new(
siteId,
$"SITE-{siteId}",
$"Site{siteId}",
state,
state == ArtifactDeploymentState.NeverDeployed
? null
: new DateTimeOffset(2026, 9, 13, 8, 0, 0, TimeSpan.Zero),
deployed,
stale,
unknown);
[Fact]
public void LoadData_FetchesAllConnectionsInOneQuery_NoPerSiteQueries()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2, 3));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>
{
Conn(1, "c1"), Conn(2, "c2"), Conn(3, "c3"),
});
Render<SitesPage>();
// Regression: exactly one bulk query, and zero per-site queries.
_siteRepo.Received(1).GetAllDataConnectionsAsync();
_siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(Arg.Any<int>());
}
[Fact]
public void LoadData_GroupsConnectionsBySite_AndRendersThem()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>
{
Conn(1, "alpha-conn"), Conn(2, "beta-conn"),
});
var cut = Render<SitesPage>();
Assert.Contains("alpha-conn", cut.Markup);
Assert.Contains("beta-conn", cut.Markup);
}
[Fact]
public void ArtifactBadge_ShowsCurrent_OutOfDate_NeverDeployed()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2, 3));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(
Pending(1, ArtifactDeploymentState.Current),
Pending(2, ArtifactDeploymentState.OutOfDate),
Pending(3, ArtifactDeploymentState.NeverDeployed))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("aria-label=\"Artifacts: current\"", cut.Markup);
Assert.Contains("Artifacts current", cut.Markup);
Assert.Contains("aria-label=\"Artifacts: out of date\"", cut.Markup);
Assert.Contains("Artifacts out of date", cut.Markup);
Assert.Contains("aria-label=\"Artifacts: never deployed\"", cut.Markup);
Assert.Contains("Artifacts never deployed", cut.Markup);
});
}
[Fact]
public void InstanceLine_ShowsStaleOfDeployed()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(
Pending(1, ArtifactDeploymentState.Current, deployed: 5, stale: 2, unknown: 1))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("2 of 5 instances out of date", cut.Markup);
Assert.Contains("1 could not be evaluated", cut.Markup);
Assert.Contains("/deployment/topology", cut.Markup);
});
}
[Fact]
public void Recheck_ForcesRefresh()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
var cut = Render<SitesPage>();
cut.Find("button[aria-label=\"Re-check pending updates\"]").Click();
cut.WaitForAssertion(() =>
_pendingUpdates.Received().GetAsync(true, Arg.Any<CancellationToken>()));
}
[Fact]
public void CacheFault_ShowsUnavailable_AndSitesStillRender()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns<Task<PendingUpdatesSnapshot>>(_ => throw new InvalidOperationException("evaluator down"));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
// The snapshot failing must never take the site list down with it.
Assert.Contains("Site1", cut.Markup);
Assert.Contains("Site2", cut.Markup);
Assert.Contains("Artifacts: unavailable", cut.Markup);
});
}
}