Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportDtoMapperTests.cs
T
Joseph Doherty 521e1eb89b feat(deploy): additive degraded flag on ApplyAck + publish-actor apply outcome reply (#601)
ApplyAck gains trailing Degraded/DegradedReason (defaulted, so an old node's
DPS JSON or gRPC ack decodes exactly as before). mesh_transport.proto adds
ApplyAckMessage.degraded = 6 / optional degraded_reason = 7. OpcUaPublishActor
replies RebuildCompleted(Degraded, Reason) on every handler path when the
RebuildAddressSpace carries ReportOutcome.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:19:57 -04:00

507 lines
21 KiB
C#

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 mesh 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_Degraded_RoundTrips_WithItsReason()
{
// #601: an Applied ack whose address-space apply was DEGRADED must reach central as degraded —
// a node that loses the flag on the wire reports a clean apply it did not have.
var domain = new ApplyAck(
new DeploymentId(DeploymentGuid),
new NodeId("site-a-1"),
ApplyAckOutcome.Applied,
null,
new CorrelationId(CorrelationGuid),
Degraded: true,
DegradedReason: "failedNodes=3");
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
back.ShouldBe(domain);
back.Degraded.ShouldBeTrue();
back.DegradedReason.ShouldBe("failedNodes=3");
}
[Fact]
public void ApplyAck_Clean_EncodesNoDegradedFields()
{
var proto = MeshTransportDtoMapper.ToProto(new ApplyAck(
new DeploymentId(DeploymentGuid), new NodeId("site-a-1"), ApplyAckOutcome.Applied, null,
new CorrelationId(CorrelationGuid)));
proto.Degraded.ShouldBeFalse();
proto.HasDegradedReason.ShouldBeFalse();
}
[Fact]
public void ApplyAck_FromAnOldNode_WithoutTheDegradedFields_DecodesAsTodaysCleanAck()
{
// #601 compatibility: a node built before the degraded fields existed sends exactly fields 1-5.
// Its ack must decode exactly as it always did — not degraded, no reason.
var oldNodeBytes = new ApplyAckMessage
{
DeploymentId = DeploymentGuid.ToString("N"),
NodeId = "site-a-1",
Outcome = ApplyOutcome.Applied,
CorrelationId = CorrelationGuid.ToString("N"),
}.ToByteArray();
var back = MeshTransportDtoMapper.FromProto(ApplyAckMessage.Parser.ParseFrom(oldNodeBytes));
back.Outcome.ShouldBe(ApplyAckOutcome.Applied);
back.Degraded.ShouldBeFalse();
back.DegradedReason.ShouldBeNull();
back.ShouldBe(new ApplyAck(
new DeploymentId(DeploymentGuid), new NodeId("site-a-1"), ApplyAckOutcome.Applied, null,
new CorrelationId(CorrelationGuid)));
}
[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]);
}
}
}