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>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Observability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class HealthEndpointsHostTests : IAsyncLifetime
|
||||
{
|
||||
private static int _portCounter = 48500 + Random.Shared.Next(0, 99);
|
||||
private readonly int _port = Interlocked.Increment(ref _portCounter);
|
||||
private string Prefix => $"http://localhost:{_port}/";
|
||||
private readonly DriverHost _driverHost = new();
|
||||
private HealthEndpointsHost _host = null!;
|
||||
private HttpClient _client = null!;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_client = new HttpClient { BaseAddress = new Uri(Prefix) };
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
if (_host is not null) await _host.DisposeAsync();
|
||||
}
|
||||
|
||||
private HealthEndpointsHost Start(Func<bool>? configDbHealthy = null, Func<bool>? usingStaleConfig = null)
|
||||
{
|
||||
_host = new HealthEndpointsHost(
|
||||
_driverHost,
|
||||
NullLogger<HealthEndpointsHost>.Instance,
|
||||
configDbHealthy,
|
||||
usingStaleConfig,
|
||||
prefix: Prefix);
|
||||
_host.Start();
|
||||
return _host;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Healthz_ReturnsHealthy_EmptyFleet()
|
||||
{
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/healthz");
|
||||
|
||||
response.IsSuccessStatusCode.ShouldBeTrue();
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("status").GetString().ShouldBe("healthy");
|
||||
body.GetProperty("configDbReachable").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("usingStaleConfig").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Healthz_StaleConfig_Returns200_WithFlag()
|
||||
{
|
||||
Start(configDbHealthy: () => false, usingStaleConfig: () => true);
|
||||
|
||||
var response = await _client.GetAsync("/healthz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("configDbReachable").GetBoolean().ShouldBeFalse();
|
||||
body.GetProperty("usingStaleConfig").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Healthz_UnreachableConfig_And_NoCache_Returns503()
|
||||
{
|
||||
Start(configDbHealthy: () => false, usingStaleConfig: () => false);
|
||||
|
||||
var response = await _client.GetAsync("/healthz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_EmptyFleet_Is200_Healthy()
|
||||
{
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/readyz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("verdict").GetString().ShouldBe("Healthy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_WithHealthyDriver_Is200()
|
||||
{
|
||||
await _driverHost.RegisterAsync(new StubDriver("drv-1", DriverState.Healthy), "{}", CancellationToken.None);
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/readyz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("verdict").GetString().ShouldBe("Healthy");
|
||||
body.GetProperty("drivers").GetArrayLength().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_WithFaultedDriver_Is503()
|
||||
{
|
||||
await _driverHost.RegisterAsync(new StubDriver("dead", DriverState.Faulted), "{}", CancellationToken.None);
|
||||
await _driverHost.RegisterAsync(new StubDriver("alive", DriverState.Healthy), "{}", CancellationToken.None);
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/readyz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("verdict").GetString().ShouldBe("Faulted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_WithDegradedDriver_Is200_WithDegradedList()
|
||||
{
|
||||
await _driverHost.RegisterAsync(new StubDriver("drv-ok", DriverState.Healthy), "{}", CancellationToken.None);
|
||||
await _driverHost.RegisterAsync(new StubDriver("drv-deg", DriverState.Degraded), "{}", CancellationToken.None);
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/readyz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("verdict").GetString().ShouldBe("Degraded");
|
||||
body.GetProperty("degradedDrivers").GetArrayLength().ShouldBe(1);
|
||||
body.GetProperty("degradedDrivers")[0].GetString().ShouldBe("drv-deg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_WithInitializingDriver_Is503()
|
||||
{
|
||||
await _driverHost.RegisterAsync(new StubDriver("init", DriverState.Initializing), "{}", CancellationToken.None);
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/readyz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_Path_Returns404()
|
||||
{
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/foo");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ===== #154 — driver-diagnostics endpoint =====
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnostics_ReturnsModbusAutoProhibitions_ForLiveDriver()
|
||||
{
|
||||
// Bring up a Modbus driver with a programmable transport that protects register 102,
|
||||
// record one prohibition, then hit /diagnostics/drivers/{id}/modbus/auto-prohibited.
|
||||
var fake = new ModbusDriverDiagnosticsTransport { ProtectedAddress = 102 };
|
||||
var t1 = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusTagDefinition(
|
||||
"T1", ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion.HoldingRegisters, 100, ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType.Int16);
|
||||
var t2 = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusTagDefinition(
|
||||
"T2", ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion.HoldingRegisters, 102, ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType.Int16);
|
||||
var opts = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriverOptions
|
||||
{
|
||||
Host = "f", Tags = [t1, t2], MaxReadGap = 5,
|
||||
Probe = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
var driver = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriver(opts, "diag-mb", _ => fake);
|
||||
await _driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
await driver.ReadAsync(["T1", "T2"], CancellationToken.None);
|
||||
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/diagnostics/drivers/diag-mb/modbus/auto-prohibited");
|
||||
|
||||
response.IsSuccessStatusCode.ShouldBeTrue();
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("driverInstanceId").GetString().ShouldBe("diag-mb");
|
||||
body.GetProperty("count").GetInt32().ShouldBe(1);
|
||||
var first = body.GetProperty("ranges")[0];
|
||||
first.GetProperty("startAddress").GetInt32().ShouldBe(100);
|
||||
first.GetProperty("endAddress").GetInt32().ShouldBe(102);
|
||||
first.GetProperty("region").GetString().ShouldBe("HoldingRegisters");
|
||||
first.GetProperty("bisectionPending").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnostics_404_When_Driver_Not_Found()
|
||||
{
|
||||
Start();
|
||||
var response = await _client.GetAsync("/diagnostics/drivers/no-such/modbus/auto-prohibited");
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnostics_400_When_Driver_Is_Wrong_Type()
|
||||
{
|
||||
await _driverHost.RegisterAsync(new StubDriver("not-modbus", DriverState.Healthy), "{}", CancellationToken.None);
|
||||
Start();
|
||||
var response = await _client.GetAsync("/diagnostics/drivers/not-modbus/modbus/auto-prohibited");
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
private sealed class ModbusDriverDiagnosticsTransport : ZB.MOM.WW.OtOpcUa.Driver.Modbus.IModbusTransport
|
||||
{
|
||||
public ushort ProtectedAddress { get; set; } = 102;
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
if (pdu[0] is 0x03 && ProtectedAddress >= addr && ProtectedAddress < addr + qty)
|
||||
return Task.FromException<byte[]>(new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusException(0x03, 0x02, "IllegalDataAddress"));
|
||||
var resp = new byte[2 + qty * 2];
|
||||
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class StubDriver : IDriver
|
||||
{
|
||||
private readonly DriverState _state;
|
||||
public StubDriver(string id, DriverState state)
|
||||
{
|
||||
DriverInstanceId = id;
|
||||
_state = state;
|
||||
}
|
||||
public string DriverInstanceId { get; }
|
||||
public string DriverType => "Stub";
|
||||
public Task InitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(_state, null, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user