test(communication): pin wire round-trip + type identity of cross-cluster message contracts

This commit is contained in:
Joseph Doherty
2026-07-08 21:00:02 -04:00
parent 4bdd5f0e4a
commit 7626d5ba34
2 changed files with 273 additions and 0 deletions
@@ -0,0 +1,191 @@
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Characterization pins for the cross-cluster wire contracts. The Akka default
/// reflective-JSON wire format couples compatibility to <b>type and namespace
/// identity</b> plus round-trip fidelity, and nothing else tested that
/// (<c>MessageContractTests</c> asserts correlation-ids only). These tests pin
/// (a) round-trip fidelity and (b) the fully-qualified type identity of the
/// highest-traffic central↔site contracts, so a rename/move that would break a
/// rolling central↔site upgrade fails a test here instead of in production.
///
/// The serializer swap (proto / explicit bindings) is deliberately deferred — it
/// changes the wire format and needs a coordinated rolling-upgrade plan; until
/// then these pins are the guardrail.
/// </summary>
public class WireSerializationPinTests : TestKit
{
/// <summary>
/// Serializes with the same serializer Akka would pick on the wire, then
/// deserializes back through the type manifest — exactly the path a peer node
/// takes when it receives the bytes.
/// </summary>
private T RoundTrip<T>(T message)
{
var serialization = Sys.Serialization;
var serializer = serialization.FindSerializerFor(message);
var bytes = serializer.ToBinary(message);
return (T)serialization.Deserialize(bytes, serializer.Identifier, message!.GetType());
}
[Fact]
public void HeartbeatMessage_RoundTripsOnTheWire()
{
var original = new HeartbeatMessage("site-1", "host-a", true, DateTimeOffset.UtcNow);
var back = RoundTrip(original);
Assert.Equal(original, back); // all-scalar record → structural equality holds
}
[Fact]
public void NotificationSubmit_RoundTripsOnTheWire()
{
var original = new NotificationSubmit(
NotificationId: Guid.NewGuid().ToString("N"),
ListName: "Operators",
Subject: "Pump alarm",
Body: "Pump 3 tripped",
SourceSiteId: "site-1",
SourceInstanceId: "Plant.Pump3",
SourceScript: "ScriptActor:MonitorSpeed",
SiteEnqueuedAt: DateTimeOffset.UtcNow,
OriginExecutionId: Guid.NewGuid(),
OriginParentExecutionId: Guid.NewGuid(),
SourceNode: "node-a");
var back = RoundTrip(original);
// All-scalar record; assert every field so a dropped ctor arg on either
// side (a wire-fidelity bug) is caught field-by-field, not just by ==.
Assert.Equal(original.NotificationId, back.NotificationId);
Assert.Equal(original.ListName, back.ListName);
Assert.Equal(original.Subject, back.Subject);
Assert.Equal(original.Body, back.Body);
Assert.Equal(original.SourceSiteId, back.SourceSiteId);
Assert.Equal(original.SourceInstanceId, back.SourceInstanceId);
Assert.Equal(original.SourceScript, back.SourceScript);
Assert.Equal(original.SiteEnqueuedAt, back.SiteEnqueuedAt);
Assert.Equal(original.OriginExecutionId, back.OriginExecutionId);
Assert.Equal(original.OriginParentExecutionId, back.OriginParentExecutionId);
Assert.Equal(original.SourceNode, back.SourceNode);
}
[Fact]
public void IngestAuditEventsCommand_RoundTripsOnTheWire()
{
var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc);
var evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCallCached,
status: AuditStatus.Forwarded,
eventId: Guid.NewGuid(),
occurredAtUtc: occurredAt,
actor: "design-key",
target: "weather-api",
sourceNode: "node-a",
correlationId: Guid.NewGuid(),
executionId: Guid.NewGuid(),
parentExecutionId: Guid.NewGuid(),
sourceSiteId: "site-1",
sourceInstanceId: "Pump01",
sourceScript: "OnDemand",
httpStatus: 200,
durationMs: 42,
errorMessage: "transient timeout",
errorDetail: "stack-trace",
requestSummary: "GET /weather",
responseSummary: "{ \"ok\": true }",
payloadTruncated: true,
extra: "{ \"retryCount\": 1 }");
var original = new IngestAuditEventsCommand(new[] { evt });
var back = RoundTrip(original);
Assert.Single(back.Events);
var o = original.Events[0].AsRow();
var rt = back.Events[0].AsRow();
Assert.Equal(o.EventId, rt.EventId);
Assert.Equal(o.OccurredAtUtc, rt.OccurredAtUtc);
Assert.Equal(o.Channel, rt.Channel);
Assert.Equal(o.Kind, rt.Kind);
Assert.Equal(o.Status, rt.Status);
Assert.Equal(o.CorrelationId, rt.CorrelationId);
Assert.Equal(o.ExecutionId, rt.ExecutionId);
Assert.Equal(o.ParentExecutionId, rt.ParentExecutionId);
Assert.Equal(o.SourceSiteId, rt.SourceSiteId);
Assert.Equal(o.SourceNode, rt.SourceNode);
Assert.Equal(o.SourceInstanceId, rt.SourceInstanceId);
Assert.Equal(o.SourceScript, rt.SourceScript);
Assert.Equal(o.Actor, rt.Actor);
Assert.Equal(o.Target, rt.Target);
Assert.Equal(o.HttpStatus, rt.HttpStatus);
Assert.Equal(o.DurationMs, rt.DurationMs);
Assert.Equal(o.ErrorMessage, rt.ErrorMessage);
}
[Fact]
public void SiteHealthReport_RoundTripsOnTheWire()
{
var original = new SiteHealthReport(
SiteId: "site-1",
SequenceNumber: 42,
ReportTimestamp: DateTimeOffset.UtcNow,
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>
{
["opc-1"] = ConnectionHealth.Connected,
},
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>
{
["opc-1"] = new TagResolutionStatus(10, 9),
},
ScriptErrorCount: 1,
AlarmEvaluationErrorCount: 2,
StoreAndForwardBufferDepths: new Dictionary<string, int>
{
["Notification"] = 3,
},
DeadLetterCount: 4,
DeployedInstanceCount: 5,
EnabledInstanceCount: 6,
DisabledInstanceCount: 7,
NodeRole: "Active",
NodeHostname: "host-a",
ParkedMessageCount: 8);
var back = RoundTrip(original);
// Scalars + one entry per primary collection (record equality does NOT
// hold — dictionaries compare by reference — so assert field/entry-wise).
Assert.Equal(original.SiteId, back.SiteId);
Assert.Equal(original.SequenceNumber, back.SequenceNumber);
Assert.Equal(original.ReportTimestamp, back.ReportTimestamp);
Assert.Equal(ConnectionHealth.Connected, back.DataConnectionStatuses["opc-1"]);
Assert.Equal(new TagResolutionStatus(10, 9), back.TagResolutionCounts["opc-1"]);
Assert.Equal(3, back.StoreAndForwardBufferDepths["Notification"]);
Assert.Equal(original.DeadLetterCount, back.DeadLetterCount);
Assert.Equal(original.DeployedInstanceCount, back.DeployedInstanceCount);
Assert.Equal(original.NodeRole, back.NodeRole);
Assert.Equal(original.NodeHostname, back.NodeHostname);
Assert.Equal(original.ParkedMessageCount, back.ParkedMessageCount);
}
// Type-identity pins: the reflective-JSON wire embeds CLR type manifests, so a
// rename/move of any of these types silently breaks rolling cross-version
// central↔site communication. If one of these assertions fails, you are making
// a wire-breaking change — coordinate a full-fleet upgrade or add an explicit
// serialization binding before renaming/moving the type.
[Theory]
[InlineData(typeof(HeartbeatMessage), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.HeartbeatMessage")]
[InlineData(typeof(NotificationSubmit), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification.NotificationSubmit")]
[InlineData(typeof(IngestAuditEventsCommand), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit.IngestAuditEventsCommand")]
[InlineData(typeof(SiteHealthReport), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport")]
public void CrossClusterContract_TypeIdentity_IsPinned(Type type, string expectedFullName) =>
Assert.Equal(expectedFullName, type.FullName);
}
@@ -0,0 +1,82 @@
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
/// <summary>
/// Characterization pin for the intra-cluster S&amp;F replication contract. A
/// <see cref="ReplicationOperation"/> (carrying a full <see cref="StoreAndForwardMessage"/>)
/// is Told from the active node to the standby node's replication actor and rides
/// the Akka default reflective-JSON wire format. Nothing tested that it round-trips
/// or that its type identity is stable — a rename/move, a dropped setter, or a
/// non-default-constructible message would silently break standby buffer sync and
/// only surface as a divergent buffer after a failover.
///
/// The serializer swap (proto / explicit bindings) is deferred; until then this pin
/// is the guardrail.
/// </summary>
public class ReplicationWireSerializationPinTests : TestKit
{
private T RoundTrip<T>(T message)
{
var serialization = Sys.Serialization;
var serializer = serialization.FindSerializerFor(message);
var bytes = serializer.ToBinary(message);
return (T)serialization.Deserialize(bytes, serializer.Identifier, message!.GetType());
}
[Fact]
public void ReplicationOperation_WithFullMessage_RoundTripsOnTheWire()
{
var message = new StoreAndForwardMessage
{
Id = Guid.NewGuid().ToString("N"),
Category = StoreAndForwardCategory.Notification,
Target = "Operators",
PayloadJson = "{\"notificationId\":\"abc\"}",
RetryCount = 4,
MaxRetries = 0,
RetryIntervalMs = 30000,
CreatedAt = DateTimeOffset.UtcNow,
LastAttemptAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Parked,
LastError = "central rejected",
OriginInstanceName = "Plant.Pump3",
ExecutionId = Guid.NewGuid(),
SourceScript = "ScriptActor:MonitorSpeed",
ParentExecutionId = Guid.NewGuid(),
};
var original = new ReplicationOperation(ReplicationOperationType.Park, message.Id, message);
var back = RoundTrip(original);
Assert.Equal(original.OperationType, back.OperationType);
Assert.Equal(original.MessageId, back.MessageId);
Assert.NotNull(back.Message);
var m = back.Message!;
Assert.Equal(message.Id, m.Id);
Assert.Equal(message.Category, m.Category);
Assert.Equal(message.Target, m.Target);
Assert.Equal(message.PayloadJson, m.PayloadJson);
Assert.Equal(message.RetryCount, m.RetryCount);
Assert.Equal(message.MaxRetries, m.MaxRetries);
Assert.Equal(message.RetryIntervalMs, m.RetryIntervalMs);
Assert.Equal(message.CreatedAt, m.CreatedAt);
Assert.Equal(message.LastAttemptAt, m.LastAttemptAt);
Assert.Equal(message.Status, m.Status);
Assert.Equal(message.LastError, m.LastError);
Assert.Equal(message.OriginInstanceName, m.OriginInstanceName);
Assert.Equal(message.ExecutionId, m.ExecutionId);
Assert.Equal(message.SourceScript, m.SourceScript);
Assert.Equal(message.ParentExecutionId, m.ParentExecutionId);
}
// Type-identity pins: the reflective-JSON wire embeds CLR type manifests, so a
// rename/move of either type silently breaks standby replication across a
// rolling upgrade. If one fails, you are making a wire-breaking change.
[Theory]
[InlineData(typeof(ReplicationOperation), "ZB.MOM.WW.ScadaBridge.StoreAndForward.ReplicationOperation")]
[InlineData(typeof(StoreAndForwardMessage), "ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardMessage")]
public void ReplicationContract_TypeIdentity_IsPinned(Type type, string expectedFullName) =>
Assert.Equal(expectedFullName, type.FullName);
}