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;
///
/// Round-trips / through
/// the real schema (per-cluster mesh Phase 1). These are central's dial targets: Phase 2
/// builds its ClusterClient contact points from Host + AkkaPort, and Phase 5 dials
/// GrpcPort 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.
///
[Trait("Category", "SchemaCompliance")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class ClusterNodeTransportPortsTests(SchemaComplianceFixture fixture)
{
/// Verifies both ports survive a DB round-trip and that AkkaPort defaults to 4053.
[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();
}
///
/// Verifies the DB-side default on AkkaPort — the migration's
/// defaultValue: 4053, which is what rows created before the column existed inherit.
/// The entity round-trip above cannot see this: '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.
///
[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();
}
///
/// 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.
///
[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? 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 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()
.UseSqlServer(fixture.ConnectionString)
.Options);
}