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 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", RedundancyMode = RedundancyMode.Warm, CreatedBy = nameof(ClusterNodeTransportPortsTests), }); await ctx.SaveChangesAsync(); return clusterId; } private OtOpcUaConfigDbContext NewContext() => new(new DbContextOptionsBuilder() .UseSqlServer(fixture.ConnectionString) .Options); }