From e156c7fa8aa5d813f9ca94bf8e402e998d8a8343 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 06:49:00 -0400 Subject: [PATCH] =?UTF-8?q?test(commons):=20contract-lock=20tests=20for=20?= =?UTF-8?q?top=20ClusterClient=20records=20=E2=80=94=20enforce=20additive-?= =?UTF-8?q?only=20evolution=20under=20version=20skew=20(arch-review=2008?= =?UTF-8?q?=20=C2=A71.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- Directory.Packages.props | 3 + .../ClusterClientContractLockTests.cs | 195 ++++++++++++++++++ ...ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj | 1 + 3 files changed, 199 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ClusterClientContractLockTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index b81ebba2..32cfb650 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -49,6 +49,9 @@ + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ClusterClientContractLockTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ClusterClientContractLockTests.cs new file mode 100644 index 00000000..a24b27dc --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ClusterClientContractLockTests.cs @@ -0,0 +1,195 @@ +using Newtonsoft.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; + +/// +/// Contract-lock tests for the highest-traffic ClusterClient record contracts. +/// ClusterClient traffic uses Akka.NET's default Newtonsoft-JSON serializer and the +/// design supports system-wide artifact version skew across sites, so these records +/// must obey additive-only evolution: +/// (a) an old-shape payload (missing newer trailing optional fields) still +/// deserializes, with the missing fields taking their constructor defaults; +/// (b) an unknown field from a *newer* peer is ignored (never throws); +/// (c) a round-trip preserves the values that were sent. +/// If a change breaks one of these tests, it breaks rolling cross-cluster +/// compatibility — do NOT "fix the test"; make the change additive (append optional +/// trailing constructor parameters, never reorder/rename/remove/retype existing ones). +/// +public class ClusterClientContractLockTests +{ + // Mirrors Akka.NET's NewtonSoftJsonSerializer tolerance: unknown members are ignored + // rather than treated as errors, which is what lets a newer peer add fields safely. + private static readonly JsonSerializerSettings AkkaLikeSettings = new() + { + MissingMemberHandling = MissingMemberHandling.Ignore, + }; + + private static T RoundTrip(T value) + { + var json = JsonConvert.SerializeObject(value, AkkaLikeSettings); + return JsonConvert.DeserializeObject(json, AkkaLikeSettings)!; + } + + // --------------------------------------------------------------------- + // (a) Old-shape payloads: newer trailing optional fields absent → defaults. + // --------------------------------------------------------------------- + + [Fact] + public void CreateDataConnectionCommand_OldShape_WithoutNewerTrailingFields_Deserializes() + { + // Shape as emitted before BackupConfiguration/FailoverRetryCount existed. + const string oldShape = + """{"SiteId":3,"Name":"conn1","Protocol":"OpcUa","PrimaryConfiguration":"{}"}"""; + + var cmd = JsonConvert.DeserializeObject(oldShape, AkkaLikeSettings); + + Assert.NotNull(cmd); + Assert.Equal(3, cmd!.SiteId); + Assert.Equal("conn1", cmd.Name); + Assert.Equal("OpcUa", cmd.Protocol); + Assert.Equal("{}", cmd.PrimaryConfiguration); + // NOTE (locked reality): under Akka's Newtonsoft-JSON serializer a missing trailing + // field takes default(T) — NOT the C# optional-parameter default. So a reference type + // is null (BackupConfiguration) and a value type is 0 (FailoverRetryCount), even though + // the record declares `FailoverRetryCount = 3`. Version-skew callers therefore cannot + // rely on the C# default reaching the wire; additive fields must be safe at default(T). + Assert.Null(cmd.BackupConfiguration); // reference-type missing field → null + Assert.Equal(0, cmd.FailoverRetryCount); // value-type missing field → default(int), not 3 + } + + [Fact] + public void DeployInstanceCommand_OldShape_WithoutFetchFields_Deserializes() + { + // Shape as emitted before the notify-and-fetch CentralFetchBaseUrl/FetchToken pair. + const string oldShape = + """{"DeploymentId":"dep-1","InstanceUniqueName":"i","RevisionHash":"r","FlattenedConfigurationJson":"{}","DeployedBy":"me","Timestamp":"2026-07-08T00:00:00+00:00"}"""; + + var cmd = JsonConvert.DeserializeObject(oldShape, AkkaLikeSettings); + + Assert.NotNull(cmd); + Assert.Equal("dep-1", cmd!.DeploymentId); + Assert.Equal("i", cmd.InstanceUniqueName); + Assert.Null(cmd.CentralFetchBaseUrl); // default applied + Assert.Null(cmd.FetchToken); // default applied + } + + [Fact] + public void NotificationSubmit_OldShape_WithoutExecutionCorrelationFields_Deserializes() + { + // Shape as emitted before OriginExecutionId/OriginParentExecutionId/SourceNode. + const string oldShape = + """{"NotificationId":"n-1","ListName":"ops","Subject":"s","Body":"b","SourceSiteId":"site-1","SourceInstanceId":null,"SourceScript":null,"SiteEnqueuedAt":"2026-07-08T00:00:00+00:00"}"""; + + var msg = JsonConvert.DeserializeObject(oldShape, AkkaLikeSettings); + + Assert.NotNull(msg); + Assert.Equal("n-1", msg!.NotificationId); + Assert.Equal("ops", msg.ListName); + Assert.Null(msg.OriginExecutionId); // default applied + Assert.Null(msg.OriginParentExecutionId); // default applied + Assert.Null(msg.SourceNode); // default applied + } + + // --------------------------------------------------------------------- + // (b) Unknown fields from a newer peer are ignored (never throw). + // --------------------------------------------------------------------- + + [Fact] + public void AttributeValueChanged_UnknownFieldFromNewerPeer_IsIgnored() + { + const string newerShape = + """{"InstanceUniqueName":"i","AttributePath":"a.b","AttributeName":"b","Value":1,"Quality":"Good","Timestamp":"2026-07-08T00:00:00+00:00","SomeFutureField":"x"}"""; + + var evt = JsonConvert.DeserializeObject(newerShape, AkkaLikeSettings); + + Assert.NotNull(evt); + Assert.Equal("i", evt!.InstanceUniqueName); + Assert.Equal("Good", evt.Quality); + } + + [Fact] + public void GetAttributeRequest_UnknownFieldFromNewerPeer_IsIgnored() + { + const string newerShape = + """{"CorrelationId":"c-1","InstanceUniqueName":"i","AttributeName":"a","Timestamp":"2026-07-08T00:00:00+00:00","FutureFlag":true}"""; + + var req = JsonConvert.DeserializeObject(newerShape, AkkaLikeSettings); + + Assert.NotNull(req); + Assert.Equal("c-1", req!.CorrelationId); + Assert.Equal("a", req.AttributeName); + } + + [Fact] + public void SetStaticAttributeCommand_UnknownFieldFromNewerPeer_IsIgnored() + { + const string newerShape = + """{"CorrelationId":"c-1","InstanceUniqueName":"i","AttributeName":"a","Value":"42","Timestamp":"2026-07-08T00:00:00+00:00","NewerAudience":"x"}"""; + + var cmd = JsonConvert.DeserializeObject(newerShape, AkkaLikeSettings); + + Assert.NotNull(cmd); + Assert.Equal("c-1", cmd!.CorrelationId); + Assert.Equal("42", cmd.Value); + } + + // --------------------------------------------------------------------- + // (c) Round-trip preserves values. + // --------------------------------------------------------------------- + + [Fact] + public void ManagementEnvelope_RoundTrip_PreservesCorrelationIdAndUser() + { + var user = new AuthenticatedUser( + "multi-role", "Multi Role", + new[] { "Administrator", "Designer" }, new[] { "site-1", "site-2" }); + var envelope = new ManagementEnvelope( + user, new GetDataConnectionCommand(7), "corr-1"); + + var round = RoundTrip(envelope); + + Assert.Equal("corr-1", round.CorrelationId); + Assert.Equal("multi-role", round.User.Username); + Assert.Equal(new[] { "Administrator", "Designer" }, round.User.Roles); + Assert.Equal(new[] { "site-1", "site-2" }, round.User.PermittedSiteIds); + } + + [Fact] + public void AttributeValueChanged_RoundTrip_PreservesValues() + { + var evt = new AttributeValueChanged( + "inst-1", "path.to.attr", "attr", 42, "Good", + new DateTimeOffset(2026, 7, 8, 12, 0, 0, TimeSpan.Zero)); + + var round = RoundTrip(evt); + + Assert.Equal("inst-1", round.InstanceUniqueName); + Assert.Equal("path.to.attr", round.AttributePath); + Assert.Equal("attr", round.AttributeName); + Assert.Equal("Good", round.Quality); + Assert.Equal(evt.Timestamp, round.Timestamp); + } + + [Fact] + public void CreateDataConnectionCommand_RoundTrip_PreservesValues() + { + var cmd = new CreateDataConnectionCommand( + SiteId: 5, Name: "conn-x", Protocol: "MxAccess", + PrimaryConfiguration: "{\"a\":1}", BackupConfiguration: "{\"b\":2}", + FailoverRetryCount: 9); + + var round = RoundTrip(cmd); + + Assert.Equal(5, round.SiteId); + Assert.Equal("conn-x", round.Name); + Assert.Equal("MxAccess", round.Protocol); + Assert.Equal("{\"a\":1}", round.PrimaryConfiguration); + Assert.Equal("{\"b\":2}", round.BackupConfiguration); + Assert.Equal(9, round.FailoverRetryCount); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj index 621db8e6..bd53af9e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj @@ -11,6 +11,7 @@ +