Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ClusterNodeTransportPortsTests.cs
T
Joseph Doherty 4d2dfb5b50 test(mesh): step 7 — replace the boundary test with MeshGrpcBoundaryTests and sweep the comments
MeshGrpcBoundaryTests replaces the MeshClusterClientBoundaryTests deleted in step 1. Three
self-seeded single-member clusters sharing the ActorSystem name, joined ONLY by gRPC, driving
the REAL CentralCommunicationActor -> MeshGrpcCommandDialer -> Kestrel h2c ->
MeshCommandAuthInterceptor -> NodeCommandGrpcService -> NodeCommunicationActor -> EventStream:

  1. No_cross_mesh_Akka_association_forms_between_central_and_either_site — the program's own
     point, kept as a test rather than a live-gate note: each cluster stays single-member and
     none ever holds another's address as a member.
  2. One_dispatch_through_central_reaches_a_node_in_every_cluster_mesh — the fan, with each
     site holding its OWN per-cluster PSK, so a dial that selected the wrong cluster's key
     would be refused at the far side.
  3. Falsifiability_control_a_dial_carrying_the_wrong_cluster_key_delivers_nothing — without
     it, (2) cannot distinguish "the gRPC boundary carried it" from "something else in the
     process did". The same node then accepts the right key, so the silence is the auth gate
     and not a dead endpoint.

All three run fully offline — no Docker fixture — and pass.

Comment sweep across every remaining clusterclient|receptionist hit in src/ and tests/:
the program label becomes "mesh gRPC command plane", and every comment that described the
deleted transport as live now describes what actually runs (Dps = pair-local default,
Grpc = cross-mesh). mesh_transport.proto's header/service comments are reworded; no message,
field or enum is touched — the contract is locked. MeshCommActorPathTests is reshaped rather
than retired: the two paths still exist and still matter, but what a mismatch costs is now a
misleading Unavailable from the gRPC ingress rather than a silently dropped receptionist send.

The only surviving mentions of the old names are the ones that must survive: the fail-closed
rules that name "ClusterClient" as a retired value refused at boot, their tests, and this
test class's own account of what it replaces.
2026-08-06 15:58:36 -04:00

150 lines
6.5 KiB
C#

using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// Round-trips <see cref="ClusterNode.AkkaPort"/> / <see cref="ClusterNode.GrpcPort"/> through
/// the real schema (per-cluster mesh Phase 1). These are <b>central's dial targets</b>: Phase 2
/// built its Akka contact points from <c>Host</c> + <c>AkkaPort</c>, and Phase 5 dials
/// <c>GrpcPort</c> for the telemetry stream. Central cannot see a site node's appsettings once
/// the meshes split, so the value has to live in a row central can read.
/// </summary>
[Trait("Category", "SchemaCompliance")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class ClusterNodeTransportPortsTests(SchemaComplianceFixture fixture)
{
/// <summary>Verifies both ports survive a DB round-trip and that AkkaPort defaults to 4053.</summary>
[Fact]
public async Task Transport_ports_round_trip_and_AkkaPort_defaults_to_4053()
{
await using var ctx = NewContext();
var clusterId = await SeedClusterAsync(ctx);
// Explicit non-default values on one node — proves the columns are mapped and persisted
// rather than silently dropped (an unmapped property round-trips fine in memory).
ctx.ClusterNodes.Add(NewNode(clusterId, "explicit", n =>
{
n.AkkaPort = 4055;
n.GrpcPort = 5223;
}));
// Nothing set on the other — AkkaPort must land on 4053 and GrpcPort must stay null.
// GrpcPort is deliberately nullable: nothing listens on it until Phase 5, and a non-null
// default would assert a port that does not exist.
ctx.ClusterNodes.Add(NewNode(clusterId, "defaulted"));
await ctx.SaveChangesAsync();
await using var read = NewContext();
var explicitNode = await read.ClusterNodes.AsNoTracking()
.SingleAsync(n => n.NodeId == $"{clusterId}-explicit:4053");
explicitNode.AkkaPort.ShouldBe(4055);
explicitNode.GrpcPort.ShouldBe(5223);
var defaulted = await read.ClusterNodes.AsNoTracking()
.SingleAsync(n => n.NodeId == $"{clusterId}-defaulted:4053");
defaulted.AkkaPort.ShouldBe(4053);
defaulted.GrpcPort.ShouldBeNull();
}
/// <summary>
/// Verifies the <b>DB-side</b> default on <c>AkkaPort</c> — the migration's
/// <c>defaultValue: 4053</c>, which is what rows created before the column existed inherit.
/// The entity round-trip above cannot see this: <see cref="ClusterNode.AkkaPort"/>'s CLR
/// initializer is also 4053, so that assertion passes with the mapping default deleted. This
/// one inserts through raw SQL with the column omitted, so only the schema can supply the value.
/// </summary>
[Fact]
public async Task AkkaPort_has_a_DB_side_default_of_4053()
{
await using var ctx = NewContext();
var clusterId = await SeedClusterAsync(ctx);
await using (var conn = fixture.OpenConnection())
{
var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO dbo.ClusterNode (NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@nodeId, @clusterId, @host, 4840, 8081, @uri, 200, 1, 'raw-sql');
""";
cmd.Parameters.AddWithValue("@nodeId", $"{clusterId}-raw:4053");
cmd.Parameters.AddWithValue("@clusterId", clusterId);
cmd.Parameters.AddWithValue("@host", $"{clusterId}-raw");
cmd.Parameters.AddWithValue("@uri", $"urn:OtOpcUa:{clusterId}-raw");
await cmd.ExecuteNonQueryAsync();
}
await using var read = NewContext();
var row = await read.ClusterNodes.AsNoTracking()
.SingleAsync(n => n.NodeId == $"{clusterId}-raw:4053");
row.AkkaPort.ShouldBe(4053);
row.GrpcPort.ShouldBeNull();
}
/// <summary>
/// Verifies the columns are queryable server-side — Phase 2's contact-point refresh selects
/// them in SQL rather than materialising every node, so a client-side-evaluation regression
/// (or an unmapped property) has to fail here.
/// </summary>
[Fact]
public async Task Transport_ports_are_queryable_server_side()
{
await using var ctx = NewContext();
var clusterId = await SeedClusterAsync(ctx);
ctx.ClusterNodes.Add(NewNode(clusterId, "a", n => n.GrpcPort = 5223));
ctx.ClusterNodes.Add(NewNode(clusterId, "b"));
await ctx.SaveChangesAsync();
await using var read = NewContext();
var contacts = await read.ClusterNodes.AsNoTracking()
.Where(n => n.ClusterId == clusterId && n.GrpcPort != null)
.Select(n => new { n.Host, n.AkkaPort, n.GrpcPort })
.ToListAsync();
contacts.Count.ShouldBe(1);
contacts[0].AkkaPort.ShouldBe(4053);
contacts[0].GrpcPort.ShouldBe(5223);
}
private static ClusterNode NewNode(string clusterId, string suffix, Action<ClusterNode>? configure = null)
{
var node = new ClusterNode
{
NodeId = $"{clusterId}-{suffix}:4053",
ClusterId = clusterId,
Host = $"{clusterId}-{suffix}",
ApplicationUri = $"urn:OtOpcUa:{clusterId}-{suffix}",
CreatedBy = nameof(ClusterNodeTransportPortsTests),
};
configure?.Invoke(node);
return node;
}
private static async Task<string> SeedClusterAsync(OtOpcUaConfigDbContext ctx)
{
// Unique per test: the collection shares one database, and ApplicationUri is fleet-wide unique.
var clusterId = $"PORTS-{Guid.NewGuid():N}"[..24];
ctx.ServerClusters.Add(new ServerCluster
{
ClusterId = clusterId,
Name = clusterId,
Enterprise = "zb",
Site = "ports-test",
// CK_ServerCluster_RedundancyMode_NodeCount: Warm/Hot require exactly 2 nodes.
RedundancyMode = RedundancyMode.Warm,
NodeCount = 2,
CreatedBy = nameof(ClusterNodeTransportPortsTests),
});
await ctx.SaveChangesAsync();
return clusterId;
}
private OtOpcUaConfigDbContext NewContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(fixture.ConnectionString)
.Options);
}