Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ClusterNodeTransportPortsTests.cs
T
Joseph Doherty da74ebd696 feat(config): migration AddClusterNodeTransportPorts
Additive only — two AddColumn ops, no AlterColumn, so no accumulated model
drift is riding along with this change:

  ALTER TABLE [ClusterNode] ADD [AkkaPort] int NOT NULL DEFAULT 4053;
  ALTER TABLE [ClusterNode] ADD [GrpcPort] int NULL;

Adds a third test covering the DB-side default specifically. The entity
round-trip cannot see it: ClusterNode.AkkaPort's CLR initializer is also 4053,
so that assertion passes with the mapping default deleted. The new test inserts
through raw SQL with the column omitted, so only the schema can supply the
value — verified by setting defaultValue: 0, which turns exactly that one test
red and leaves the other two green.

Configuration.Tests 95/95.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 08:26:39 -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
/// 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 <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);
}