d7455577a8
Phase 1A of the ClusterClient→gRPC migration needs a wire contract for the seven messages SiteCommunicationActor forwards to /user/central-communication. This lands the contract and its mapper only — hosting (T1A.2) and the site-side transport seam (T1A.3) follow. `Protos/central_control.proto` (package scadabridge.centralcontrol.v1, service CentralControlService) declares SubmitNotification, QueryNotificationStatus, IngestAuditEvents, IngestCachedTelemetry, ReconcileSite, ReportSiteHealth and Heartbeat. Note the direction is the inverse of SiteStreamService: here the site dials and central serves. Decisions worth recording: - The two ingest RPCs IMPORT sitestream.proto and reuse AuditEventBatch / CachedTelemetryBatch / IngestAck rather than redeclaring them. The site telemetry actor already builds those messages, so a second copy would fork one wire contract into two kept in lockstep by hand. ForwardState / IngestedAtUtc stay off-wire exactly as they are today. - Heartbeat replies google.protobuf.Empty — it is fire-and-forget and must never surface a fault onto the heartbeat timer path. - The three NULLABLE SiteHealthReport collections travel inside single-field wrapper messages (ConnectionEndpointMapDto / TagQualityMapDto / NodeStatusListDto). proto3 cannot express presence on repeated/map fields, but null and empty genuinely differ here — SiteHealthCollector emits `ClusterNodes: _clusterNodes?.ToList()` and the central health surface reads null as "not reported", not as "reported empty". Same reasoning drives the BoolValue/Int64Value/DoubleValue wrappers on LocalDbReplicationConnected, LocalDbOplogBacklog and the two age gauges, whose docs are explicit that null is not zero/false. - ConnectionHealthEnum reserves 0 for UNSPECIFIED instead of mapping Connected onto it, and the decoder resolves anything unknown to ConnectionHealth.Error. An unrecognised connection state must not render as "healthy". - Guid? execution ids travel as "D" strings with empty meaning null; a malformed non-empty value throws rather than being laundered into "no correlation". - DateTimeOffset normalizes to a UTC instant (a protobuf Timestamp has no offset). Lossless in practice — every producer stamps UTC — and documented + asserted rather than left implicit. SiteCallDtoMapper gains a ToDto(SiteCall) overload. Its doc comment previously asserted such a method "would be dead code"; that held only while ClusterClient was the sole path from IngestCachedTelemetryCommand (which carries SiteCall, not SiteCallOperational) to central. Comment corrected alongside. Golden tests round-trip every message through a real protobuf encode/decode — DTO → proto → bytes → proto → DTO — with a fully-populated case and a null/empty/minimal case for every optional member. Verified to have teeth by mutation: dropping a scalar, collapsing an empty nullable collection to absent, and nulling a gauge each fail a test. Codegen stays CHECKED IN under CentralControlGrpc/ (protoc segfaults in the linux_arm64 Docker image); no active <Protobuf> item is committed. docker/regen-proto.sh is generalized to `regen-proto.sh [sitestream| centralcontrol|all]` — it now injects the ItemGroup rather than unwrapping a comment, so it no longer depends on there being exactly one Protobuf line, and it restores the csproj verbatim on every exit path.
644 lines
28 KiB
C#
644 lines
28 KiB
C#
using Google.Protobuf;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
|
|
|
/// <summary>
|
|
/// Round-trip golden tests for <see cref="CentralControlDtoMapper"/> — the DTO bridge
|
|
/// for the seven <c>CentralControlService</c> RPCs (Phase 1A of the ClusterClient→gRPC
|
|
/// migration).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Every message gets a pair: a FULLY-POPULATED case that proves no field is dropped,
|
|
/// and a MINIMAL case that proves every nullable/optional/empty-collection member comes
|
|
/// back as null-or-empty rather than as a zero-valued stand-in. The minimal cases are
|
|
/// the ones that matter: a per-field unit test happily passes while a whole optional
|
|
/// branch is silently never written, which is exactly the class of bug the round-trip
|
|
/// guard in PLAN-05 T8 caught five times over.
|
|
/// </para>
|
|
/// <para>
|
|
/// When a round-trip here fails, the mapper is wrong — not the test.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class CentralControlDtoMapperTests
|
|
{
|
|
private static readonly DateTimeOffset SampleInstant =
|
|
new(2026, 7, 22, 9, 30, 15, 250, TimeSpan.Zero);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// NotificationSubmit / Ack
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void NotificationSubmit_RoundTrip_FullyPopulated_PreservesEveryField()
|
|
{
|
|
var original = new NotificationSubmit(
|
|
NotificationId: Guid.NewGuid().ToString(),
|
|
ListName: "plant-ops",
|
|
Subject: "Tank 4 overfill",
|
|
Body: "Level exceeded 95% for 5 minutes.",
|
|
SourceSiteId: "site-a",
|
|
SourceInstanceId: "Tank04",
|
|
SourceScript: "OnLevelHigh",
|
|
SiteEnqueuedAt: SampleInstant,
|
|
OriginExecutionId: Guid.NewGuid(),
|
|
OriginParentExecutionId: Guid.NewGuid(),
|
|
SourceNode: "node-b");
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
// Every member is a scalar, so record value-equality is a true deep compare.
|
|
Assert.Equal(original, roundTripped);
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationSubmit_RoundTrip_AllOptionalsNull_StayNull()
|
|
{
|
|
var original = new NotificationSubmit(
|
|
NotificationId: Guid.NewGuid().ToString(),
|
|
ListName: "plant-ops",
|
|
Subject: "s",
|
|
Body: "b",
|
|
SourceSiteId: "site-a",
|
|
SourceInstanceId: null,
|
|
SourceScript: null,
|
|
SiteEnqueuedAt: SampleInstant,
|
|
OriginExecutionId: null,
|
|
OriginParentExecutionId: null,
|
|
SourceNode: null);
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
Assert.Equal(original, roundTripped);
|
|
Assert.Null(roundTripped.SourceInstanceId);
|
|
Assert.Null(roundTripped.SourceScript);
|
|
Assert.Null(roundTripped.OriginExecutionId);
|
|
Assert.Null(roundTripped.OriginParentExecutionId);
|
|
Assert.Null(roundTripped.SourceNode);
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationSubmit_NonUtcOffset_NormalizesToTheSameInstant()
|
|
{
|
|
// Documented contract: a protobuf Timestamp is an instant, so the offset
|
|
// component of a DateTimeOffset does not survive. Every producer stamps UTC
|
|
// (Notify.Send uses DateTimeOffset.UtcNow), so the instant is what matters.
|
|
var melbourne = new DateTimeOffset(2026, 7, 22, 19, 30, 15, TimeSpan.FromHours(10));
|
|
|
|
var original = new NotificationSubmit(
|
|
"id", "list", "s", "b", "site-a", null, null, melbourne);
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
Assert.Equal(melbourne.UtcDateTime, roundTripped.SiteEnqueuedAt.UtcDateTime);
|
|
Assert.Equal(TimeSpan.Zero, roundTripped.SiteEnqueuedAt.Offset);
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationSubmitAck_RoundTrip_Accepted_And_Rejected()
|
|
{
|
|
var accepted = new NotificationSubmitAck("n1", Accepted: true, Error: null);
|
|
var rejected = new NotificationSubmitAck("n2", Accepted: false, Error: "list not found");
|
|
|
|
Assert.Equal(accepted, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))));
|
|
Assert.Equal(rejected, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(rejected))));
|
|
Assert.Null(CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))).Error);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// NotificationStatusQuery / Response
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void NotificationStatusQuery_RoundTrip_PreservesEveryField()
|
|
{
|
|
var original = new NotificationStatusQuery("corr-1", Guid.NewGuid().ToString());
|
|
|
|
Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationStatusResponse_RoundTrip_FullyPopulated_PreservesEveryField()
|
|
{
|
|
var original = new NotificationStatusResponse(
|
|
CorrelationId: "corr-1",
|
|
Found: true,
|
|
Status: "Delivered",
|
|
RetryCount: 3,
|
|
LastError: "smtp 421",
|
|
DeliveredAt: SampleInstant);
|
|
|
|
Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationStatusResponse_RoundTrip_NotFound_LeavesOptionalsNull()
|
|
{
|
|
var original = new NotificationStatusResponse(
|
|
CorrelationId: "corr-1",
|
|
Found: false,
|
|
Status: "Unknown",
|
|
RetryCount: 0,
|
|
LastError: null,
|
|
DeliveredAt: null);
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
Assert.Equal(original, roundTripped);
|
|
Assert.Null(roundTripped.LastError);
|
|
Assert.Null(roundTripped.DeliveredAt);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Audit ingest envelopes (rows delegate to AuditEventDtoMapper / SiteCallDtoMapper)
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void IngestAuditEventsCommand_RoundTrip_PreservesOrderAndRows()
|
|
{
|
|
var first = NewAuditEvent(sourceScript: "OnDemand");
|
|
var second = NewAuditEvent(sourceScript: "OnTrigger");
|
|
var original = new IngestAuditEventsCommand([first, second]);
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
Assert.Equal(2, roundTripped.Events.Count);
|
|
Assert.Equal(first.AsRow().EventId, roundTripped.Events[0].AsRow().EventId);
|
|
Assert.Equal("OnDemand", roundTripped.Events[0].AsRow().SourceScript);
|
|
Assert.Equal(second.AsRow().EventId, roundTripped.Events[1].AsRow().EventId);
|
|
Assert.Equal("OnTrigger", roundTripped.Events[1].AsRow().SourceScript);
|
|
}
|
|
|
|
[Fact]
|
|
public void IngestAuditEventsCommand_RoundTrip_EmptyBatch_StaysEmpty()
|
|
{
|
|
var roundTripped = CentralControlDtoMapper.FromDto(
|
|
Wire(CentralControlDtoMapper.ToDto(new IngestAuditEventsCommand([]))));
|
|
|
|
Assert.Empty(roundTripped.Events);
|
|
}
|
|
|
|
[Fact]
|
|
public void IngestCachedTelemetryCommand_RoundTrip_PreservesBothHalvesOfEachPacket()
|
|
{
|
|
var audit = NewAuditEvent(sourceScript: "OnDemand");
|
|
var siteCall = NewSiteCall();
|
|
var original = new IngestCachedTelemetryCommand([new CachedTelemetryEntry(audit, siteCall)]);
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
var entry = Assert.Single(roundTripped.Entries);
|
|
Assert.Equal(audit.AsRow().EventId, entry.Audit.AsRow().EventId);
|
|
|
|
// IngestedAtUtc is central-set inside the dual-write transaction and is
|
|
// deliberately off the wire, so it is the one member excluded from the compare.
|
|
Assert.Equal(siteCall with { IngestedAtUtc = default }, entry.SiteCall with { IngestedAtUtc = default });
|
|
}
|
|
|
|
[Fact]
|
|
public void IngestCachedTelemetryCommand_RoundTrip_NullableSiteCallFields_StayNull()
|
|
{
|
|
var siteCall = NewSiteCall() with
|
|
{
|
|
SourceNode = null,
|
|
LastError = null,
|
|
HttpStatus = null,
|
|
TerminalAtUtc = null,
|
|
};
|
|
var original = new IngestCachedTelemetryCommand(
|
|
[new CachedTelemetryEntry(NewAuditEvent(), siteCall)]);
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
var entry = Assert.Single(roundTripped.Entries);
|
|
Assert.Null(entry.SiteCall.SourceNode);
|
|
Assert.Null(entry.SiteCall.LastError);
|
|
Assert.Null(entry.SiteCall.HttpStatus);
|
|
Assert.Null(entry.SiteCall.TerminalAtUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void IngestCachedTelemetryCommand_RoundTrip_EmptyBatch_StaysEmpty()
|
|
{
|
|
var roundTripped = CentralControlDtoMapper.FromDto(
|
|
Wire(CentralControlDtoMapper.ToDto(new IngestCachedTelemetryCommand([]))));
|
|
|
|
Assert.Empty(roundTripped.Entries);
|
|
}
|
|
|
|
[Fact]
|
|
public void IngestAck_RoundTrip_PreservesIdsAndOrder()
|
|
{
|
|
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromIngestAck(
|
|
Wire(CentralControlDtoMapper.ToIngestAck(ids)));
|
|
|
|
Assert.Equal(ids, roundTripped);
|
|
}
|
|
|
|
[Fact]
|
|
public void IngestAck_RoundTrip_NothingAccepted_StaysEmpty()
|
|
{
|
|
// An empty ack is meaningful — "zero rows persisted" leaves the site's rows
|
|
// Pending — so it must not be indistinguishable from a dropped field.
|
|
Assert.Empty(CentralControlDtoMapper.FromIngestAck(
|
|
Wire(CentralControlDtoMapper.ToIngestAck([]))));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Startup reconciliation
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void ReconcileSiteRequest_RoundTrip_PreservesInventoryMap()
|
|
{
|
|
var original = new ReconcileSiteRequest(
|
|
SiteIdentifier: "site-a",
|
|
NodeId: "node-a",
|
|
LocalNameToRevisionHash: new Dictionary<string, string>
|
|
{
|
|
["Plant.Tank04"] = "hash-1",
|
|
["Plant.Pump01"] = "hash-2",
|
|
});
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
Assert.Equal(original.SiteIdentifier, roundTripped.SiteIdentifier);
|
|
Assert.Equal(original.NodeId, roundTripped.NodeId);
|
|
Assert.Equal(
|
|
original.LocalNameToRevisionHash.OrderBy(kv => kv.Key),
|
|
roundTripped.LocalNameToRevisionHash.OrderBy(kv => kv.Key));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReconcileSiteRequest_RoundTrip_EmptyInventory_StaysEmpty()
|
|
{
|
|
// A node with nothing deployed is the normal first-boot case, and central
|
|
// must see an empty inventory rather than a missing one.
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(
|
|
new ReconcileSiteRequest("site-a", "node-a", new Dictionary<string, string>()))));
|
|
|
|
Assert.Empty(roundTripped.LocalNameToRevisionHash);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReconcileSiteResponse_RoundTrip_PreservesGapOrphansAndUrl()
|
|
{
|
|
var original = new ReconcileSiteResponse(
|
|
Gap:
|
|
[
|
|
new ReconcileGapItem("Plant.Tank04", "dep-1", "hash-1", IsEnabled: true, "token-1"),
|
|
new ReconcileGapItem("Plant.Pump01", "dep-2", "hash-2", IsEnabled: false, "token-2"),
|
|
],
|
|
OrphanNames: ["Plant.Retired01", "Plant.Retired02"],
|
|
CentralFetchBaseUrl: "http://scadabridge-central-a:5000");
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
Assert.Equal(original.Gap, roundTripped.Gap);
|
|
Assert.Equal(original.OrphanNames, roundTripped.OrphanNames);
|
|
Assert.Equal(original.CentralFetchBaseUrl, roundTripped.CentralFetchBaseUrl);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReconcileSiteResponse_RoundTrip_NoGap_StaysEmpty()
|
|
{
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(
|
|
new ReconcileSiteResponse([], [], "http://central:5000"))));
|
|
|
|
Assert.Empty(roundTripped.Gap);
|
|
Assert.Empty(roundTripped.OrphanNames);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Site health
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void SiteHealthReport_RoundTrip_FullyPopulated_PreservesEveryField()
|
|
{
|
|
var original = FullyPopulatedHealthReport();
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
AssertHealthReportsEqual(original, roundTripped);
|
|
}
|
|
|
|
[Fact]
|
|
public void SiteHealthReport_RoundTrip_MinimalReport_LeavesEveryOptionalNullOrEmpty()
|
|
{
|
|
// The shape a producer emits before any reporter has run: no endpoints map, no
|
|
// tag-quality map, no cluster-node list, no audit backlog, no nullable gauges.
|
|
var original = new SiteHealthReport(
|
|
SiteId: "site-a",
|
|
SequenceNumber: 1,
|
|
ReportTimestamp: SampleInstant,
|
|
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
|
|
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
|
ScriptErrorCount: 0,
|
|
AlarmEvaluationErrorCount: 0,
|
|
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
|
DeadLetterCount: 0,
|
|
DeployedInstanceCount: 0,
|
|
EnabledInstanceCount: 0,
|
|
DisabledInstanceCount: 0);
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
AssertHealthReportsEqual(original, roundTripped);
|
|
|
|
Assert.Empty(roundTripped.DataConnectionStatuses);
|
|
Assert.Empty(roundTripped.TagResolutionCounts);
|
|
Assert.Empty(roundTripped.StoreAndForwardBufferDepths);
|
|
Assert.Null(roundTripped.DataConnectionEndpoints);
|
|
Assert.Null(roundTripped.DataConnectionTagQuality);
|
|
Assert.Null(roundTripped.ClusterNodes);
|
|
Assert.Null(roundTripped.SiteAuditBacklog);
|
|
Assert.Null(roundTripped.OldestParkedMessageAgeSeconds);
|
|
Assert.Null(roundTripped.ScriptOldestBusyAgeSeconds);
|
|
Assert.Null(roundTripped.LocalDbReplicationConnected);
|
|
Assert.Null(roundTripped.LocalDbOplogBacklog);
|
|
}
|
|
|
|
[Fact]
|
|
public void SiteHealthReport_RoundTrip_EmptyNullableCollections_StayEmptyNotNull()
|
|
{
|
|
// The distinction the three wrapper messages exist for. Null means "this node
|
|
// does not report the signal"; empty means "it reports it, and there is
|
|
// nothing in it". proto3 cannot express presence on repeated/map fields, so
|
|
// collapsing one into the other here would be invisible until an operator
|
|
// misread the health page.
|
|
var original = FullyPopulatedHealthReport() with
|
|
{
|
|
DataConnectionEndpoints = new Dictionary<string, string>(),
|
|
DataConnectionTagQuality = new Dictionary<string, TagQualityCounts>(),
|
|
ClusterNodes = [],
|
|
};
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
Assert.NotNull(roundTripped.DataConnectionEndpoints);
|
|
Assert.Empty(roundTripped.DataConnectionEndpoints);
|
|
Assert.NotNull(roundTripped.DataConnectionTagQuality);
|
|
Assert.Empty(roundTripped.DataConnectionTagQuality);
|
|
Assert.NotNull(roundTripped.ClusterNodes);
|
|
Assert.Empty(roundTripped.ClusterNodes);
|
|
}
|
|
|
|
[Fact]
|
|
public void SiteHealthReport_RoundTrip_FalseAndZeroGauges_StayFalseAndZero()
|
|
{
|
|
// The mirror of the null case: LocalDbReplicationConnected=false ("configured
|
|
// but disconnected") and LocalDbOplogBacklog=0 ("healthy, nothing queued") are
|
|
// real values that must not decay into null on the wire.
|
|
var original = FullyPopulatedHealthReport() with
|
|
{
|
|
LocalDbReplicationConnected = false,
|
|
LocalDbOplogBacklog = 0,
|
|
OldestParkedMessageAgeSeconds = 0d,
|
|
ScriptOldestBusyAgeSeconds = 0d,
|
|
};
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
Assert.False(roundTripped.LocalDbReplicationConnected);
|
|
Assert.Equal(0L, roundTripped.LocalDbOplogBacklog);
|
|
Assert.Equal(0d, roundTripped.OldestParkedMessageAgeSeconds);
|
|
Assert.Equal(0d, roundTripped.ScriptOldestBusyAgeSeconds);
|
|
}
|
|
|
|
[Fact]
|
|
public void SiteHealthReport_RoundTrip_BacklogWithEmptyQueue_KeepsNullOldestPending()
|
|
{
|
|
var original = FullyPopulatedHealthReport() with
|
|
{
|
|
SiteAuditBacklog = new SiteAuditBacklogSnapshot(0, null, 4096),
|
|
};
|
|
|
|
var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));
|
|
|
|
Assert.Equal(new SiteAuditBacklogSnapshot(0, null, 4096), roundTripped.SiteAuditBacklog);
|
|
}
|
|
|
|
[Fact]
|
|
public void SiteHealthReportAck_RoundTrip_Accepted_And_Rejected()
|
|
{
|
|
var accepted = new SiteHealthReportAck("site-a", 42, Accepted: true);
|
|
var rejected = new SiteHealthReportAck("site-a", 43, Accepted: false, Error: "aggregator down");
|
|
|
|
Assert.Equal(accepted, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))));
|
|
Assert.Equal(rejected, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(rejected))));
|
|
Assert.Null(CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))).Error);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Heartbeat + ConnectionHealth enum
|
|
// -----------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void HeartbeatMessage_RoundTrip_PreservesEveryField(bool isActive)
|
|
{
|
|
var original = new HeartbeatMessage("site-a", "scadabridge-site-a-node-b", isActive, SampleInstant);
|
|
|
|
Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(ConnectionHealth.Connected)]
|
|
[InlineData(ConnectionHealth.Disconnected)]
|
|
[InlineData(ConnectionHealth.Connecting)]
|
|
[InlineData(ConnectionHealth.Error)]
|
|
public void ConnectionHealth_RoundTrip_EveryValueSurvives(ConnectionHealth health)
|
|
{
|
|
Assert.Equal(health, CentralControlDtoMapper.FromDto(CentralControlDtoMapper.ToDto(health)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectionHealth_Unspecified_DecodesToErrorNotConnected()
|
|
{
|
|
// Fail-safe direction: a wire value this build does not recognise must never
|
|
// render as "healthy" on the central health page.
|
|
Assert.Equal(
|
|
ConnectionHealth.Error,
|
|
CentralControlDtoMapper.FromDto(ConnectionHealthEnum.ConnectionHealthUnspecified));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectionHealth_EveryEnumMemberIsMapped()
|
|
{
|
|
// Guards the ArgumentOutOfRangeException path: adding a ConnectionHealth value
|
|
// without extending the mapper should fail here, not silently on a rig.
|
|
foreach (var health in Enum.GetValues<ConnectionHealth>())
|
|
{
|
|
var wire = CentralControlDtoMapper.ToDto(health);
|
|
Assert.NotEqual(ConnectionHealthEnum.ConnectionHealthUnspecified, wire);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Fixtures
|
|
// -----------------------------------------------------------------------
|
|
|
|
private static SiteHealthReport FullyPopulatedHealthReport() =>
|
|
new(
|
|
SiteId: "site-a",
|
|
SequenceNumber: 987654321L,
|
|
ReportTimestamp: SampleInstant,
|
|
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>
|
|
{
|
|
["opc-main"] = ConnectionHealth.Connected,
|
|
["mx-gateway"] = ConnectionHealth.Error,
|
|
["opc-backup"] = ConnectionHealth.Connecting,
|
|
["legacy"] = ConnectionHealth.Disconnected,
|
|
},
|
|
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>
|
|
{
|
|
["opc-main"] = new(TotalSubscribed: 120, SuccessfullyResolved: 118),
|
|
},
|
|
ScriptErrorCount: 4,
|
|
AlarmEvaluationErrorCount: 2,
|
|
StoreAndForwardBufferDepths: new Dictionary<string, int>
|
|
{
|
|
["erp"] = 17,
|
|
["mes"] = 0,
|
|
},
|
|
DeadLetterCount: 5,
|
|
DeployedInstanceCount: 30,
|
|
EnabledInstanceCount: 28,
|
|
DisabledInstanceCount: 2,
|
|
NodeRole: "Active",
|
|
NodeHostname: "scadabridge-site-a-node-a",
|
|
DataConnectionEndpoints: new Dictionary<string, string>
|
|
{
|
|
["opc-main"] = "opc.tcp://opcua:4840",
|
|
},
|
|
DataConnectionTagQuality: new Dictionary<string, TagQualityCounts>
|
|
{
|
|
["opc-main"] = new(Good: 100, Bad: 3, Uncertain: 15),
|
|
},
|
|
ParkedMessageCount: 6,
|
|
ClusterNodes:
|
|
[
|
|
new NodeStatus("scadabridge-site-a-node-a", IsOnline: true, "Active"),
|
|
new NodeStatus("scadabridge-site-a-node-b", IsOnline: false, "Standby"),
|
|
],
|
|
SiteAuditWriteFailures: 7,
|
|
AuditRedactionFailure: 8,
|
|
SiteAuditBacklog: new SiteAuditBacklogSnapshot(
|
|
PendingCount: 42,
|
|
OldestPendingUtc: new DateTime(2026, 7, 22, 8, 0, 0, DateTimeKind.Utc),
|
|
OnDiskBytes: 1_234_567L),
|
|
SiteEventLogWriteFailures: 9L,
|
|
OldestParkedMessageAgeSeconds: 3600.5d)
|
|
{
|
|
ScriptQueueDepth = 11,
|
|
ScriptBusyThreads = 3,
|
|
ScriptOldestBusyAgeSeconds = 12.25d,
|
|
LocalDbReplicationConnected = true,
|
|
LocalDbOplogBacklog = 250L,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Compares two reports member by member. <see cref="SiteHealthReport"/> is a record,
|
|
/// but its dictionary/list members compare by reference under record equality, so
|
|
/// <c>Assert.Equal(a, b)</c> would pass trivially for the scalars and never look
|
|
/// inside the collections — the exact blind spot these goldens exist to close.
|
|
/// </summary>
|
|
private static void AssertHealthReportsEqual(SiteHealthReport expected, SiteHealthReport actual)
|
|
{
|
|
Assert.Equal(expected.SiteId, actual.SiteId);
|
|
Assert.Equal(expected.SequenceNumber, actual.SequenceNumber);
|
|
Assert.Equal(expected.ReportTimestamp, actual.ReportTimestamp);
|
|
Assert.Equal(
|
|
expected.DataConnectionStatuses.OrderBy(kv => kv.Key),
|
|
actual.DataConnectionStatuses.OrderBy(kv => kv.Key));
|
|
Assert.Equal(
|
|
expected.TagResolutionCounts.OrderBy(kv => kv.Key),
|
|
actual.TagResolutionCounts.OrderBy(kv => kv.Key));
|
|
Assert.Equal(expected.ScriptErrorCount, actual.ScriptErrorCount);
|
|
Assert.Equal(expected.AlarmEvaluationErrorCount, actual.AlarmEvaluationErrorCount);
|
|
Assert.Equal(
|
|
expected.StoreAndForwardBufferDepths.OrderBy(kv => kv.Key),
|
|
actual.StoreAndForwardBufferDepths.OrderBy(kv => kv.Key));
|
|
Assert.Equal(expected.DeadLetterCount, actual.DeadLetterCount);
|
|
Assert.Equal(expected.DeployedInstanceCount, actual.DeployedInstanceCount);
|
|
Assert.Equal(expected.EnabledInstanceCount, actual.EnabledInstanceCount);
|
|
Assert.Equal(expected.DisabledInstanceCount, actual.DisabledInstanceCount);
|
|
Assert.Equal(expected.NodeRole, actual.NodeRole);
|
|
Assert.Equal(expected.NodeHostname, actual.NodeHostname);
|
|
Assert.Equal(
|
|
expected.DataConnectionEndpoints?.OrderBy(kv => kv.Key),
|
|
actual.DataConnectionEndpoints?.OrderBy(kv => kv.Key));
|
|
Assert.Equal(
|
|
expected.DataConnectionTagQuality?.OrderBy(kv => kv.Key),
|
|
actual.DataConnectionTagQuality?.OrderBy(kv => kv.Key));
|
|
Assert.Equal(expected.ParkedMessageCount, actual.ParkedMessageCount);
|
|
Assert.Equal(expected.ClusterNodes, actual.ClusterNodes);
|
|
Assert.Equal(expected.SiteAuditWriteFailures, actual.SiteAuditWriteFailures);
|
|
Assert.Equal(expected.AuditRedactionFailure, actual.AuditRedactionFailure);
|
|
Assert.Equal(expected.SiteAuditBacklog, actual.SiteAuditBacklog);
|
|
Assert.Equal(expected.SiteEventLogWriteFailures, actual.SiteEventLogWriteFailures);
|
|
Assert.Equal(expected.OldestParkedMessageAgeSeconds, actual.OldestParkedMessageAgeSeconds);
|
|
Assert.Equal(expected.ScriptQueueDepth, actual.ScriptQueueDepth);
|
|
Assert.Equal(expected.ScriptBusyThreads, actual.ScriptBusyThreads);
|
|
Assert.Equal(expected.ScriptOldestBusyAgeSeconds, actual.ScriptOldestBusyAgeSeconds);
|
|
Assert.Equal(expected.LocalDbReplicationConnected, actual.LocalDbReplicationConnected);
|
|
Assert.Equal(expected.LocalDbOplogBacklog, actual.LocalDbOplogBacklog);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serializes a wire message and parses it back, so every round-trip below crosses a
|
|
/// real protobuf encode/decode rather than only exercising the mapper's object graph.
|
|
/// This is what proves the three collection wrapper messages keep their presence
|
|
/// when empty — an empty message encodes as a tag with zero-length payload, and a
|
|
/// mapper that used a bare <c>repeated</c>/<c>map</c> field instead would decode an
|
|
/// empty collection back as null with no test-visible difference at the object level.
|
|
/// </summary>
|
|
/// <typeparam name="T">Generated protobuf message type.</typeparam>
|
|
/// <param name="message">The message to send through an encode/decode cycle.</param>
|
|
/// <returns>An independent instance parsed from <paramref name="message"/>'s bytes.</returns>
|
|
private static T Wire<T>(T message) where T : IMessage<T>, new() =>
|
|
new MessageParser<T>(() => new T()).ParseFrom(message.ToByteArray());
|
|
|
|
private static ZB.MOM.WW.Audit.AuditEvent NewAuditEvent(string? sourceScript = null) =>
|
|
ScadaBridgeAuditEventFactory.Create(
|
|
channel: AuditChannel.ApiOutbound,
|
|
kind: AuditKind.ApiCallCached,
|
|
status: AuditStatus.Forwarded,
|
|
eventId: Guid.NewGuid(),
|
|
occurredAtUtc: new DateTime(2026, 7, 22, 9, 0, 0, DateTimeKind.Utc),
|
|
target: "ERP.GetOrder",
|
|
sourceSiteId: "site-a",
|
|
sourceNode: "node-a",
|
|
sourceScript: sourceScript);
|
|
|
|
private static SiteCall NewSiteCall() => new()
|
|
{
|
|
TrackedOperationId = TrackedOperationId.New(),
|
|
Channel = "ApiOutbound",
|
|
Target = "ERP.GetOrder",
|
|
SourceSite = "site-a",
|
|
SourceNode = "node-a",
|
|
Status = "Delivered",
|
|
RetryCount = 2,
|
|
LastError = "transient 503",
|
|
HttpStatus = 200,
|
|
CreatedAtUtc = new DateTime(2026, 7, 22, 8, 0, 0, DateTimeKind.Utc),
|
|
UpdatedAtUtc = new DateTime(2026, 7, 22, 8, 5, 0, DateTimeKind.Utc),
|
|
TerminalAtUtc = new DateTime(2026, 7, 22, 8, 10, 0, DateTimeKind.Utc),
|
|
IngestedAtUtc = new DateTime(2026, 7, 22, 8, 10, 1, DateTimeKind.Utc),
|
|
};
|
|
}
|