feat(mesh): DTO mapper with round-trip goldens
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user