b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask, SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass TestContext.Current.CancellationToken. Test counts unchanged. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
155 lines
6.8 KiB
C#
155 lines
6.8 KiB
C#
using Akka.Actor;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
|
|
|
/// <summary>
|
|
/// Proves the reconciler is actually wired to something — subscribes to membership, reads the DB,
|
|
/// and emits. <see cref="ClusterNodeAddressReconcilerTests"/> covers the comparison logic, which a
|
|
/// dormant actor would leave perfectly correct and completely inert.
|
|
/// </summary>
|
|
public sealed class ClusterNodeAddressReconcilerActorTests : ControlPlaneActorTestBase
|
|
{
|
|
/// <summary>
|
|
/// An enabled row with no matching driver member is warned about. The harness ActorSystem
|
|
/// joins as <c>admin</c> with no driver members, so the seeded row is unmatched by
|
|
/// construction.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Enabled_row_with_no_matching_member_is_logged()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
SeedNode(dbFactory, "site-a-1:4053", enabled: true);
|
|
|
|
EventFilter.Warning(contains: "site-a-1:4053").ExpectOne(TimeSpan.FromSeconds(10), () =>
|
|
Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(dbFactory)), TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The finding set is logged once, not once per sweep. A check that reprints the same warning
|
|
/// every five minutes trains operators to filter it out, which costs more than it catches —
|
|
/// so this runs a deliberately fast sweep and asserts the count stays at one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Unchanged_findings_are_not_repeated_on_every_sweep()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
SeedNode(dbFactory, "site-a-1:4053", enabled: true);
|
|
|
|
// ~6 sweeps inside the window; without the change-detection guard this logs 6 times.
|
|
EventFilter.Warning(contains: "site-a-1:4053").Expect(1, TimeSpan.FromSeconds(3), () =>
|
|
{
|
|
Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(
|
|
dbFactory, sweepInterval: TimeSpan.FromMilliseconds(400)));
|
|
// Hold the filter open past several sweeps rather than returning immediately.
|
|
ExpectNoMsg(TimeSpan.FromSeconds(2.5));
|
|
}, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A disabled row is silence — the maintenance hatch must not trade a failed deployment for a
|
|
/// permanent warning. Positive control for the test above: same setup, one flag flipped.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Disabled_row_produces_no_warning()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
SeedNode(dbFactory, "site-a-1:4053", enabled: false);
|
|
|
|
EventFilter.Warning(contains: "site-a-1:4053").Expect(0, TimeSpan.FromSeconds(2), () =>
|
|
{
|
|
Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(
|
|
dbFactory, sweepInterval: TimeSpan.FromMilliseconds(400)));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(1.5));
|
|
}, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-cluster mesh Phase 6: an admin node scoped to its OWN cluster ignores rows in a cluster
|
|
/// its mesh can no longer see. Scoped to <c>MAIN</c>, a <c>SITE-A</c> enabled row is filtered
|
|
/// out before <see cref="ClusterNodeAddressReconciler.Reconcile"/> ever sees it, so it produces
|
|
/// no <see cref="AddressMismatchKind.EnabledRowNotInCluster"/> warning. This is the false
|
|
/// positive the split would otherwise print forever.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Row_in_a_foreign_cluster_is_ignored_when_scoped()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
SeedNode(dbFactory, "site-a-1:4053", enabled: true, clusterId: "SITE-A");
|
|
|
|
EventFilter.Warning(contains: "site-a-1:4053").Expect(0, TimeSpan.FromSeconds(2), () =>
|
|
{
|
|
Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(
|
|
dbFactory, ownClusterId: "MAIN", sweepInterval: TimeSpan.FromMilliseconds(400)));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(1.5));
|
|
}, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scoping does not silence the admin node's own cluster. Scoped to <c>MAIN</c>, an enabled
|
|
/// <c>MAIN</c> row with no matching driver member is still warned about — proving the filter
|
|
/// keeps in-cluster rows (and with them, within-cluster drift detection).
|
|
/// </summary>
|
|
[Fact]
|
|
public void Enabled_row_in_own_cluster_is_still_logged_when_scoped()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
SeedNode(dbFactory, "main-1:4053", enabled: true, clusterId: "MAIN");
|
|
|
|
EventFilter.Warning(contains: "main-1:4053").ExpectOne(TimeSpan.FromSeconds(10), () =>
|
|
Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(dbFactory, ownClusterId: "MAIN")), TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Legacy backward-compat: an admin node with no cluster role (null <c>ownClusterId</c>) still
|
|
/// reconciles the WHOLE fleet — such a node genuinely sees every member via gossip. A
|
|
/// <c>SITE-A</c> enabled row with no member is warned about, exactly as before the Phase 6 scope.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Foreign_cluster_row_is_still_logged_when_unscoped()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
SeedNode(dbFactory, "site-a-1:4053", enabled: true, clusterId: "SITE-A");
|
|
|
|
EventFilter.Warning(contains: "site-a-1:4053").ExpectOne(TimeSpan.FromSeconds(10), () =>
|
|
Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(dbFactory, ownClusterId: null)), TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
private static void SeedNode(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, string nodeId, bool enabled,
|
|
string clusterId = "SITE-A")
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
if (!db.ServerClusters.Any(c => c.ClusterId == clusterId))
|
|
{
|
|
db.ServerClusters.Add(new ServerCluster
|
|
{
|
|
ClusterId = clusterId,
|
|
Name = clusterId,
|
|
Enterprise = "zb",
|
|
Site = clusterId.ToLowerInvariant(),
|
|
NodeCount = 2,
|
|
RedundancyMode = RedundancyMode.Warm,
|
|
CreatedBy = "test",
|
|
});
|
|
}
|
|
|
|
db.ClusterNodes.Add(new ClusterNode
|
|
{
|
|
NodeId = nodeId,
|
|
ClusterId = clusterId,
|
|
Host = nodeId.Split(':')[0],
|
|
ApplicationUri = $"urn:OtOpcUa:{nodeId}",
|
|
Enabled = enabled,
|
|
CreatedBy = "test",
|
|
});
|
|
db.SaveChanges();
|
|
}
|
|
}
|