Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/HostStatusPublisherTests.cs
Joseph Doherty a25593a9c6 chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:55:28 -04:00

198 lines
9.5 KiB
C#

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<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
_sp = services.BuildServiceProvider();
using var scope = _sp.CreateScope();
scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().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<IServiceScopeFactory>(),
NullLogger<HostStatusPublisher>.Instance);
await publisher.PublishOnceAsync(CancellationToken.None);
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
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<IServiceScopeFactory>(),
NullLogger<HostStatusPublisher>.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<OtOpcUaConfigDbContext>();
(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<IServiceScopeFactory>(),
NullLogger<HostStatusPublisher>.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<Configuration.Entities.DriverHostStatus> SingleRowAsync(string node, string driver, string host)
{
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
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<HostStatusChangedEventArgs>? 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<HostConnectivityStatus> 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;
}
}