From 545413a83534e60bb7a10128063dfb9a3432d148 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 6 Aug 2026 03:19:11 -0400 Subject: [PATCH] feat(mesh): DTO mapper with round-trip goldens --- .../Messages/Mesh/MeshTransportDtoMapper.cs | 391 +++++++++++++++ .../MeshTransportDtoMapperTests.cs | 451 ++++++++++++++++++ 2 files changed, 842 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshTransportDtoMapper.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportDtoMapperTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshTransportDtoMapper.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshTransportDtoMapper.cs new file mode 100644 index 00000000..1b83ef7d --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Mesh/MeshTransportDtoMapper.cs @@ -0,0 +1,391 @@ +using Google.Protobuf.WellKnownTypes; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1; +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh; + +/// +/// The one translation between the five cross-mesh command records and their +/// mesh_transport.v1 wire shapes (ClusterClient→gRPC migration Phase 0). Both directions live +/// here so a field can only be added to one side without the other by making this file +/// visibly asymmetric. +/// +/// +/// +/// Nothing here guesses. The transport it replaces — an Akka ClusterClient receptionist — +/// dropped an undeliverable command silently, and the entire point of the migration is to stop +/// doing that. So every decode that cannot produce a faithful record throws: an unset oneof, an +/// APPLY_OUTCOME_UNSPECIFIED ack, an enum value from a newer peer. A loud failure on one +/// command is recoverable; a deployment reported applied because an absent enum field decoded to +/// 0 is not. +/// +/// +/// Presence, not emptiness. The two nullable members that cross this contract — +/// and — are +/// optional string on the wire, so null maps to an absent field and "" to a +/// present-but-empty one. Collapsing them would report an operator's deliberately blank comment +/// as "no comment supplied", and a blank failure reason as "no reason recorded". +/// uses a message field, whose own absence carries the +/// null. +/// +/// +/// Correlation is written twice and read once. Encode stamps the envelope's +/// correlation_id and the command's own from the same source. Decode takes the command's +/// own for the three records that declare one — that member is domain state the record must +/// carry back verbatim — and leaves the envelope's to the transport, which is the only place +/// 's correlation can live at all since the record has no member for it. +/// +/// +public static class MeshTransportDtoMapper +{ + // ---------------------------------------------------------------- DispatchDeployment + + /// Projects onto its wire form. + /// The domain dispatch command. + /// The wire . + public static DispatchDeploymentCommand ToProto(DispatchDeployment message) + { + ArgumentNullException.ThrowIfNull(message); + + return new DispatchDeploymentCommand + { + DeploymentId = message.DeploymentId.ToString(), + RevisionHash = message.RevisionHash.Value ?? "", + CorrelationId = message.CorrelationId.ToString(), + }; + } + + /// Rebuilds the domain record from its wire form. + /// The wire dispatch command. + /// The domain . + /// An id field is not a hyphenless GUID. + /// The revision hash is absent or blank. + public static DispatchDeployment FromProto(DispatchDeploymentCommand proto) + { + ArgumentNullException.ThrowIfNull(proto); + + return new DispatchDeployment( + DeploymentId.Parse(proto.DeploymentId), + RevisionHash.Parse(proto.RevisionHash), + CorrelationId.Parse(proto.CorrelationId)); + } + + // ---------------------------------------------------------------- RestartDriver + + /// Projects onto its wire form. + /// The domain restart command. + /// The wire . + public static RestartDriverCommand ToProto(RestartDriver message) + { + ArgumentNullException.ThrowIfNull(message); + + // Required (non-optional) proto string setters throw on null and the record's nullable-ref + // annotations are compile-time only, so a null reaching here is already a producer bug. Coalesce + // rather than throw out of the mapper: a command with a blank field is diagnosable downstream, + // whereas a throw here is the silent drop this transport exists to remove. + return new RestartDriverCommand + { + ClusterId = message.ClusterId ?? "", + DriverInstanceId = message.DriverInstanceId ?? "", + ActorByUserName = message.ActorByUserName ?? "", + CorrelationId = message.CorrelationId.ToString("N"), + }; + } + + /// Rebuilds the domain record from its wire form. + /// The wire restart command. + /// The domain . + /// The correlation id is not a hyphenless GUID. + public static RestartDriver FromProto(RestartDriverCommand proto) + { + ArgumentNullException.ThrowIfNull(proto); + + return new RestartDriver( + proto.ClusterId, + proto.DriverInstanceId, + proto.ActorByUserName, + Guid.ParseExact(proto.CorrelationId, "N")); + } + + // ---------------------------------------------------------------- ReconnectDriver + + /// Projects onto its wire form. + /// The domain reconnect command. + /// The wire . + public static ReconnectDriverCommand ToProto(ReconnectDriver message) + { + ArgumentNullException.ThrowIfNull(message); + + return new ReconnectDriverCommand + { + ClusterId = message.ClusterId ?? "", + DriverInstanceId = message.DriverInstanceId ?? "", + ActorByUserName = message.ActorByUserName ?? "", + CorrelationId = message.CorrelationId.ToString("N"), + }; + } + + /// Rebuilds the domain record from its wire form. + /// The wire reconnect command. + /// The domain . + /// The correlation id is not a hyphenless GUID. + public static ReconnectDriver FromProto(ReconnectDriverCommand proto) + { + ArgumentNullException.ThrowIfNull(proto); + + return new ReconnectDriver( + proto.ClusterId, + proto.DriverInstanceId, + proto.ActorByUserName, + Guid.ParseExact(proto.CorrelationId, "N")); + } + + // ---------------------------------------------------------------- AlarmCommand + + /// Projects onto its wire form. + /// The domain alarm command. + /// The wire . + /// + /// is copied verbatim. It is an unconstrained string in the + /// record and stays one on the wire, so an operation this build does not recognise reaches the + /// far side intact instead of collapsing onto an enum's UNSPECIFIED and being dropped. + /// + public static AlarmCommandMessage ToProto(AlarmCommand message) + { + ArgumentNullException.ThrowIfNull(message); + + var proto = new AlarmCommandMessage + { + AlarmId = message.AlarmId ?? "", + Operation = message.Operation ?? "", + User = message.User ?? "", + }; + + if (message.Comment is not null) + proto.Comment = message.Comment; + if (message.UnshelveAtUtc is { } unshelveAt) + proto.UnshelveAtUtc = ToUtcTimestamp(unshelveAt); + + return proto; + } + + /// Rebuilds the domain record from its wire form. + /// The wire alarm command. + /// The domain . + public static AlarmCommand FromProto(AlarmCommandMessage proto) + { + ArgumentNullException.ThrowIfNull(proto); + + return new AlarmCommand( + proto.AlarmId, + proto.Operation, + proto.User, + proto.HasComment ? proto.Comment : null, + proto.UnshelveAtUtc?.ToDateTime()); + } + + // ---------------------------------------------------------------- ApplyAck + + /// Projects onto its wire form. + /// The domain per-node apply acknowledgment. + /// The wire . + /// + /// is not a declared . + /// + public static ApplyAckMessage ToProto(ApplyAck message) + { + ArgumentNullException.ThrowIfNull(message); + + var proto = new ApplyAckMessage + { + DeploymentId = message.DeploymentId.ToString(), + NodeId = message.NodeId.Value ?? "", + Outcome = message.Outcome switch + { + ApplyAckOutcome.Applied => ApplyOutcome.Applied, + ApplyAckOutcome.Failed => ApplyOutcome.Failed, + _ => throw new ArgumentOutOfRangeException( + nameof(message), message.Outcome, "Unknown ApplyAckOutcome"), + }, + CorrelationId = message.CorrelationId.ToString(), + }; + + if (message.FailureReason is not null) + proto.FailureReason = message.FailureReason; + + return proto; + } + + /// Rebuilds the domain record from its wire form. + /// The wire apply acknowledgment. + /// The domain . + /// + /// The outcome is APPLY_OUTCOME_UNSPECIFIED or a value this build does not know. + /// + /// An id field is not a hyphenless GUID. + /// The node id is absent or blank. + public static ApplyAck FromProto(ApplyAckMessage proto) + { + ArgumentNullException.ThrowIfNull(proto); + + // proto3 decodes an absent enum field as 0, and the deploy path seals a revision on Applied. + // ApplyAckOutcome has no Unknown member to land on, so an unspecified or unrecognised outcome + // must refuse rather than pick one of the two real answers — either choice would be a lie about + // what the node said, and the "Applied" lie seals a deployment nobody applied. + var outcome = proto.Outcome switch + { + ApplyOutcome.Applied => ApplyAckOutcome.Applied, + ApplyOutcome.Failed => ApplyAckOutcome.Failed, + _ => throw new InvalidOperationException( + $"ApplyAckMessage carries outcome '{proto.Outcome}', which has no ApplyAckOutcome " + + "equivalent. Refusing to decode rather than report an unsaid deployment result."), + }; + + return new ApplyAck( + DeploymentId.Parse(proto.DeploymentId), + NodeId.Parse(proto.NodeId), + outcome, + proto.HasFailureReason ? proto.FailureReason : null, + CorrelationId.Parse(proto.CorrelationId)); + } + + // ---------------------------------------------------------------- Envelope + + /// Wraps a dispatch command in its transport envelope. + /// The domain dispatch command. + /// The envelope, correlated from the record's own CorrelationId. + public static CommandEnvelope ToEnvelope(DispatchDeployment message) + { + ArgumentNullException.ThrowIfNull(message); + + return new CommandEnvelope + { + CorrelationId = message.CorrelationId.ToString(), + DispatchDeployment = ToProto(message), + }; + } + + /// Wraps a restart command in its transport envelope. + /// The domain restart command. + /// The envelope, correlated from the record's own CorrelationId. + public static CommandEnvelope ToEnvelope(RestartDriver message) + { + ArgumentNullException.ThrowIfNull(message); + + return new CommandEnvelope + { + CorrelationId = message.CorrelationId.ToString("N"), + RestartDriver = ToProto(message), + }; + } + + /// Wraps a reconnect command in its transport envelope. + /// The domain reconnect command. + /// The envelope, correlated from the record's own CorrelationId. + public static CommandEnvelope ToEnvelope(ReconnectDriver message) + { + ArgumentNullException.ThrowIfNull(message); + + return new CommandEnvelope + { + CorrelationId = message.CorrelationId.ToString("N"), + ReconnectDriver = ToProto(message), + }; + } + + /// Wraps an alarm command in its transport envelope. + /// The domain alarm command. + /// + /// Transport correlation. Explicit because is the one record with no + /// correlation member of its own, so the envelope is the only place it can be carried. + /// + /// The envelope. + public static CommandEnvelope ToEnvelope(AlarmCommand message, CorrelationId correlationId) + { + ArgumentNullException.ThrowIfNull(message); + + return new CommandEnvelope + { + CorrelationId = correlationId.ToString(), + AlarmCommand = ToProto(message), + }; + } + + /// + /// Untyped dispatch for the transport router, which holds its payload as + /// (an ). + /// + /// One of the four central→node command records. + /// + /// Consulted only when is an . The + /// other three carry their own correlation and it is authoritative for them, so passing a + /// different value here cannot make the envelope disagree with the command inside it. + /// + /// The envelope produced by the matching typed overload. + /// + /// is not one of the four handled command records — the transport + /// would otherwise carry a payload the far side cannot decode. + /// + public static CommandEnvelope ToEnvelope(object message, CorrelationId alarmCorrelationId) + { + ArgumentNullException.ThrowIfNull(message); + + return message switch + { + DispatchDeployment m => ToEnvelope(m), + RestartDriver m => ToEnvelope(m), + ReconnectDriver m => ToEnvelope(m), + AlarmCommand m => ToEnvelope(m, alarmCorrelationId), + _ => throw new ArgumentOutOfRangeException( + nameof(message), message.GetType().FullName, "Not a cross-mesh command record"), + }; + } + + /// Unwraps a transport envelope into the domain record it carries. + /// The received envelope. + /// + /// One of , , + /// or , boxed — the node-side handlers + /// match on the concrete type exactly as they do under the DPS transport. + /// + /// + /// The oneof is unset or names a case this build does not handle. Kept unreachable by + /// MeshTransportProtoContract and its contract-lock test; it throws anyway because the + /// alternative is a command that crosses the mesh and disappears. + /// + public static object FromEnvelope(CommandEnvelope envelope) + { + ArgumentNullException.ThrowIfNull(envelope); + + return envelope.CommandCase switch + { + CommandEnvelope.CommandOneofCase.DispatchDeployment => FromProto(envelope.DispatchDeployment), + CommandEnvelope.CommandOneofCase.RestartDriver => FromProto(envelope.RestartDriver), + CommandEnvelope.CommandOneofCase.ReconnectDriver => FromProto(envelope.ReconnectDriver), + CommandEnvelope.CommandOneofCase.AlarmCommand => FromProto(envelope.AlarmCommand), + _ => throw new InvalidOperationException( + $"CommandEnvelope (correlation '{envelope.CorrelationId}') carries oneof case " + + $"'{envelope.CommandCase}', which this build does not handle."), + }; + } + + /// + /// Converts a domain to a proto without ever + /// throwing on Kind: Utc is used as-is, Local is converted, and Unspecified is assumed + /// already-UTC (the producer contract for every …Utc member here). + /// + private static Timestamp ToUtcTimestamp(DateTime dt) + { + var utc = dt.Kind switch + { + DateTimeKind.Utc => dt, + DateTimeKind.Local => dt.ToUniversalTime(), + _ => DateTime.SpecifyKind(dt, DateTimeKind.Utc), + }; + + return Timestamp.FromDateTime(utc); + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportDtoMapperTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportDtoMapperTests.cs new file mode 100644 index 00000000..0177a85a --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportDtoMapperTests.cs @@ -0,0 +1,451 @@ +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 ClusterClient→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]); + } + } +}