test(controlplane): mixed-version wire tests for DriverHealth.reporting_node_id (#851)
A frozen pre-#847 copy of DriverHealth (test-only telemetry_pre847.proto, generated via Grpc.Tools in the test project) stands in for an old node / old central: new->new round-trips the node id; old->new parses as absent (HasReportingNodeId false -> null, not "") with every other field intact; new->old ignores field 15 without shifting known fields and preserves it as an unknown field.
This commit is contained in:
+126
@@ -0,0 +1,126 @@
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Telemetry.Pre847;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Mixed-version wire tests for <c>DriverHealth.reporting_node_id</c> (proto field 15, added by lmxopcua#847;
|
||||
/// tests lmxopcua#851). A rolling upgrade runs old and new nodes against old and new centrals, so the field
|
||||
/// must cross the wire in all three directions: new→new round-trips, old→new parses as ABSENT (not an empty
|
||||
/// node id), and new→old is ignored as an unknown field without disturbing the rest of the message. "Old" is
|
||||
/// the frozen pre-#847 schema in <c>Protos/telemetry_pre847.proto</c> — a real generated parser/serializer, not
|
||||
/// a hand-built byte string.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverHealthReportingNodeWireTests
|
||||
{
|
||||
private static readonly DateTime PublishedUtc = new(2026, 11, 11, 12, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime LastReadUtc = new(2026, 11, 11, 11, 59, 30, DateTimeKind.Utc);
|
||||
|
||||
private static DriverHealth NewHealth(string? reportingNodeId)
|
||||
{
|
||||
var msg = new DriverHealth
|
||||
{
|
||||
ClusterId = "MAIN",
|
||||
DriverInstanceId = "drv-1",
|
||||
State = "Faulted",
|
||||
LastSuccessfulReadUtc = Timestamp.FromDateTime(LastReadUtc),
|
||||
LastError = "timeout",
|
||||
ErrorCount5Min = 4,
|
||||
PublishedUtc = Timestamp.FromDateTime(PublishedUtc),
|
||||
HasDiagnostics = true,
|
||||
};
|
||||
msg.Diagnostics["pdu"] = "480";
|
||||
if (reportingNodeId is not null) msg.ReportingNodeId = reportingNodeId;
|
||||
return msg;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_sender_to_new_receiver_round_trips_the_reporting_node_id()
|
||||
{
|
||||
var bytes = NewHealth("central-1").ToByteArray();
|
||||
|
||||
var parsed = DriverHealth.Parser.ParseFrom(bytes);
|
||||
parsed.HasReportingNodeId.ShouldBeTrue();
|
||||
|
||||
var e = TelemetryProtoMapCentral.ToHealth(parsed);
|
||||
e.ReportingNodeId.ShouldBe("central-1");
|
||||
e.DriverInstanceId.ShouldBe("drv-1");
|
||||
e.State.ShouldBe("Faulted");
|
||||
e.PublishedUtc.ShouldBe(PublishedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_sender_without_a_node_id_omits_field_15_and_maps_to_null()
|
||||
{
|
||||
// A harness / node with no role info leaves the field unset: it must not appear on the wire at all, so an
|
||||
// explicit "" would not be confused with "absent" either way.
|
||||
var msg = NewHealth(reportingNodeId: null);
|
||||
msg.HasReportingNodeId.ShouldBeFalse();
|
||||
|
||||
var e = TelemetryProtoMapCentral.ToHealth(DriverHealth.Parser.ParseFrom(msg.ToByteArray()));
|
||||
e.ReportingNodeId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Old_sender_to_new_receiver_parses_as_absent_node_id_and_keeps_every_other_field()
|
||||
{
|
||||
var old = new DriverHealthPre847
|
||||
{
|
||||
ClusterId = "MAIN",
|
||||
DriverInstanceId = "drv-1",
|
||||
State = "Healthy",
|
||||
LastSuccessfulReadUtc = Timestamp.FromDateTime(LastReadUtc),
|
||||
ErrorCount5Min = 0,
|
||||
PublishedUtc = Timestamp.FromDateTime(PublishedUtc),
|
||||
RediscoveryReason = "schema changed",
|
||||
HasDiagnostics = true,
|
||||
};
|
||||
old.Diagnostics["pdu"] = "240";
|
||||
|
||||
var parsed = DriverHealth.Parser.ParseFrom(old.ToByteArray());
|
||||
|
||||
// Explicit presence: absent, NOT the proto3 default "" — central keys the snapshot under the
|
||||
// driver-only (unlabelled) node and the AdminUI renders it as a pre-#847 row.
|
||||
parsed.HasReportingNodeId.ShouldBeFalse();
|
||||
var e = TelemetryProtoMapCentral.ToHealth(parsed);
|
||||
e.ReportingNodeId.ShouldBeNull();
|
||||
e.ClusterId.ShouldBe("MAIN");
|
||||
e.DriverInstanceId.ShouldBe("drv-1");
|
||||
e.State.ShouldBe("Healthy");
|
||||
e.LastSuccessfulReadUtc.ShouldBe(LastReadUtc);
|
||||
e.LastError.ShouldBeNull();
|
||||
e.PublishedUtc.ShouldBe(PublishedUtc);
|
||||
e.RediscoveryReason.ShouldBe("schema changed");
|
||||
e.Diagnostics.ShouldNotBeNull();
|
||||
e.Diagnostics!["pdu"].ShouldBe("240");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_sender_to_old_receiver_ignores_field_15_and_keeps_every_other_field()
|
||||
{
|
||||
var bytes = NewHealth("central-2").ToByteArray();
|
||||
|
||||
// An old central's parser: field 15 is unknown to it. It must neither throw nor shift any known field.
|
||||
var old = DriverHealthPre847.Parser.ParseFrom(bytes);
|
||||
|
||||
old.ClusterId.ShouldBe("MAIN");
|
||||
old.DriverInstanceId.ShouldBe("drv-1");
|
||||
old.State.ShouldBe("Faulted");
|
||||
old.LastSuccessfulReadUtc.ToDateTime().ShouldBe(LastReadUtc);
|
||||
old.LastError.ShouldBe("timeout");
|
||||
old.ErrorCount5Min.ShouldBe(4);
|
||||
old.PublishedUtc.ToDateTime().ShouldBe(PublishedUtc);
|
||||
old.HasDiagnostics.ShouldBeTrue();
|
||||
old.Diagnostics["pdu"].ShouldBe("480");
|
||||
|
||||
// The old parser keeps field 15 as an unknown field rather than corrupting it: re-serialised by an old
|
||||
// process, a new reader still recovers the node id.
|
||||
DriverHealth.Parser.ParseFrom(old.ToByteArray()).ReportingNodeId.ShouldBe("central-2");
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
syntax = "proto3";
|
||||
|
||||
// TEST-ONLY frozen copy of telemetry.v1.DriverHealth as it stood BEFORE lmxopcua#847 added
|
||||
// `optional string reporting_node_id = 15`. It stands in for an old node / an old central in the mixed-version
|
||||
// wire tests (lmxopcua#851): the same field numbers and wire types as the real message, minus field 15. Never edit
|
||||
// it to track telemetry.proto — its whole point is to stay old.
|
||||
package telemetry.tests.pre847;
|
||||
|
||||
option csharp_namespace = "ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Telemetry.Pre847";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
message DriverHealthPre847 {
|
||||
string cluster_id = 1;
|
||||
string driver_instance_id = 2;
|
||||
string state = 3;
|
||||
google.protobuf.Timestamp last_successful_read_utc = 4;
|
||||
optional string last_error = 5;
|
||||
int32 error_count_5min = 6;
|
||||
google.protobuf.Timestamp published_utc = 7;
|
||||
google.protobuf.Timestamp rediscovery_needed_utc = 8;
|
||||
optional string rediscovery_reason = 9;
|
||||
bool has_host_statuses = 10;
|
||||
repeated HostConnectivityPre847 host_statuses = 11;
|
||||
SkippedTagsPre847 skipped_tags = 12;
|
||||
bool has_diagnostics = 13;
|
||||
map<string, string> diagnostics = 14;
|
||||
}
|
||||
|
||||
message SkippedTagsPre847 {
|
||||
int32 authored_count = 1;
|
||||
int32 skipped_count = 2;
|
||||
repeated SkippedTagPre847 sample = 3;
|
||||
}
|
||||
|
||||
message SkippedTagPre847 {
|
||||
string raw_path = 1;
|
||||
string reason = 2;
|
||||
}
|
||||
|
||||
message HostConnectivityPre847 {
|
||||
string host_name = 1;
|
||||
string state = 2;
|
||||
google.protobuf.Timestamp last_changed_utc = 3;
|
||||
}
|
||||
+10
@@ -11,6 +11,12 @@
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="Akka.TestKit.Xunit"/>
|
||||
<!-- Generates the test-only pre-#847 DriverHealth schema (Telemetry/Protos/telemetry_pre847.proto) used by
|
||||
the mixed-version wire tests (lmxopcua#851). Message types only - no services. -->
|
||||
<PackageReference Include="Grpc.Tools">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -18,6 +24,10 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Telemetry\Protos\telemetry_pre847.proto" GrpcServices="None"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
||||
|
||||
Reference in New Issue
Block a user