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); } }