test(commons): contract-lock tests for top ClusterClient records — enforce additive-only evolution under version skew (arch-review 08 §1.8)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 06:49:00 -04:00
parent fcc9c7bf8e
commit e156c7fa8a
3 changed files with 199 additions and 0 deletions
+3
View File
@@ -49,6 +49,9 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Microsoft.Playwright" Version="1.58.0" /> <PackageVersion Include="Microsoft.Playwright" Version="1.58.0" />
<PackageVersion Include="Moq" Version="4.20.72" /> <PackageVersion Include="Moq" Version="4.20.72" />
<!-- Pinned to the version Akka.NET already pulls transitively (no version-bump ride-along);
used by Commons.Tests contract-lock tests that exercise the Akka default JSON serializer. -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" /> <PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" /> <PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" /> <PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
@@ -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;
/// <summary>
/// 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).
/// </summary>
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>(T value)
{
var json = JsonConvert.SerializeObject(value, AkkaLikeSettings);
return JsonConvert.DeserializeObject<T>(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<CreateDataConnectionCommand>(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<DeployInstanceCommand>(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<NotificationSubmit>(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<AttributeValueChanged>(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<GetAttributeRequest>(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<SetStaticAttributeCommand>(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);
}
}
@@ -11,6 +11,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup> </ItemGroup>