using Akka.Actor; using Akka.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Communication; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests; /// /// Verifies WithOtOpcUaRuntimeActors spawns DriverHostActor + DbHealthProbeActor /// on the host's ActorSystem and registers both under their marker keys. This is the /// driver-role mirror of the admin-role WithOtOpcUaControlPlaneSingletons bootstrap. /// public sealed class ServiceCollectionExtensionsTests { /// Verifies that WithOtOpcUaRuntimeActors spawns driver host and DB health probe actors. [Fact] public async Task WithOtOpcUaRuntimeActors_spawns_driver_host_and_db_health_probe() { using var host = Host.CreateDefaultBuilder() .ConfigureServices((_, services) => { services.AddSingleton>( new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"))); services.AddSingleton(new FakeClusterRoleInfo()); services.AddAkka("otopcua-test", (ab, _) => { ab.AddHocon(@" akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"" akka.remote.dot-netty.tcp.hostname = ""127.0.0.1"" akka.remote.dot-netty.tcp.port = 0 akka.cluster.seed-nodes = [] akka.cluster.roles = [""driver""] ", HoconAddMode.Prepend); ab.WithOtOpcUaRuntimeActors(); }); }) .Build(); await host.StartAsync(); try { var driverHost = host.Services.GetRequiredService>(); var dbHealth = host.Services.GetRequiredService>(); var historian = host.Services.GetRequiredService>(); var mux = host.Services.GetRequiredService>(); var publish = host.Services.GetRequiredService>(); var peerProbes = host.Services.GetRequiredService>(); driverHost.ActorRef.ShouldNotBeNull(); dbHealth.ActorRef.ShouldNotBeNull(); historian.ActorRef.ShouldNotBeNull(); mux.ActorRef.ShouldNotBeNull(); publish.ActorRef.ShouldNotBeNull(); peerProbes.ActorRef.ShouldNotBeNull(); driverHost.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DriverHostActorName); dbHealth.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DbHealthProbeActorName); historian.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.HistorianAdapterActorName); mux.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DependencyMuxActorName); publish.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.OpcUaPublishActorName); peerProbes.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.PeerProbeSupervisorName); } finally { await host.StopAsync(); } } /// /// When ContinuousHistorization is not enabled (no options registered), the recorder /// actor is NOT spawned — its key does not resolve in the registry. /// [Fact] public async Task Recorder_not_spawned_when_continuous_historization_disabled() { using var host = BuildRuntimeActorHost(extra: null); await host.StartAsync(); try { var registry = host.Services.GetRequiredService(); registry.TryGet(out _).ShouldBeFalse(); } finally { await host.StopAsync(); } } /// /// When ContinuousHistorization is enabled and a value-writer + outbox are registered, /// the recorder actor IS spawned and its key resolves under the expected actor name. /// [Fact] public async Task Recorder_spawned_when_enabled_with_writer_and_outbox() { using var host = BuildRuntimeActorHost(extra: services => { services.AddSingleton(new ContinuousHistorizationOptions { Enabled = true, OutboxPath = "x" }); services.AddSingleton(new FakeValueWriter()); services.AddSingleton(new FakeOutbox()); }); await host.StartAsync(); try { var recorder = host.Services.GetRequiredService>(); recorder.ActorRef.ShouldNotBeNull(); recorder.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.ContinuousHistorizationRecorderActorName); } finally { await host.StopAsync(); } } /// /// Under MeshTransport:Mode = Grpc an ApplyAck produced by the driver host reaches the /// registered . /// /// /// This is the fact that pins the ackRouter gate in WithOtOpcUaRuntimeActors /// (useGrpc ? nodeComm : null). Drop the useGrpc arm and that gate still compiles /// and every unit test still passes, while every ack on a Grpc node goes out on the /// deployment-acks DPS topic instead — where, in the split-mesh topology Grpc exists to /// serve, central is not subscribed. The whole transport would be dead and nothing below this /// level of the stack could tell. /// /// The dispatch names a deployment that does not exist in the in-memory ConfigDb, so the apply /// fails and the host emits a Failed ack — deterministic, synchronous, and it exercises the /// real SendAck path rather than poking the router directly. /// [Fact] public async Task Under_Grpc_a_driver_host_ack_reaches_the_ack_sender() { var sender = new RecordingAckSender(); using var host = BuildRuntimeActorHost(services => { services.AddSingleton(sender); services.Configure(o => { o.Mode = MeshTransportOptions.ModeGrpc; o.CentralGrpcEndpoints = ["http://central-1:4055"]; o.GrpcPsk = "rig-key"; }); }); await host.StartAsync(); try { var driverHost = host.Services.GetRequiredService>(); driverHost.ActorRef.Tell(new DispatchDeployment( new DeploymentId(Guid.NewGuid()), new RevisionHash("rev-that-does-not-exist"), new CorrelationId(Guid.NewGuid()))); // The ack travelled DriverHostActor -> NodeCommunicationActor -> IApplyAckSender. With the // useGrpc arm dropped from the ackRouter gate it would have gone to DPS instead and this // never completes. var deadline = DateTime.UtcNow.AddSeconds(15); while (sender.Sent.Count == 0 && DateTime.UtcNow < deadline) await Task.Delay(50); sender.Sent.Count.ShouldBe(1); } finally { await host.StopAsync(); } } /// An double recording what the node comm actor handed it. private sealed class RecordingAckSender : IApplyAckSender { private readonly Lock _gate = new(); private readonly List _sent = []; /// Gets a snapshot of the acks posted through this sender. public IReadOnlyList Sent { get { lock (_gate) return [.. _sent]; } } /// Records . /// The ack handed over. /// Unused. /// A completed task. public Task SendAsync(ApplyAck ack, CancellationToken cancellationToken = default) { lock (_gate) _sent.Add(ack); return Task.CompletedTask; } } /// Builds a driver-role host that runs WithOtOpcUaRuntimeActors, with optional /// extra DI registrations applied before AddAkka. private static IHost BuildRuntimeActorHost(Action? extra) => Host.CreateDefaultBuilder() .ConfigureServices((_, services) => { services.AddSingleton>( new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"))); services.AddSingleton(new FakeClusterRoleInfo()); extra?.Invoke(services); services.AddAkka("otopcua-test", (ab, _) => { ab.AddHocon(@" akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"" akka.remote.dot-netty.tcp.hostname = ""127.0.0.1"" akka.remote.dot-netty.tcp.port = 0 akka.cluster.seed-nodes = [] akka.cluster.roles = [""driver""] ", HoconAddMode.Prepend); ab.WithOtOpcUaRuntimeActors(); }); }) .Build(); /// Non-throwing fake value writer that acks every batch. private sealed class FakeValueWriter : IHistorianValueWriter { /// Acks the write unconditionally. public Task WriteLiveValuesAsync( string tag, IReadOnlyList values, CancellationToken ct) => Task.FromResult(true); } /// Empty in-memory outbox fake — the spawn test only needs construction, not draining. private sealed class FakeOutbox : IHistorizationOutbox { /// Never drops (unbounded). public long DroppedCount => 0; /// No-op append. public ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct) => ValueTask.CompletedTask; /// No-op batch append. public ValueTask AppendBatchAsync(IReadOnlyList entries, CancellationToken ct) => ValueTask.CompletedTask; /// Always returns an empty batch. public ValueTask> PeekBatchAsync(int max, CancellationToken ct) => ValueTask.FromResult>(Array.Empty()); /// No-op remove. public ValueTask RemoveAsync(Guid id, CancellationToken ct) => ValueTask.CompletedTask; /// Always empty. public ValueTask CountAsync(CancellationToken ct) => ValueTask.FromResult(0); /// No-op dispose. public void Dispose() { } } /// In-memory database factory for testing. private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory { /// Creates a new in-memory database context. public OtOpcUaConfigDbContext CreateDbContext() { var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase(dbName) .Options; return new OtOpcUaConfigDbContext(opts); } } /// Fake cluster role information for testing. private sealed class FakeClusterRoleInfo : IClusterRoleInfo { /// Gets the local node ID. public NodeId LocalNode { get; } = NodeId.Parse("test-node"); /// Gets the local roles. public IReadOnlySet LocalRoles { get; } = new HashSet(["driver"]); /// Gets the local node's cluster-scoped role. None configured in this fake. public string? ClusterRole => null; /// Gets the ClusterId extracted from . None configured in this fake. public string? ClusterId => null; /// Determines whether the local node has the specified role. /// The role to check. public bool HasRole(string role) => LocalRoles.Contains(role); /// Gets the members with the specified role. /// The role to query. public IReadOnlyList MembersWithRole(string role) => Array.Empty(); /// Gets the leader node for the specified role. /// The role to query. public NodeId? RoleLeader(string role) => null; /// Raised when the role leader changes. public event EventHandler? RoleLeaderChanged { add { _ = value; } remove { _ = value; } } } }