using Akka.Hosting; using Akka.TestKit.Xunit2; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Host.Grpc; using ZB.MOM.WW.OtOpcUa.Runtime; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Grpc; /// /// Verifies the node-side command ingress (mesh gRPC command plane, Phase 1): each of the four /// central→node commands is decoded off its and handed to the /// registered NodeCommunicationActor as the same CLR record every other transport /// delivers, an undecodable envelope is refused with , and /// a call landing in the boot window before the actor is registered is refused with /// . /// /// /// The actor is a TestProbe registered under : what /// this service owes the rest of the system is the exact message the real actor already knows how to /// re-emit, so asserting on what arrives at the registry key is the whole contract. The onward leg — /// comm actor to EventStream to the real driver host — is pinned unchanged by /// EventStreamCommandDeliveryTests in the Runtime test project. /// public sealed class NodeCommandGrpcServiceTests : TestKit { private static readonly CorrelationId AlarmCorrelation = new(Guid.NewGuid()); private static ServerCallContext Ctx() => new FakeServerCallContext(); private static NodeCommandGrpcService NewService(ActorRegistry registry) => new(registry, NullLogger.Instance); [Fact] public async Task Each_of_the_four_commands_reaches_the_comm_actor_as_its_domain_record() { var probe = CreateTestProbe(); var registry = new ActorRegistry(); registry.Register(probe.Ref); var service = NewService(registry); var dispatch = new DispatchDeployment( new DeploymentId(Guid.NewGuid()), new RevisionHash("rev-1"), new CorrelationId(Guid.NewGuid())); var restart = new RestartDriver("cluster-a", "drv-1", "alice", Guid.NewGuid()); var reconnect = new ReconnectDriver("cluster-a", "drv-2", "bob", Guid.NewGuid()); var alarm = new AlarmCommand("alarm-7", "Acknowledge", "carol", "looked at it", null); (await service.Execute(MeshTransportDtoMapper.ToEnvelope(dispatch), Ctx())).ShouldNotBeNull(); probe.ExpectMsg().ShouldBe(dispatch); await service.Execute(MeshTransportDtoMapper.ToEnvelope(restart), Ctx()); probe.ExpectMsg().ShouldBe(restart); await service.Execute(MeshTransportDtoMapper.ToEnvelope(reconnect), Ctx()); probe.ExpectMsg().ShouldBe(reconnect); await service.Execute(MeshTransportDtoMapper.ToEnvelope(alarm, AlarmCorrelation), Ctx()); probe.ExpectMsg().ShouldBe(alarm); } [Fact] public async Task A_call_in_the_boot_window_before_the_actor_is_registered_is_unavailable() { // Nothing registered: the ActorSystem exists but AddOtOpcUaRuntime has not spawned the comm // actor yet. Unavailable (not Internal) because the condition is transient and retryable — // central's dialer may try the same node again a moment later and succeed. var service = NewService(new ActorRegistry()); var dispatch = new DispatchDeployment( new DeploymentId(Guid.NewGuid()), new RevisionHash("rev-1"), new CorrelationId(Guid.NewGuid())); var ex = await Should.ThrowAsync( () => service.Execute(MeshTransportDtoMapper.ToEnvelope(dispatch), Ctx())); ex.StatusCode.ShouldBe(StatusCode.Unavailable); } [Fact] public async Task An_unset_oneof_is_invalid_argument() { var probe = CreateTestProbe(); var registry = new ActorRegistry(); registry.Register(probe.Ref); var service = NewService(registry); var ex = await Should.ThrowAsync(() => service.Execute( new CommandEnvelope { CorrelationId = Guid.NewGuid().ToString("N") }, Ctx())); ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); probe.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } [Theory] [MemberData(nameof(UndecodableEnvelopes))] public async Task An_envelope_whose_fields_do_not_parse_is_invalid_argument(CommandEnvelope envelope) { var probe = CreateTestProbe(); var registry = new ActorRegistry(); registry.Register(probe.Ref); var service = NewService(registry); var ex = await Should.ThrowAsync(() => service.Execute(envelope, Ctx())); ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); probe.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } public static TheoryData UndecodableEnvelopes() => [ // Deployment id is not a hyphenless GUID -> FormatException out of the mapper. new CommandEnvelope { CorrelationId = Guid.NewGuid().ToString("N"), DispatchDeployment = new DispatchDeploymentCommand { DeploymentId = "not-a-guid", RevisionHash = "rev-1", CorrelationId = Guid.NewGuid().ToString("N"), }, }, // Revision hash is blank -> ArgumentException out of RevisionHash.Parse. new CommandEnvelope { CorrelationId = Guid.NewGuid().ToString("N"), DispatchDeployment = new DispatchDeploymentCommand { DeploymentId = Guid.NewGuid().ToString("N"), RevisionHash = "", CorrelationId = Guid.NewGuid().ToString("N"), }, }, // Restart command with an unparseable correlation id -> FormatException. new CommandEnvelope { CorrelationId = Guid.NewGuid().ToString("N"), RestartDriver = new RestartDriverCommand { ClusterId = "cluster-a", DriverInstanceId = "drv-1", ActorByUserName = "alice", CorrelationId = "nope", }, }, ]; [Fact] public async Task An_undecodable_envelope_in_the_boot_window_is_invalid_argument_not_unavailable() { // Ordering pin: decode runs BEFORE the registry lookup. Unavailable invites the caller to // retry, and an envelope this build cannot decode will never decode — reporting it as a // readiness problem would turn a permanent fault into an unbounded retry loop. var service = NewService(new ActorRegistry()); var ex = await Should.ThrowAsync(() => service.Execute( new CommandEnvelope { CorrelationId = Guid.NewGuid().ToString("N") }, Ctx())); ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); } /// Minimal ; this RPC reads nothing off the context but its method name. private sealed class FakeServerCallContext : ServerCallContext { protected override string MethodCore => "/mesh_transport.v1.NodeCommandService/Execute"; protected override string HostCore => "localhost"; protected override string PeerCore => "ipv4:127.0.0.1:0"; protected override DateTime DeadlineCore => DateTime.MaxValue; protected override Metadata RequestHeadersCore => []; protected override CancellationToken CancellationTokenCore => CancellationToken.None; protected override Metadata ResponseTrailersCore { get; } = []; protected override Status StatusCore { get; set; } protected override WriteOptions? WriteOptionsCore { get; set; } protected override AuthContext AuthContextCore => new(null, new Dictionary>()); protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => throw new NotSupportedException(); protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; } }