2bbb02713c
Per-cluster mesh Phase 1 groundwork. Once the meshes split (Phase 2) central can no longer see a site node's appsettings, so the transport ports it must dial have to live in a row central can read. AkkaPort is non-nullable with a 4053 default — every node listens on a remoting port, so 0 is never a truthful value and pre-existing rows must migrate to something real. GrpcPort is nullable with no default: nothing listens on it until Phase 5, and a non-null default would assert a port that does not exist. Both are documented as central's dial targets rather than the node's own binding config; the duplication against Cluster:Port is reconciled in Task 4. The new SchemaCompliance test is red until the migration lands (Task 2). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
113 lines
4.7 KiB
C#
113 lines
4.7 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
|
|
/// builds its ClusterClient 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 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",
|
|
RedundancyMode = RedundancyMode.Warm,
|
|
CreatedBy = nameof(ClusterNodeTransportPortsTests),
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
return clusterId;
|
|
}
|
|
|
|
private OtOpcUaConfigDbContext NewContext() =>
|
|
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
|
.UseSqlServer(fixture.ConnectionString)
|
|
.Options);
|
|
}
|