using Google.Protobuf; 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; using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1; using ZB.MOM.WW.OtOpcUa.Commons.Types; namespace ZB.MOM.WW.OtOpcUa.Commons.Tests; /// /// Round-trip goldens for — the mesh gRPC command /// plane's only data contract. Every golden goes domain → proto → serialized bytes → proto → /// domain, because the failure class this suite exists to catch is not a mis-assigned property but a /// member that survives an in-memory copy and vanishes on the wire: proto3 explicit presence is a /// serialization-level property, so a null-vs-"" or absent-vs-epoch mistake is invisible /// unless the bytes are actually round-tripped. /// /// /// Each record gets one all-members-non-default golden plus a null-variant golden for every /// nullable member, since a nullable member is exactly where a silent drop hides — the record still /// compares equal to a fresh instance built with the default, so nothing else in the system notices. /// public class MeshTransportDtoMapperTests { // Deliberately non-default in every position: distinct GUIDs (so a swapped assignment cannot pass), // a real 64-char lowercase SHA-256 hex, and strings that are neither empty nor equal to each other. private static readonly Guid DeploymentGuid = Guid.ParseExact("11111111111111111111111111111111", "N"); private static readonly Guid CorrelationGuid = Guid.ParseExact("22222222222222222222222222222222", "N"); private static readonly Guid EnvelopeGuid = Guid.ParseExact("33333333333333333333333333333333", "N"); private const string Sha256Hex = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; /// Serializes and re-parses so presence is proven on the wire. private static T Wire(T message) where T : IMessage, new() => new MessageParser(() => new T()).ParseFrom(message.ToByteArray()); // ---------------------------------------------------------------- DispatchDeployment [Fact] public void DispatchDeployment_RoundTrips_WithEveryMemberSet() { var domain = new DispatchDeployment( new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid)); MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain); } [Fact] public void DispatchDeployment_EncodesIdsInTheirHexNForm() { var proto = MeshTransportDtoMapper.ToProto(new DispatchDeployment( new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid))); proto.DeploymentId.ShouldBe(DeploymentGuid.ToString("N")); proto.RevisionHash.ShouldBe(Sha256Hex); proto.CorrelationId.ShouldBe(CorrelationGuid.ToString("N")); } // ---------------------------------------------------------------- RestartDriver / ReconnectDriver [Fact] public void RestartDriver_RoundTrips_WithEveryMemberSet() { var domain = new RestartDriver("cluster-alpha", "drv-modbus-7", "jdoe@zb.local", CorrelationGuid); MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain); } [Fact] public void ReconnectDriver_RoundTrips_WithEveryMemberSet() { var domain = new ReconnectDriver("cluster-beta", "drv-s7-3", "aoperator@zb.local", CorrelationGuid); MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain); } // ---------------------------------------------------------------- AlarmCommand [Fact] public void AlarmCommand_RoundTrips_WithEveryMemberSet() { var domain = new AlarmCommand( "alarm-42", "TimedShelve", "jdoe@zb.local", "shelving for maintenance", new DateTime(2026, 8, 6, 13, 45, 12, 345, DateTimeKind.Utc).AddTicks(6789)); MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain); } [Fact] public void AlarmCommand_RoundTrips_WithNullComment() { var domain = new AlarmCommand("alarm-42", "Acknowledge", "jdoe", null, null); var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))); back.ShouldBe(domain); back.Comment.ShouldBeNull(); back.UnshelveAtUtc.ShouldBeNull(); } [Fact] public void AlarmCommand_EmptyComment_StaysEmpty_AndIsNotNull() { // The trap: proto3 without explicit presence decodes an absent string as "", collapsing // null and "" onto one value. `optional string comment` keeps them distinct — an operator who // acknowledged with a deliberately blank comment must not be reported as having supplied none. var domain = new AlarmCommand("alarm-42", "AddComment", "jdoe", "", null); var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))); back.ShouldBe(domain); back.Comment.ShouldNotBeNull(); back.Comment.ShouldBeEmpty(); } [Fact] public void AlarmCommand_UnshelveAt_RoundTripsAsUtc() { // DateTime equality compares ticks and ignores Kind, so record equality alone would pass on a // Local-Kind result whose instant is hours off once anything re-interprets it. Assert Kind. var when = new DateTime(2026, 8, 6, 13, 45, 12, DateTimeKind.Utc).AddTicks(1234567); var domain = new AlarmCommand("alarm-42", "TimedShelve", "jdoe", null, when); var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))); back.UnshelveAtUtc.ShouldNotBeNull(); back.UnshelveAtUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc); back.UnshelveAtUtc.Value.Ticks.ShouldBe(when.Ticks); } [Fact] public void AlarmCommand_UnrecognizedOperation_RoundTripsVerbatim() { // Controller decision: `operation` stays a proto string precisely so an operation this build // does not know travels intact rather than decoding to an enum's UNSPECIFIED and being dropped. var domain = new AlarmCommand("alarm-42", "SomeFutureOperation", "jdoe", null, null); var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))); back.ShouldBe(domain); back.Operation.ShouldBe("SomeFutureOperation"); } // ---------------------------------------------------------------- ApplyAck [Theory] [InlineData(ApplyAckOutcome.Applied)] [InlineData(ApplyAckOutcome.Failed)] public void ApplyAck_RoundTrips_WithEveryMemberSet(ApplyAckOutcome outcome) { var domain = new ApplyAck( new DeploymentId(DeploymentGuid), new NodeId("site-a-1"), outcome, "artifact fetch failed: sha mismatch", new CorrelationId(CorrelationGuid)); MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain); } [Fact] public void ApplyAck_RoundTrips_WithNullFailureReason() { var domain = new ApplyAck( new DeploymentId(DeploymentGuid), new NodeId("site-a-1"), ApplyAckOutcome.Applied, null, new CorrelationId(CorrelationGuid)); var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))); back.ShouldBe(domain); back.FailureReason.ShouldBeNull(); } [Fact] public void ApplyAck_EmptyFailureReason_StaysEmpty_AndIsNotNull() { var domain = new ApplyAck( new DeploymentId(DeploymentGuid), new NodeId("site-a-1"), ApplyAckOutcome.Failed, "", new CorrelationId(CorrelationGuid)); var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))); back.ShouldBe(domain); back.FailureReason.ShouldNotBeNull(); back.FailureReason.ShouldBeEmpty(); } [Fact] public void ApplyAck_UnspecifiedOutcome_Throws_RatherThanDecodingToAMeaningfulValue() { // The single highest-stakes decode in this contract. proto3 hands back 0 for an absent enum // field, and the deploy path seals on Applied — so 0 must never resolve to Applied (or to // Failed, which would fail a deployment that actually succeeded). ApplyAckOutcome has no // Unknown member to land on, so refusal is the only honest answer. var proto = new ApplyAckMessage { DeploymentId = DeploymentGuid.ToString("N"), NodeId = "site-a-1", Outcome = ApplyOutcome.Unspecified, CorrelationId = CorrelationGuid.ToString("N"), }; var ex = Should.Throw(() => MeshTransportDtoMapper.FromProto(proto)); ex.Message.ShouldContain(nameof(ApplyOutcome.Unspecified)); } [Fact] public void ApplyAck_UnknownOutcomeValue_Throws() { // A peer built against a newer contract with a third outcome. Same rule: refuse, do not guess. var proto = new ApplyAckMessage { DeploymentId = DeploymentGuid.ToString("N"), NodeId = "site-a-1", Outcome = (ApplyOutcome)99, CorrelationId = CorrelationGuid.ToString("N"), }; Should.Throw(() => MeshTransportDtoMapper.FromProto(proto)) .Message.ShouldContain("99"); } [Fact] public void ApplyAck_UnknownClrOutcome_ThrowsOnEncode() { var domain = new ApplyAck( new DeploymentId(DeploymentGuid), new NodeId("site-a-1"), (ApplyAckOutcome)99, null, new CorrelationId(CorrelationGuid)); Should.Throw(() => MeshTransportDtoMapper.ToProto(domain)); } // ---------------------------------------------------------------- Envelope [Fact] public void Envelope_RoundTrips_DispatchDeployment() { var domain = new DispatchDeployment( new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid)); var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain)); envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.DispatchDeployment); MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain); } [Fact] public void Envelope_RoundTrips_RestartDriver() { var domain = new RestartDriver("cluster-alpha", "drv-modbus-7", "jdoe", CorrelationGuid); var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain)); envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.RestartDriver); MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain); } [Fact] public void Envelope_RoundTrips_ReconnectDriver() { var domain = new ReconnectDriver("cluster-beta", "drv-s7-3", "jdoe", CorrelationGuid); var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain)); envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.ReconnectDriver); MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain); } [Fact] public void Envelope_RoundTrips_AlarmCommand() { var domain = new AlarmCommand("alarm-42", "Acknowledge", "jdoe", "ack'd", null); var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain, new CorrelationId(EnvelopeGuid))); envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.AlarmCommand); envelope.CorrelationId.ShouldBe(EnvelopeGuid.ToString("N")); MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain); } [Fact] public void Envelope_SetsBothCorrelationIds_ForTheThreeSelfCorrelatingCommands() { // Encode writes envelope and inner correlation from the record's own member, so the two can // only diverge if a peer produced them apart — see the decode-precedence golden below. var dispatch = MeshTransportDtoMapper.ToEnvelope(new DispatchDeployment( new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid))); dispatch.CorrelationId.ShouldBe(CorrelationGuid.ToString("N")); dispatch.DispatchDeployment.CorrelationId.ShouldBe(CorrelationGuid.ToString("N")); var restart = MeshTransportDtoMapper.ToEnvelope( new RestartDriver("c", "d", "u", CorrelationGuid)); restart.CorrelationId.ShouldBe(CorrelationGuid.ToString("N")); restart.RestartDriver.CorrelationId.ShouldBe(CorrelationGuid.ToString("N")); var reconnect = MeshTransportDtoMapper.ToEnvelope( new ReconnectDriver("c", "d", "u", CorrelationGuid)); reconnect.CorrelationId.ShouldBe(CorrelationGuid.ToString("N")); reconnect.ReconnectDriver.CorrelationId.ShouldBe(CorrelationGuid.ToString("N")); } [Fact] public void Envelope_InnerCorrelationWins_OnDecode() { // Controller decision: the inner field is the domain member, the envelope's is transport // metadata. If a peer ever writes them apart, the record must carry what the record declared. var envelope = Wire(new CommandEnvelope { CorrelationId = EnvelopeGuid.ToString("N"), DispatchDeployment = new DispatchDeploymentCommand { DeploymentId = DeploymentGuid.ToString("N"), RevisionHash = Sha256Hex, CorrelationId = CorrelationGuid.ToString("N"), }, }); var decoded = MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBeOfType(); decoded.CorrelationId.Value.ShouldBe(CorrelationGuid); decoded.CorrelationId.Value.ShouldNotBe(EnvelopeGuid); } [Fact] public void Envelope_InnerCorrelationWins_OnDecode_ForDriverCommands() { var restart = Wire(new CommandEnvelope { CorrelationId = EnvelopeGuid.ToString("N"), RestartDriver = new RestartDriverCommand { ClusterId = "c", DriverInstanceId = "d", ActorByUserName = "u", CorrelationId = CorrelationGuid.ToString("N"), }, }); MeshTransportDtoMapper.FromEnvelope(restart).ShouldBeOfType() .CorrelationId.ShouldBe(CorrelationGuid); var reconnect = Wire(new CommandEnvelope { CorrelationId = EnvelopeGuid.ToString("N"), ReconnectDriver = new ReconnectDriverCommand { ClusterId = "c", DriverInstanceId = "d", ActorByUserName = "u", CorrelationId = CorrelationGuid.ToString("N"), }, }); MeshTransportDtoMapper.FromEnvelope(reconnect).ShouldBeOfType() .CorrelationId.ShouldBe(CorrelationGuid); } [Fact] public void FromEnvelope_UnsetOneof_Throws() { // Unreachable while MeshTransportProtoContractTests holds the oneof locked, which is exactly // why it must throw rather than return null: the alternative is a command that crosses the // mesh, decodes to nothing and is dropped without a trace. var ex = Should.Throw( () => MeshTransportDtoMapper.FromEnvelope(new CommandEnvelope { CorrelationId = "x" })); ex.Message.ShouldContain(nameof(CommandEnvelope.CommandOneofCase.None)); } [Fact] public void ToEnvelope_UnknownMessageType_Throws() { Should.Throw( () => MeshTransportDtoMapper.ToEnvelope("not a command", new CorrelationId(EnvelopeGuid))); } [Fact] public void EveryHandledOneofCase_DecodesToADomainRecord() { // Ties the mapper to the contract-lock list: a fifth oneof case added to the proto and to // HandledCases without a mapper arm fails here rather than at runtime on the far side. var samples = new Dictionary { [CommandEnvelope.CommandOneofCase.DispatchDeployment] = MeshTransportDtoMapper.ToEnvelope( new DispatchDeployment(new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid))), [CommandEnvelope.CommandOneofCase.RestartDriver] = MeshTransportDtoMapper.ToEnvelope( new RestartDriver("c", "d", "u", CorrelationGuid)), [CommandEnvelope.CommandOneofCase.ReconnectDriver] = MeshTransportDtoMapper.ToEnvelope( new ReconnectDriver("c", "d", "u", CorrelationGuid)), [CommandEnvelope.CommandOneofCase.AlarmCommand] = MeshTransportDtoMapper.ToEnvelope( new AlarmCommand("a", "Acknowledge", "u", null, null), new CorrelationId(EnvelopeGuid)), }; samples.Keys.ShouldBe(MeshTransportProtoContract.HandledCases, ignoreOrder: true); foreach (var (expectedCase, envelope) in samples) { envelope.CommandCase.ShouldBe(expectedCase); MeshTransportDtoMapper.FromEnvelope(Wire(envelope)).ShouldNotBeNull(); } } // ---------------------------------------------------------------- ToEnvelope(object …) dispatch [Fact] public void ToEnvelope_ObjectDispatch_MatchesTheTypedOverloads() { // The router holds its command as MeshCommand.Message (an object), so the untyped entry point // must produce byte-identical envelopes to the typed ones it delegates to. var alarmCorrelation = new CorrelationId(EnvelopeGuid); object[] commands = [ new DispatchDeployment(new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid)), new RestartDriver("c", "d", "u", CorrelationGuid), new ReconnectDriver("c", "d", "u", CorrelationGuid), new AlarmCommand("a", "Acknowledge", "u", "note", null), ]; CommandEnvelope[] typed = [ MeshTransportDtoMapper.ToEnvelope((DispatchDeployment)commands[0]), MeshTransportDtoMapper.ToEnvelope((RestartDriver)commands[1]), MeshTransportDtoMapper.ToEnvelope((ReconnectDriver)commands[2]), MeshTransportDtoMapper.ToEnvelope((AlarmCommand)commands[3], alarmCorrelation), ]; for (var i = 0; i < commands.Length; i++) { MeshTransportDtoMapper.ToEnvelope(commands[i], alarmCorrelation).ShouldBe(typed[i]); } } }