test(harness): production-fidelity DI (AddOtOpcUaRuntime) + opt-in fake driver factory
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Opt-in fake <see cref="IDriverFactory"/> the <see cref="TwoNodeClusterHarness"/> can register
|
||||
/// so a deployed driver actually reaches <c>Connected</c> (Healthy) in-process — no Docker sim and
|
||||
/// no real backend. Materialises a <see cref="FakeReconnectDriver"/> for the <c>"Modbus"</c> driver
|
||||
/// type (any other type returns <c>null</c>, mirroring <see cref="NullDriverFactory"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The DI wiring resolves <see cref="IDriverFactory"/> from the container; the production
|
||||
/// <c>AddOtOpcUaRuntime()</c> seeds <see cref="NullDriverFactory"/> via <c>TryAddSingleton</c>, so a
|
||||
/// harness that wants live drivers registers this fake first (its registration wins). Every
|
||||
/// initialize/connect succeeds, so the wrapping <c>DriverInstanceActor</c> walks
|
||||
/// <c>Connecting → Connected → Healthy</c> and (on <c>ReconnectDriver</c>) drives
|
||||
/// <c>ForceReconnect → Reconnecting → re-initialize → Connected</c> without any fault injection.
|
||||
/// </remarks>
|
||||
public sealed class FakeReconnectDriverFactory : IDriverFactory
|
||||
{
|
||||
/// <summary>The single driver type this fake factory materialises.</summary>
|
||||
public const string FakeDriverType = "Modbus";
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <see cref="FakeReconnectDriver"/> when <paramref name="driverType"/> is
|
||||
/// <see cref="FakeDriverType"/>; otherwise <c>null</c> (the host logs + skips the row).
|
||||
/// </summary>
|
||||
/// <param name="driverType">The driver type name from the deployed <c>DriverInstance</c> row.</param>
|
||||
/// <param name="driverInstanceId">The stable driver-instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string (ignored by the fake).</param>
|
||||
/// <returns>A new <see cref="FakeReconnectDriver"/>, or <c>null</c> for an unsupported type.</returns>
|
||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
||||
=> driverType == FakeDriverType
|
||||
? new FakeReconnectDriver(driverInstanceId, driverType)
|
||||
: null;
|
||||
|
||||
/// <summary>Gets the driver-type names this factory can materialise.</summary>
|
||||
public IReadOnlyCollection<string> SupportedTypes { get; } = new[] { FakeDriverType };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A minimal in-process <see cref="IDriver"/> whose connect/initialize path always succeeds, so the
|
||||
/// wrapping <c>DriverInstanceActor</c> reaches <c>Connected</c> and publishes a
|
||||
/// <see cref="DriverState.Healthy"/> 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.
|
||||
/// </summary>
|
||||
public sealed class FakeReconnectDriver : IDriver
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="FakeReconnectDriver"/>.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The stable logical driver-instance identifier.</param>
|
||||
/// <param name="driverType">The driver type name.</param>
|
||||
public FakeReconnectDriver(string driverInstanceId, string driverType)
|
||||
{
|
||||
DriverInstanceId = driverInstanceId;
|
||||
DriverType = driverType;
|
||||
}
|
||||
|
||||
/// <summary>Gets the stable logical ID of this driver instance.</summary>
|
||||
public string DriverInstanceId { get; }
|
||||
|
||||
/// <summary>Gets the driver type name (e.g. "Modbus").</summary>
|
||||
public string DriverType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Connect/initialize path — always succeeds (returns a completed task), so the actor self-Tells
|
||||
/// <c>InitializeSucceeded</c> and becomes <c>Connected</c>. This is the method that makes connect
|
||||
/// succeed; the FSM's reconnect path re-invokes it and it succeeds again.
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON (ignored).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task — initialization always succeeds.</returns>
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <summary>Applies a config change in place — a no-op that always succeeds.</summary>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON (ignored).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task — reinitialization always succeeds.</returns>
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <summary>Stops the driver — a no-op that always succeeds.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <summary>Returns a <see cref="DriverState.Healthy"/> snapshot so deployed drivers publish health.</summary>
|
||||
/// <returns>A <see cref="DriverHealth"/> in the <see cref="DriverState.Healthy"/> state.</returns>
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
|
||||
/// <summary>Returns a zero memory footprint (the fake holds no driver-attributable caches).</summary>
|
||||
/// <returns>Always <c>0</c>.</returns>
|
||||
public long GetMemoryFootprint() => 0;
|
||||
|
||||
/// <summary>Flushes optional caches — a no-op (the fake holds none).</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Optional opt-in <see cref="IDriverFactory"/> both nodes register so deployed drivers can
|
||||
/// reach <c>Connected</c>/Healthy in-process. Null (default) leaves the production
|
||||
/// <see cref="NullDriverFactory"/> from <c>AddOtOpcUaRuntime()</c> in place — unchanged behaviour for
|
||||
/// existing tests.</summary>
|
||||
private IDriverFactory? _driverFactory;
|
||||
|
||||
/// <summary>Gets the first web application node.</summary>
|
||||
public WebApplication NodeA { get; private set; } = null!;
|
||||
/// <summary>Gets the second web application node.</summary>
|
||||
@@ -109,9 +116,15 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
|
||||
/// <summary>Boots both nodes and waits up to <paramref name="formationTimeout"/> for cluster convergence.</summary>
|
||||
/// <param name="formationTimeout">Maximum time to wait for cluster formation; defaults to 20 seconds if not provided.</param>
|
||||
public static async Task<TwoNodeClusterHarness> StartAsync(TimeSpan? formationTimeout = null)
|
||||
/// <param name="driverFactory">Optional opt-in <see cref="IDriverFactory"/> both nodes register so deployed
|
||||
/// drivers reach <c>Connected</c>/Healthy in-process. Null (default) leaves the production
|
||||
/// <see cref="NullDriverFactory"/> default in place — unchanged behaviour for existing tests.</param>
|
||||
public static async Task<TwoNodeClusterHarness> 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<OtOpcUaConfigDbContext>(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<IDriverFactory>(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) =>
|
||||
|
||||
Reference in New Issue
Block a user