using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Admin.Hubs; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; [Trait("Category", "Integration")] public sealed class FleetStatusPollerTests : IDisposable { private const string DefaultServer = "localhost,14330"; private const string DefaultSaPassword = "OtOpcUaDev_2026!"; private readonly string _databaseName = $"OtOpcUaPollerTest_{Guid.NewGuid():N}"; private readonly string _connectionString; private readonly ServiceProvider _sp; public FleetStatusPollerTests() { var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword; _connectionString = $"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;"; var services = new ServiceCollection(); services.AddLogging(); services.AddSignalR(); services.AddDbContext(o => o.UseSqlServer(_connectionString)); _sp = services.BuildServiceProvider(); using var scope = _sp.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.Migrate(); } public void Dispose() { _sp.Dispose(); using var conn = new Microsoft.Data.SqlClient.SqlConnection( new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString) { InitialCatalog = "master" }.ConnectionString); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $@" IF DB_ID(N'{_databaseName}') IS NOT NULL BEGIN ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_databaseName}]; END"; cmd.ExecuteNonQuery(); } [Fact] public async Task Poller_detects_new_apply_state_and_pushes_to_fleet_hub() { // Seed a cluster + node + credential + generation + apply state. using (var scope = _sp.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.ServerClusters.Add(new ServerCluster { ClusterId = "p-1", Name = "Poll test", Enterprise = "zb", Site = "dev", NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t", }); db.ClusterNodes.Add(new ClusterNode { NodeId = "p-1-a", ClusterId = "p-1", RedundancyRole = RedundancyRole.Primary, Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001, ApplicationUri = "urn:p1:test", ServiceLevelBase = 200, Enabled = true, CreatedBy = "t", }); var gen = new ConfigGeneration { ClusterId = "p-1", Status = GenerationStatus.Published, CreatedBy = "t", PublishedBy = "t", PublishedAt = DateTime.UtcNow, }; db.ConfigGenerations.Add(gen); await db.SaveChangesAsync(); db.ClusterNodeGenerationStates.Add(new ClusterNodeGenerationState { NodeId = "p-1-a", CurrentGenerationId = gen.GenerationId, LastAppliedStatus = NodeApplyStatus.Applied, LastAppliedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); } // Recording hub contexts — capture what would be pushed to clients. var recorder = new RecordingHubClients(); var fleetHub = new RecordingHubContext(recorder); var alertHub = new RecordingHubContext(new RecordingHubClients()); var poller = new FleetStatusPoller( _sp.GetRequiredService(), fleetHub, alertHub, NullLogger.Instance); await poller.PollOnceAsync(CancellationToken.None); var match = recorder.SentMessages.FirstOrDefault(m => m.Method == "NodeStateChanged" && m.Args.Length > 0 && m.Args[0] is NodeStateChangedMessage msg && msg.NodeId == "p-1-a"); match.ShouldNotBeNull("poller should have pushed a NodeStateChanged for p-1-a"); } [Fact] public async Task Poller_raises_alert_on_transition_into_Failed() { using (var scope = _sp.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.ServerClusters.Add(new ServerCluster { ClusterId = "p-2", Name = "Fail test", Enterprise = "zb", Site = "dev", NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t", }); db.ClusterNodes.Add(new ClusterNode { NodeId = "p-2-a", ClusterId = "p-2", RedundancyRole = RedundancyRole.Primary, Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001, ApplicationUri = "urn:p2:test", ServiceLevelBase = 200, Enabled = true, CreatedBy = "t", }); db.ClusterNodeGenerationStates.Add(new ClusterNodeGenerationState { NodeId = "p-2-a", LastAppliedStatus = NodeApplyStatus.Failed, LastAppliedError = "simulated", LastAppliedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); } var alerts = new RecordingHubClients(); var alertHub = new RecordingHubContext(alerts); var fleetHub = new RecordingHubContext(new RecordingHubClients()); var poller = new FleetStatusPoller( _sp.GetRequiredService(), fleetHub, alertHub, NullLogger.Instance); await poller.PollOnceAsync(CancellationToken.None); var alertMatch = alerts.SentMessages.FirstOrDefault(m => m.Method == "AlertRaised" && m.Args.Length > 0 && m.Args[0] is AlertMessage alert && alert.NodeId == "p-2-a" && alert.Severity == "error"); alertMatch.ShouldNotBeNull("poller should have raised AlertRaised for p-2-a"); } }