diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/WireSerializationPinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/WireSerializationPinTests.cs new file mode 100644 index 00000000..c99598c4 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/WireSerializationPinTests.cs @@ -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; + +/// +/// Characterization pins for the cross-cluster wire contracts. The Akka default +/// reflective-JSON wire format couples compatibility to type and namespace +/// identity plus round-trip fidelity, and nothing else tested that +/// (MessageContractTests 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. +/// +public class WireSerializationPinTests : TestKit +{ + /// + /// 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. + /// + private T RoundTrip(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 + { + ["opc-1"] = ConnectionHealth.Connected, + }, + TagResolutionCounts: new Dictionary + { + ["opc-1"] = new TagResolutionStatus(10, 9), + }, + ScriptErrorCount: 1, + AlarmEvaluationErrorCount: 2, + StoreAndForwardBufferDepths: new Dictionary + { + ["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); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationWireSerializationPinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationWireSerializationPinTests.cs new file mode 100644 index 00000000..f1d194f0 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationWireSerializationPinTests.cs @@ -0,0 +1,82 @@ +using Akka.TestKit.Xunit2; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; + +/// +/// Characterization pin for the intra-cluster S&F replication contract. A +/// (carrying a full ) +/// 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. +/// +public class ReplicationWireSerializationPinTests : TestKit +{ + private T RoundTrip(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); +}