using Akka.Hosting;
using Akka.TestKit.Xunit2;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Grpc;
///
/// The central-side gRPC ingress for a node's deployment ack (mesh gRPC command plane
/// Phase 1B). It decodes the wire message and hands it to the registered
/// CentralCommunicationActor — the same actor the DistributedPubSub leg delivers to,
/// whose forward-to-the-coordinator path is untouched.
///
///
/// Both failure modes matter more than the happy path. An ack that cannot be decoded must be
/// refused rather than guessed at (a wrong outcome seals a deployment nobody applied), and an ack
/// arriving before the actor exists must say so the caller
/// retries instead of recording a permanent failure against a node that is fine.
///
public sealed class CentralCommandGrpcServiceTests : TestKit
{
private static ApplyAckMessage WellFormed() => new()
{
DeploymentId = Guid.NewGuid().ToString("N"),
NodeId = "site-a-1",
Outcome = ApplyOutcome.Applied,
CorrelationId = Guid.NewGuid().ToString("N"),
};
private static CentralCommandGrpcService Service(ActorRegistry registry) =>
new(registry, NullLogger.Instance);
[Fact]
public async Task A_well_formed_ack_is_decoded_and_told_to_the_registered_actor()
{
var probe = CreateTestProbe("central-comm");
var registry = new ActorRegistry();
registry.Register(probe.Ref);
var request = WellFormed();
request.FailureReason = "disk full";
request.Outcome = ApplyOutcome.Failed;
var response = await Service(registry).ReportApply(request, new FakeServerCallContext());
response.ShouldNotBeNull();
var received = probe.ExpectMsg(TimeSpan.FromSeconds(3));
received.DeploymentId.ShouldBe(DeploymentId.Parse(request.DeploymentId));
received.NodeId.Value.ShouldBe("site-a-1");
received.Outcome.ShouldBe(ApplyAckOutcome.Failed);
received.FailureReason.ShouldBe("disk full");
received.CorrelationId.ToString().ShouldBe(request.CorrelationId);
}
[Fact]
public async Task An_absent_failure_reason_arrives_as_null_and_an_empty_one_as_empty()
{
// Presence, not emptiness — the mapper's contract, asserted at the seam that actually feeds
// the coordinator. Collapsing the two would report an operator-visible blank reason as "no
// reason recorded".
var probe = CreateTestProbe("central-comm");
var registry = new ActorRegistry();
registry.Register(probe.Ref);
var service = Service(registry);
await service.ReportApply(WellFormed(), new FakeServerCallContext());
probe.ExpectMsg(TimeSpan.FromSeconds(3)).FailureReason.ShouldBeNull();
var blank = WellFormed();
blank.FailureReason = "";
await service.ReportApply(blank, new FakeServerCallContext());
probe.ExpectMsg(TimeSpan.FromSeconds(3)).FailureReason.ShouldBe("");
}
[Fact]
public async Task An_unregistered_actor_yields_Unavailable()
{
// The boot window: the listener answers before Akka.Hosting has spawned the actor. Unavailable
// is the retryable status, so the node's next attempt lands instead of the deployment
// recording a permanent failure against a node that applied fine.
var ex = await Should.ThrowAsync(() =>
Service(new ActorRegistry()).ReportApply(WellFormed(), new FakeServerCallContext()));
ex.StatusCode.ShouldBe(StatusCode.Unavailable);
}
[Fact]
public async Task An_unspecified_outcome_yields_InvalidArgument()
{
// proto3 decodes an absent enum as 0. Refusing is the whole point: the deploy path seals on
// Applied, so guessing an outcome the node never sent would seal a deployment nobody applied.
var probe = CreateTestProbe("central-comm");
var registry = new ActorRegistry();
registry.Register(probe.Ref);
var request = WellFormed();
request.Outcome = ApplyOutcome.Unspecified;
var ex = await Should.ThrowAsync(() =>
Service(registry).ReportApply(request, new FakeServerCallContext()));
ex.StatusCode.ShouldBe(StatusCode.InvalidArgument);
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public async Task A_malformed_id_yields_InvalidArgument()
{
var request = WellFormed();
request.DeploymentId = "not-a-guid";
var probe = CreateTestProbe("central-comm");
var registry = new ActorRegistry();
registry.Register(probe.Ref);
var ex = await Should.ThrowAsync(() =>
Service(registry).ReportApply(request, new FakeServerCallContext()));
ex.StatusCode.ShouldBe(StatusCode.InvalidArgument);
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public async Task A_blank_node_id_yields_InvalidArgument()
{
// NodeId.Parse refuses a blank name; an ack whose node cannot be named is uncountable at the
// coordinator, so it must be refused here rather than forwarded as an anonymous ack.
var request = WellFormed();
request.NodeId = "";
var probe = CreateTestProbe("central-comm");
var registry = new ActorRegistry();
registry.Register(probe.Ref);
var ex = await Should.ThrowAsync(() =>
Service(registry).ReportApply(request, new FakeServerCallContext()));
ex.StatusCode.ShouldBe(StatusCode.InvalidArgument);
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public async Task An_undecodable_message_is_graded_before_the_registry()
{
// Ordering pin. With NO actor registered AND an undecodable message, the answer is
// InvalidArgument, not Unavailable: Unavailable invites a retry, and retrying a message that
// can never decode is a loop. The caller's fault is reported as the caller's fault.
var request = WellFormed();
request.Outcome = ApplyOutcome.Unspecified;
var ex = await Should.ThrowAsync(() =>
Service(new ActorRegistry()).ReportApply(request, new FakeServerCallContext()));
ex.StatusCode.ShouldBe(StatusCode.InvalidArgument);
}
[Fact]
public async Task A_decode_failure_logs_the_raw_correlation_id()
{
// The correlation id is logged as the raw wire string precisely because the ids may be what
// failed to parse — an operator correlating a rejection cannot be given a value that only
// exists once decoding succeeded.
var logger = new CapturingLogger();
var request = WellFormed();
request.CorrelationId = "correlation-that-never-parses";
request.DeploymentId = "not-a-guid";
await Should.ThrowAsync(() =>
new CentralCommandGrpcService(new ActorRegistry(), logger)
.ReportApply(request, new FakeServerCallContext()));
logger.Entries.Any(e => e.Message.Contains("correlation-that-never-parses", StringComparison.Ordinal))
.ShouldBeTrue("the rejection log must carry the raw correlation id");
}
[Fact]
public async Task A_not_ready_rejection_logs_the_peer_and_the_correlation_id()
{
// Diagnostic symmetry with the decode-failure warning above. The two rejections are the only
// two ways an ack fails to reach the coordinator, and an operator reading the central log has
// to correlate BOTH against the node's own log — which needs the correlation id — and against
// which node was calling, which needs the peer. Decoding has already succeeded on this path,
// so the decoded correlation id costs nothing to include.
var logger = new CapturingLogger();
var request = WellFormed();
await Should.ThrowAsync(() =>
new CentralCommandGrpcService(new ActorRegistry(), logger)
.ReportApply(request, new FakeServerCallContext()));
var warning = logger.Entries.Single(e => e.Level == LogLevel.Warning);
warning.Message.ShouldContain("ipv4:127.0.0.1:0");
warning.Message.ShouldContain(request.CorrelationId);
}
/// Test logger that captures all log entries.
/// The category type.
private sealed class CapturingLogger : ILogger
{
/// Gets the captured log entries.
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = [];
/// Begins a scope (stub implementation).
/// The type of the scope state.
/// The scope state.
/// A no-op scope.
public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance;
/// Checks if logging is enabled (always true).
/// The log level to check.
/// true.
public bool IsEnabled(LogLevel logLevel) => true;
/// Logs an entry and captures it.
/// The type of the log state.
/// The log level.
/// The event ID.
/// The log state.
/// The exception, if any.
/// The log message formatter.
public void Log(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func formatter)
{
Entries.Add((logLevel, formatter(state, exception), exception));
}
private sealed class NullScope : IDisposable
{
/// Gets the singleton instance.
public static NullScope Instance { get; } = new();
/// No-op.
public void Dispose() { }
}
}
/// Minimal — the service reads only the method path.
private sealed class FakeServerCallContext : ServerCallContext
{
protected override string MethodCore => "/mesh_transport.v1.CentralCommandService/ReportApply";
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;
}
}