63c16d6912
Phase 4 of the ClusterClient -> gRPC migration deleted the Akka transports but left the naming behind: `ClusterClientSiteAuditClient` was transport- agnostic and worked unchanged, so it survived the deletion under a name that now describes a transport the repo no longer has. Same for a scatter of doc-comments still framing gRPC as "the new transport" beside an Akka one that is gone. Renames it to `SiteCommunicationAuditClient` (and its test file) and rewrites the stale comments to describe the single transport that exists. Also tightens CLAUDE.md: drops the self-describing directory listing and the 27-component enumeration in favour of the non-obvious parts only. Behaviour-neutral: names and prose only. Recorded as the Phase 4 follow-up in docs/plans/2026-07-22-clusterclient-to-grpc-plan.md.
167 lines
7.0 KiB
C#
167 lines
7.0 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.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.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Actors;
|
|
|
|
/// <summary>
|
|
/// T1A.3: the site communication actor delegates each of the seven site→central sends to the
|
|
/// injected <see cref="ICentralTransport"/>, preserving the current <c>Sender</c> as the reply
|
|
/// target — and a transport failure surfaces to that sender exactly as the S&F / audit / health
|
|
/// layers already expect, while a heartbeat transport fault never faults the actor.
|
|
/// </summary>
|
|
public class SiteCommunicationActorTransportTests : TestKit
|
|
{
|
|
private readonly CommunicationOptions _options = new();
|
|
|
|
private (IActorRef actor, ICentralTransport transport) NewActor()
|
|
{
|
|
var transport = Substitute.For<ICentralTransport>();
|
|
var dmProbe = CreateTestProbe();
|
|
var actor = Sys.ActorOf(Props.Create(() =>
|
|
new SiteCommunicationActor("site1", _options, dmProbe.Ref, () => false, null, transport)));
|
|
return (actor, transport);
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationSubmit_DelegatesToTransport_WithSenderAsReplyTo()
|
|
{
|
|
var (actor, transport) = NewActor();
|
|
var submit = new NotificationSubmit(
|
|
"notif-1", "Operators", "Subj", "Body", "site1", "inst1", "alarmScript", DateTimeOffset.UtcNow);
|
|
|
|
actor.Tell(submit, TestActor);
|
|
|
|
AwaitAssert(() => transport.Received(1).SubmitNotification(
|
|
Arg.Is<NotificationSubmit>(m => m.NotificationId == "notif-1"), Arg.Is(TestActor)));
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationStatusQuery_DelegatesToTransport_WithSenderAsReplyTo()
|
|
{
|
|
var (actor, transport) = NewActor();
|
|
|
|
actor.Tell(new NotificationStatusQuery("corr-1", "notif-1"), TestActor);
|
|
|
|
AwaitAssert(() => transport.Received(1).QueryNotificationStatus(
|
|
Arg.Is<NotificationStatusQuery>(m => m.NotificationId == "notif-1"), Arg.Is(TestActor)));
|
|
}
|
|
|
|
[Fact]
|
|
public void IngestAuditEvents_DelegatesToTransport_WithSenderAsReplyTo()
|
|
{
|
|
var (actor, transport) = NewActor();
|
|
|
|
actor.Tell(new IngestAuditEventsCommand(new List<AuditEvent>()), TestActor);
|
|
|
|
AwaitAssert(() => transport.Received(1).IngestAuditEvents(Arg.Any<IngestAuditEventsCommand>(), Arg.Is(TestActor)));
|
|
}
|
|
|
|
[Fact]
|
|
public void IngestCachedTelemetry_DelegatesToTransport_WithSenderAsReplyTo()
|
|
{
|
|
var (actor, transport) = NewActor();
|
|
|
|
actor.Tell(new IngestCachedTelemetryCommand(new List<CachedTelemetryEntry>()), TestActor);
|
|
|
|
AwaitAssert(() => transport.Received(1).IngestCachedTelemetry(Arg.Any<IngestCachedTelemetryCommand>(), Arg.Is(TestActor)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReconcileSite_DelegatesToTransport_WithSenderAsReplyTo()
|
|
{
|
|
var (actor, transport) = NewActor();
|
|
|
|
actor.Tell(
|
|
new ReconcileSiteRequest("site1", "node-a", new Dictionary<string, string>()), TestActor);
|
|
|
|
AwaitAssert(() => transport.Received(1).ReconcileSite(
|
|
Arg.Is<ReconcileSiteRequest>(m => m.NodeId == "node-a"), Arg.Is(TestActor)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReportSiteHealth_DelegatesToTransport_WithSenderAsReplyTo()
|
|
{
|
|
var (actor, transport) = NewActor();
|
|
var report = MinimalHealthReport(sequence: 7);
|
|
|
|
actor.Tell(report, TestActor);
|
|
|
|
AwaitAssert(() => transport.Received(1).ReportSiteHealth(
|
|
Arg.Is<SiteHealthReport>(m => m.SequenceNumber == 7), Arg.Is(TestActor)));
|
|
}
|
|
|
|
[Fact]
|
|
public void TransportFailureReply_RoutesBackToTheWaitingSender()
|
|
{
|
|
// The seam preserves the reply-routing the S&F layer depends on: when the transport
|
|
// answers the captured replyTo with a Status.Failure (its transient-failure signal on a
|
|
// non-OK status), that failure reaches the original sender — here the test actor — so the
|
|
// waiting Ask faults, exactly as it did on the removed ClusterClient path.
|
|
var transport = Substitute.For<ICentralTransport>();
|
|
transport
|
|
.When(t => t.SubmitNotification(Arg.Any<NotificationSubmit>(), Arg.Any<IActorRef>()))
|
|
.Do(ci => ci.Arg<IActorRef>().Tell(
|
|
new Status.Failure(new InvalidOperationException("central unavailable"))));
|
|
|
|
var dmProbe = CreateTestProbe();
|
|
var actor = Sys.ActorOf(Props.Create(() =>
|
|
new SiteCommunicationActor("site1", _options, dmProbe.Ref, () => false, null, transport)));
|
|
|
|
actor.Tell(new NotificationSubmit(
|
|
"notif-x", "Operators", "S", "B", "site1", null, null, DateTimeOffset.UtcNow), TestActor);
|
|
|
|
var failure = ExpectMsg<Status.Failure>();
|
|
Assert.IsType<InvalidOperationException>(failure.Cause);
|
|
}
|
|
|
|
[Fact]
|
|
public void HeartbeatTransportThrow_DoesNotFaultTheActor()
|
|
{
|
|
// A transport whose SendHeartbeat throws must not fault the actor — heartbeats are
|
|
// fire-and-forget and their failure is swallowed. Prove the actor still serves messages
|
|
// after a heartbeat that threw.
|
|
var transport = Substitute.For<ICentralTransport>();
|
|
transport
|
|
.When(t => t.SendHeartbeat(Arg.Any<HeartbeatMessage>(), Arg.Any<IActorRef>()))
|
|
.Do(_ => throw new InvalidOperationException("boom"));
|
|
|
|
var dmProbe = CreateTestProbe();
|
|
var actor = Sys.ActorOf(Props.Create(() =>
|
|
new SiteCommunicationActor(
|
|
"site1",
|
|
new CommunicationOptions { ApplicationHeartbeatInterval = TimeSpan.FromMilliseconds(50) },
|
|
dmProbe.Ref, () => true, null, transport)));
|
|
|
|
// Let the heartbeat timer fire a few times (each throws inside the transport).
|
|
Thread.Sleep(250);
|
|
|
|
// The actor is still alive and delegating: a subsequent send is handled normally.
|
|
actor.Tell(new NotificationSubmit(
|
|
"after-heartbeat", "Operators", "S", "B", "site1", null, null, DateTimeOffset.UtcNow), TestActor);
|
|
AwaitAssert(() => transport.Received(1).SubmitNotification(
|
|
Arg.Is<NotificationSubmit>(m => m.NotificationId == "after-heartbeat"), Arg.Is(TestActor)));
|
|
}
|
|
|
|
private static SiteHealthReport MinimalHealthReport(long sequence) => new(
|
|
SiteId: "site1",
|
|
SequenceNumber: sequence,
|
|
ReportTimestamp: DateTimeOffset.UtcNow,
|
|
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);
|
|
}
|