diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/DriverHealthReportingNodeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/DriverHealthReportingNodeWireTests.cs new file mode 100644 index 000000000..f0d096ce8 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/DriverHealthReportingNodeWireTests.cs @@ -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; + +/// +/// Mixed-version wire tests for DriverHealth.reporting_node_id (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 Protos/telemetry_pre847.proto — a real generated parser/serializer, not +/// a hand-built byte string. +/// +[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"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/Protos/telemetry_pre847.proto b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/Protos/telemetry_pre847.proto new file mode 100644 index 000000000..87bda4139 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/Protos/telemetry_pre847.proto @@ -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 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; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj index 9b1252e6b..9608a8300 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj @@ -11,6 +11,12 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + all @@ -18,6 +24,10 @@ + + + +