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;
}
}