Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ClusterNodeServiceTests.cs
Joseph Doherty 13d5a7968b Admin RedundancyTab — per-cluster read-only topology view. Closes the UI slice of task #149 (Phase 6.3 Stream E — Admin UI RedundancyTab + OpenTelemetry metrics + SignalR); the OpenTelemetry metrics + RoleChanged SignalR push are split into new follow-up task #198 because each is a structural add that deserves its own test matrix + NuGet-dep decision rather than riding this UI PR. New /clusters/{ClusterId} Redundancy tab slotted between ACLs and Audit in the existing ClusterDetail tab bar. Shows each ClusterNode row in the cluster with columns Node / Role (Primary green, Secondary blue, Standalone primary-blue badge) / Host / OPC UA port / ServiceLevel base / ApplicationUri (text-break so the long urn: doesn't blow out the table) / Enabled badge / Last seen (relative age via the same FormatAge helper as Hosts.razor, with a yellow "Stale" chip once LastSeenAt crosses the 30s threshold shared with HostStatusService.StaleThreshold — a missed heartbeat plus clock-skew buffer). Four summary cards above the table — total Nodes, Primary count, Secondary count, Stale count. Two guard-rail alerts: (a) red "No Primary or Standalone" when the cluster has no authoritative write target (all rows are Secondaries — read-only until one is promoted by the server-side RedundancyCoordinator apply-lease flow); (b) red "Split-brain" when >1 Primary exists — apply-lease enforcement at the coordinator level should have made this impossible, so the alert implies a hand-edited DB row + an investigation. New ClusterNodeService with ListByClusterAsync (ordered by ServiceLevelBase descending so Primary rows with higher base float to the top) + a static IsStale predicate matching HostStatusService's 30s convention. DI-registered alongside the existing scoped services in Program.cs. Writes (role swap, enable/disable) are deliberately absent from the service — they go through the RedundancyCoordinator apply-lease flow on the server side + direct DB mutation from Admin would race with it. New ClusterNodeServiceTests covering IsStale across null/recent/old LastSeenAt + ListByClusterAsync ordering + cluster filter. 4/4 new tests passing; full Admin.Tests suite 76/76 (was 72 before this PR, +4). Admin project builds 0 errors. Task #198 captures the deferred work: (1) OpenTelemetry Meter for primary/secondary/stale counts + role_transition counter with from/to/node tags + OTLP exporter config; (2) RoleChanged SignalR push — extend FleetStatusPoller to detect RedundancyRole changes on ClusterNode rows + emit a RoleChanged hub message so the RedundancyTab refreshes instantly instead of on-page-load polling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:14:25 -04:00

79 lines
2.6 KiB
C#

using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class ClusterNodeServiceTests
{
[Fact]
public void IsStale_NullLastSeen_Returns_True()
{
var node = NewNode("A", RedundancyRole.Primary, lastSeenAt: null);
ClusterNodeService.IsStale(node).ShouldBeTrue();
}
[Fact]
public void IsStale_RecentLastSeen_Returns_False()
{
var node = NewNode("A", RedundancyRole.Primary, lastSeenAt: DateTime.UtcNow.AddSeconds(-5));
ClusterNodeService.IsStale(node).ShouldBeFalse();
}
[Fact]
public void IsStale_Old_LastSeen_Returns_True()
{
var node = NewNode("A", RedundancyRole.Primary,
lastSeenAt: DateTime.UtcNow - ClusterNodeService.StaleThreshold - TimeSpan.FromSeconds(1));
ClusterNodeService.IsStale(node).ShouldBeTrue();
}
[Fact]
public async Task ListByClusterAsync_OrdersByServiceLevelBase_Descending_Then_NodeId()
{
using var ctx = NewContext();
ctx.ClusterNodes.AddRange(
NewNode("B-low", RedundancyRole.Secondary, serviceLevelBase: 150, clusterId: "c1"),
NewNode("A-high", RedundancyRole.Primary, serviceLevelBase: 200, clusterId: "c1"),
NewNode("other-cluster", RedundancyRole.Primary, serviceLevelBase: 200, clusterId: "c2"));
await ctx.SaveChangesAsync();
var svc = new ClusterNodeService(ctx);
var rows = await svc.ListByClusterAsync("c1", CancellationToken.None);
rows.Count.ShouldBe(2);
rows[0].NodeId.ShouldBe("A-high"); // higher ServiceLevelBase first
rows[1].NodeId.ShouldBe("B-low");
}
private static ClusterNode NewNode(
string nodeId,
RedundancyRole role,
DateTime? lastSeenAt = null,
int serviceLevelBase = 200,
string clusterId = "c1") => new()
{
NodeId = nodeId,
ClusterId = clusterId,
RedundancyRole = role,
Host = $"{nodeId}.example",
ApplicationUri = $"urn:{nodeId}",
ServiceLevelBase = (byte)serviceLevelBase,
LastSeenAt = lastSeenAt,
CreatedBy = "test",
};
private static OtOpcUaConfigDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}