diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs new file mode 100644 index 00000000..d9e560e3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs @@ -0,0 +1,103 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// Opt-in fake the can register +/// so a deployed driver actually reaches Connected (Healthy) in-process — no Docker sim and +/// no real backend. Materialises a for the "Modbus" driver +/// type (any other type returns null, mirroring ). +/// +/// +/// The DI wiring resolves from the container; the production +/// AddOtOpcUaRuntime() seeds via TryAddSingleton, so a +/// harness that wants live drivers registers this fake first (its registration wins). Every +/// initialize/connect succeeds, so the wrapping DriverInstanceActor walks +/// Connecting → Connected → Healthy and (on ReconnectDriver) drives +/// ForceReconnect → Reconnecting → re-initialize → Connected without any fault injection. +/// +public sealed class FakeReconnectDriverFactory : IDriverFactory +{ + /// The single driver type this fake factory materialises. + public const string FakeDriverType = "Modbus"; + + /// + /// Returns a when is + /// ; otherwise null (the host logs + skips the row). + /// + /// The driver type name from the deployed DriverInstance row. + /// The stable driver-instance identifier. + /// The driver configuration as a JSON string (ignored by the fake). + /// A new , or null for an unsupported type. + public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) + => driverType == FakeDriverType + ? new FakeReconnectDriver(driverInstanceId, driverType) + : null; + + /// Gets the driver-type names this factory can materialise. + public IReadOnlyCollection SupportedTypes { get; } = new[] { FakeDriverType }; +} + +/// +/// A minimal in-process whose connect/initialize path always succeeds, so the +/// wrapping DriverInstanceActor reaches Connected and publishes a +/// snapshot. Reads/writes/subscribes are benign no-ops; this exists +/// only to let a deployed driver walk the Healthy ↔ Reconnecting FSM in-process for E2E tests. +/// +public sealed class FakeReconnectDriver : IDriver +{ + /// + /// Initializes a new . + /// + /// The stable logical driver-instance identifier. + /// The driver type name. + public FakeReconnectDriver(string driverInstanceId, string driverType) + { + DriverInstanceId = driverInstanceId; + DriverType = driverType; + } + + /// Gets the stable logical ID of this driver instance. + public string DriverInstanceId { get; } + + /// Gets the driver type name (e.g. "Modbus"). + public string DriverType { get; } + + /// + /// Connect/initialize path — always succeeds (returns a completed task), so the actor self-Tells + /// InitializeSucceeded and becomes Connected. This is the method that makes connect + /// succeed; the FSM's reconnect path re-invokes it and it succeeds again. + /// + /// The driver configuration JSON (ignored). + /// Cancellation token for the operation. + /// A completed task — initialization always succeeds. + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + => Task.CompletedTask; + + /// Applies a config change in place — a no-op that always succeeds. + /// The driver configuration JSON (ignored). + /// Cancellation token for the operation. + /// A completed task — reinitialization always succeeds. + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + => Task.CompletedTask; + + /// Stops the driver — a no-op that always succeeds. + /// Cancellation token for the operation. + /// A completed task. + public Task ShutdownAsync(CancellationToken cancellationToken) + => Task.CompletedTask; + + /// Returns a snapshot so deployed drivers publish health. + /// A in the state. + public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); + + /// Returns a zero memory footprint (the fake holds no driver-attributable caches). + /// Always 0. + public long GetMemoryFootprint() => 0; + + /// Flushes optional caches — a no-op (the fake holds none). + /// Cancellation token for the operation. + /// A completed task. + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) + => Task.CompletedTask; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs index 09093f4e..a80f36a5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs @@ -15,6 +15,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.ControlPlane; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Host.Health; using ZB.MOM.WW.OtOpcUa.Runtime; using ZB.MOM.WW.OtOpcUa.Security; @@ -71,6 +72,12 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable private string? _sqlDbName; private string? _sqlConnString; + /// Optional opt-in both nodes register so deployed drivers can + /// reach Connected/Healthy in-process. Null (default) leaves the production + /// from AddOtOpcUaRuntime() in place — unchanged behaviour for + /// existing tests. + private IDriverFactory? _driverFactory; + /// Gets the first web application node. public WebApplication NodeA { get; private set; } = null!; /// Gets the second web application node. @@ -109,9 +116,15 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable /// Boots both nodes and waits up to for cluster convergence. /// Maximum time to wait for cluster formation; defaults to 20 seconds if not provided. - public static async Task StartAsync(TimeSpan? formationTimeout = null) + /// Optional opt-in both nodes register so deployed + /// drivers reach Connected/Healthy in-process. Null (default) leaves the production + /// default in place — unchanged behaviour for existing tests. + public static async Task StartAsync( + TimeSpan? formationTimeout = null, + IDriverFactory? driverFactory = null) { var harness = new TwoNodeClusterHarness(); + harness._driverFactory = driverFactory; harness.NodeAAkkaPort = AllocateFreePort(); harness.NodeBAkkaPort = AllocateFreePort(); @@ -237,6 +250,18 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase(harness.SharedDbName)); } + // Production fidelity: Program.cs calls AddOtOpcUaRuntime() (DI registration) AND + // WithOtOpcUaRuntimeActors() (Akka spawn). The harness historically called only the latter, + // so IDriverHealthPublisher fell back to NullDriverHealthPublisher (no health on the + // driver-health DPS topic) and IDriverFactory to NullDriverFactory (deployed drivers never + // reached Connected). Register the opt-in fake factory FIRST so its registration wins over the + // TryAddSingleton(NullDriverFactory) seeded inside AddOtOpcUaRuntime; when no + // factory was passed, the Null default stays — unchanged behaviour for existing tests. Must run + // BEFORE AddAkka (see the AddOtOpcUaRuntime XML doc). + if (harness._driverFactory is not null) + builder.Services.AddSingleton(harness._driverFactory); + builder.Services.AddOtOpcUaRuntime(); + builder.Services.AddOtOpcUaCluster(builder.Configuration); builder.Services.AddAkka("otopcua", (ab, sp) =>