feat(mesh): DTO mapper with round-trip goldens

This commit is contained in:
Joseph Doherty
2026-08-06 03:19:11 -04:00
parent 09db9e47de
commit 545413a835
2 changed files with 842 additions and 0 deletions
@@ -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;
/// <summary>
/// The one translation between the five cross-mesh command records and their
/// <c>mesh_transport.v1</c> 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here guesses.</b> 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
/// <c>APPLY_OUTCOME_UNSPECIFIED</c> 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.
/// </para>
/// <para>
/// <b>Presence, not emptiness.</b> The two nullable members that cross this contract —
/// <see cref="AlarmCommand.Comment"/> and <see cref="ApplyAck.FailureReason"/> — are
/// <c>optional string</c> on the wire, so <c>null</c> maps to an absent field and <c>""</c> 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".
/// <see cref="AlarmCommand.UnshelveAtUtc"/> uses a message field, whose own absence carries the
/// null.
/// </para>
/// <para>
/// <b>Correlation is written twice and read once.</b> Encode stamps the envelope's
/// <c>correlation_id</c> 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
/// <see cref="AlarmCommand"/>'s correlation can live at all since the record has no member for it.
/// </para>
/// </remarks>
public static class MeshTransportDtoMapper
{
// ---------------------------------------------------------------- DispatchDeployment
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain dispatch command.</param>
/// <returns>The wire <see cref="DispatchDeploymentCommand"/>.</returns>
public static DispatchDeploymentCommand ToProto(DispatchDeployment message)
{
ArgumentNullException.ThrowIfNull(message);
return new DispatchDeploymentCommand
{
DeploymentId = message.DeploymentId.ToString(),
RevisionHash = message.RevisionHash.Value ?? "",
CorrelationId = message.CorrelationId.ToString(),
};
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire dispatch command.</param>
/// <returns>The domain <see cref="DispatchDeployment"/>.</returns>
/// <exception cref="FormatException">An id field is not a hyphenless GUID.</exception>
/// <exception cref="ArgumentException">The revision hash is absent or blank.</exception>
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
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain restart command.</param>
/// <returns>The wire <see cref="RestartDriverCommand"/>.</returns>
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"),
};
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire restart command.</param>
/// <returns>The domain <see cref="RestartDriver"/>.</returns>
/// <exception cref="FormatException">The correlation id is not a hyphenless GUID.</exception>
public static RestartDriver FromProto(RestartDriverCommand proto)
{
ArgumentNullException.ThrowIfNull(proto);
return new RestartDriver(
proto.ClusterId,
proto.DriverInstanceId,
proto.ActorByUserName,
Guid.ParseExact(proto.CorrelationId, "N"));
}
// ---------------------------------------------------------------- ReconnectDriver
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain reconnect command.</param>
/// <returns>The wire <see cref="ReconnectDriverCommand"/>.</returns>
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"),
};
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire reconnect command.</param>
/// <returns>The domain <see cref="ReconnectDriver"/>.</returns>
/// <exception cref="FormatException">The correlation id is not a hyphenless GUID.</exception>
public static ReconnectDriver FromProto(ReconnectDriverCommand proto)
{
ArgumentNullException.ThrowIfNull(proto);
return new ReconnectDriver(
proto.ClusterId,
proto.DriverInstanceId,
proto.ActorByUserName,
Guid.ParseExact(proto.CorrelationId, "N"));
}
// ---------------------------------------------------------------- AlarmCommand
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain alarm command.</param>
/// <returns>The wire <see cref="AlarmCommandMessage"/>.</returns>
/// <remarks>
/// <see cref="AlarmCommand.Operation"/> 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.
/// </remarks>
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;
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire alarm command.</param>
/// <returns>The domain <see cref="AlarmCommand"/>.</returns>
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
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain per-node apply acknowledgment.</param>
/// <returns>The wire <see cref="ApplyAckMessage"/>.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <see cref="ApplyAck.Outcome"/> is not a declared <see cref="ApplyAckOutcome"/>.
/// </exception>
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;
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire apply acknowledgment.</param>
/// <returns>The domain <see cref="ApplyAck"/>.</returns>
/// <exception cref="InvalidOperationException">
/// The outcome is <c>APPLY_OUTCOME_UNSPECIFIED</c> or a value this build does not know.
/// </exception>
/// <exception cref="FormatException">An id field is not a hyphenless GUID.</exception>
/// <exception cref="ArgumentException">The node id is absent or blank.</exception>
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
/// <summary>Wraps a dispatch command in its transport envelope.</summary>
/// <param name="message">The domain dispatch command.</param>
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
public static CommandEnvelope ToEnvelope(DispatchDeployment message)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = message.CorrelationId.ToString(),
DispatchDeployment = ToProto(message),
};
}
/// <summary>Wraps a restart command in its transport envelope.</summary>
/// <param name="message">The domain restart command.</param>
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
public static CommandEnvelope ToEnvelope(RestartDriver message)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = message.CorrelationId.ToString("N"),
RestartDriver = ToProto(message),
};
}
/// <summary>Wraps a reconnect command in its transport envelope.</summary>
/// <param name="message">The domain reconnect command.</param>
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
public static CommandEnvelope ToEnvelope(ReconnectDriver message)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = message.CorrelationId.ToString("N"),
ReconnectDriver = ToProto(message),
};
}
/// <summary>Wraps an alarm command in its transport envelope.</summary>
/// <param name="message">The domain alarm command.</param>
/// <param name="correlationId">
/// Transport correlation. Explicit because <see cref="AlarmCommand"/> is the one record with no
/// correlation member of its own, so the envelope is the only place it can be carried.
/// </param>
/// <returns>The envelope.</returns>
public static CommandEnvelope ToEnvelope(AlarmCommand message, CorrelationId correlationId)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = correlationId.ToString(),
AlarmCommand = ToProto(message),
};
}
/// <summary>
/// Untyped dispatch for the transport router, which holds its payload as
/// <see cref="MeshCommand.Message"/> (an <see cref="object"/>).
/// </summary>
/// <param name="message">One of the four central→node command records.</param>
/// <param name="alarmCorrelationId">
/// Consulted <b>only</b> when <paramref name="message"/> is an <see cref="AlarmCommand"/>. 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.
/// </param>
/// <returns>The envelope produced by the matching typed overload.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="message"/> is not one of the four handled command records — the transport
/// would otherwise carry a payload the far side cannot decode.
/// </exception>
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"),
};
}
/// <summary>Unwraps a transport envelope into the domain record it carries.</summary>
/// <param name="envelope">The received envelope.</param>
/// <returns>
/// One of <see cref="DispatchDeployment"/>, <see cref="RestartDriver"/>,
/// <see cref="ReconnectDriver"/> or <see cref="AlarmCommand"/>, boxed — the node-side handlers
/// match on the concrete type exactly as they do under the DPS transport.
/// </returns>
/// <exception cref="InvalidOperationException">
/// The oneof is unset or names a case this build does not handle. Kept unreachable by
/// <c>MeshTransportProtoContract</c> and its contract-lock test; it throws anyway because the
/// alternative is a command that crosses the mesh and disappears.
/// </exception>
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."),
};
}
/// <summary>
/// Converts a domain <see cref="DateTime"/> to a proto <see cref="Timestamp"/> without ever
/// throwing on Kind: Utc is used as-is, Local is converted, and Unspecified is assumed
/// already-UTC (the producer contract for every <c>…Utc</c> member here).
/// </summary>
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);
}
}
@@ -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;
/// <summary>
/// Round-trip goldens for <see cref="MeshTransportDtoMapper"/> — the ClusterClient→gRPC command
/// plane's only data contract. Every golden goes domain → proto → <b>serialized bytes</b> → 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-<c>""</c> or absent-vs-epoch mistake is invisible
/// unless the bytes are actually round-tripped.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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";
/// <summary>Serializes and re-parses <paramref name="message"/> so presence is proven on the wire.</summary>
private static T Wire<T>(T message)
where T : IMessage<T>, new()
=> new MessageParser<T>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ArgumentOutOfRangeException>(() => 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<DispatchDeployment>();
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<RestartDriver>()
.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<ReconnectDriver>()
.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<InvalidOperationException>(
() => MeshTransportDtoMapper.FromEnvelope(new CommandEnvelope { CorrelationId = "x" }));
ex.Message.ShouldContain(nameof(CommandEnvelope.CommandOneofCase.None));
}
[Fact]
public void ToEnvelope_UnknownMessageType_Throws()
{
Should.Throw<ArgumentOutOfRangeException>(
() => 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, CommandEnvelope>
{
[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]);
}
}
}