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