Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Telemetry/DriverHealthReportingNodeWireTests.cs
T
Joseph Doherty 5c994d1fc8 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.
2026-11-11 06:16:06 -05:00

127 lines
5.2 KiB
C#

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