using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Server; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; [Trait("Category", "Integration")] public sealed class HostStatusPublisherTests : IDisposable { private const string DefaultServer = "localhost,14330"; private const string DefaultSaPassword = "OtOpcUaDev_2026!"; private readonly string _databaseName = $"OtOpcUaPublisher_{Guid.NewGuid():N}"; private readonly string _connectionString; private readonly ServiceProvider _sp; public HostStatusPublisherTests() { 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.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 Publisher_upserts_one_row_per_host_reported_by_each_probe_driver() { var driverHost = new DriverHost(); await driverHost.RegisterAsync(new ProbeStubDriver("driver-a", new HostConnectivityStatus("HostA1", HostState.Running, DateTime.UtcNow), new HostConnectivityStatus("HostA2", HostState.Stopped, DateTime.UtcNow)), "{}", CancellationToken.None); await driverHost.RegisterAsync(new NonProbeStubDriver("driver-no-probe"), "{}", CancellationToken.None); var nodeOptions = NewNodeOptions("node-a"); var publisher = new HostStatusPublisher(driverHost, nodeOptions, _sp.GetRequiredService(), NullLogger.Instance); await publisher.PublishOnceAsync(CancellationToken.None); using var scope = _sp.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var rows = await db.DriverHostStatuses.AsNoTracking().ToListAsync(); rows.Count.ShouldBe(2, "driver-no-probe doesn't implement IHostConnectivityProbe — no rows for it"); rows.ShouldContain(r => r.HostName == "HostA1" && r.State == DriverHostState.Running && r.DriverInstanceId == "driver-a"); rows.ShouldContain(r => r.HostName == "HostA2" && r.State == DriverHostState.Stopped && r.DriverInstanceId == "driver-a"); rows.ShouldAllBe(r => r.NodeId == "node-a"); } [Fact] public async Task Second_tick_updates_LastSeenUtc_without_creating_duplicate_rows() { var driver = new ProbeStubDriver("driver-x", new HostConnectivityStatus("HostX", HostState.Running, DateTime.UtcNow)); var driverHost = new DriverHost(); await driverHost.RegisterAsync(driver, "{}", CancellationToken.None); var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-x"), _sp.GetRequiredService(), NullLogger.Instance); await publisher.PublishOnceAsync(CancellationToken.None); var firstSeen = await SingleRowAsync("node-x", "driver-x", "HostX"); await Task.Delay(50); // guarantee a later wall-clock value so LastSeenUtc advances await publisher.PublishOnceAsync(CancellationToken.None); var secondSeen = await SingleRowAsync("node-x", "driver-x", "HostX"); secondSeen.LastSeenUtc.ShouldBeGreaterThan(firstSeen.LastSeenUtc, "heartbeat advances LastSeenUtc so Admin can stale-flag rows from crashed Servers"); // Still exactly one row — a naive Add-every-tick would have thrown or duplicated. using var scope = _sp.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); (await db.DriverHostStatuses.CountAsync(r => r.NodeId == "node-x")).ShouldBe(1); } [Fact] public async Task State_change_between_ticks_updates_State_and_StateChangedUtc() { var driver = new ProbeStubDriver("driver-y", new HostConnectivityStatus("HostY", HostState.Running, DateTime.UtcNow.AddSeconds(-10))); var driverHost = new DriverHost(); await driverHost.RegisterAsync(driver, "{}", CancellationToken.None); var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-y"), _sp.GetRequiredService(), NullLogger.Instance); await publisher.PublishOnceAsync(CancellationToken.None); var before = await SingleRowAsync("node-y", "driver-y", "HostY"); // Swap the driver's reported state to Faulted with a newer transition timestamp. var newChange = DateTime.UtcNow; driver.Statuses = [new HostConnectivityStatus("HostY", HostState.Faulted, newChange)]; await publisher.PublishOnceAsync(CancellationToken.None); var after = await SingleRowAsync("node-y", "driver-y", "HostY"); after.State.ShouldBe(DriverHostState.Faulted); // datetime2(3) has millisecond precision — DateTime.UtcNow carries up to 100ns ticks, // so the stored value rounds down. Compare at millisecond granularity to stay clean. after.StateChangedUtc.ShouldBe(newChange, tolerance: TimeSpan.FromMilliseconds(1)); after.StateChangedUtc.ShouldBeGreaterThan(before.StateChangedUtc, "StateChangedUtc must advance when the state actually changed"); before.State.ShouldBe(DriverHostState.Running); } [Fact] public void MapState_translates_every_HostState_member() { HostStatusPublisher.MapState(HostState.Running).ShouldBe(DriverHostState.Running); HostStatusPublisher.MapState(HostState.Stopped).ShouldBe(DriverHostState.Stopped); HostStatusPublisher.MapState(HostState.Faulted).ShouldBe(DriverHostState.Faulted); HostStatusPublisher.MapState(HostState.Unknown).ShouldBe(DriverHostState.Unknown); } private async Task SingleRowAsync(string node, string driver, string host) { using var scope = _sp.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return await db.DriverHostStatuses.AsNoTracking() .SingleAsync(r => r.NodeId == node && r.DriverInstanceId == driver && r.HostName == host); } private static NodeOptions NewNodeOptions(string nodeId) => new() { NodeId = nodeId, ClusterId = "cluster-t", ConfigDbConnectionString = "unused-publisher-gets-db-from-scope", }; private sealed class ProbeStubDriver(string id, params HostConnectivityStatus[] initial) : IDriver, IHostConnectivityProbe { public HostConnectivityStatus[] Statuses { get; set; } = initial; public string DriverInstanceId => id; public string DriverType => "ProbeStub"; public event EventHandler? OnHostStatusChanged; public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); public long GetMemoryFootprint() => 0; public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; public IReadOnlyList GetHostStatuses() => Statuses; // Keeps the compiler happy — event is part of the interface contract even if unused here. internal void Raise(HostStatusChangedEventArgs e) => OnHostStatusChanged?.Invoke(this, e); } private sealed class NonProbeStubDriver(string id) : IDriver { public string DriverInstanceId => id; public string DriverType => "NonProbeStub"; public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); public long GetMemoryFootprint() => 0; public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; } }