192 lines
8.6 KiB
C#
192 lines
8.6 KiB
C#
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);
|
|
}
|