Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorAuditTests.cs
T
Joseph Doherty 63c16d6912 refactor(comm): retire ClusterClient naming after the gRPC cutover
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.
2026-07-27 15:40:01 -04:00

152 lines
5.7 KiB
C#

using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
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.Actors;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Tests for the Audit Log (#23) site→central ingest routing on
/// <see cref="CentralCommunicationActor"/>. A site delivers
/// <see cref="IngestAuditEventsCommand"/> / <see cref="IngestCachedTelemetryCommand"/>
/// to the actor, which forwards to the registered
/// <c>AuditLogIngestActor</c> proxy and routes the reply back to the site.
/// Mirrors the NotificationSubmit / RegisterNotificationOutbox pattern.
/// </summary>
public class CentralCommunicationActorAuditTests : TestKit
{
public CentralCommunicationActorAuditTests() : base(@"akka.loglevel = DEBUG") { }
private IActorRef CreateActor(TimeSpan? auditIngestAskTimeout = null)
{
var mockRepo = Substitute.For<ISiteRepository>();
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>());
var services = new ServiceCollection();
services.AddScoped(_ => mockRepo);
var sp = services.BuildServiceProvider();
var transport = Substitute.For<ISiteCommandTransport>();
return Sys.ActorOf(Props.Create(() =>
new CentralCommunicationActor(sp, transport, auditIngestAskTimeout)));
}
// C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
private static AuditEvent SampleAuditEvent() =>
ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered);
private static SiteCall SampleSiteCall() => new()
{
TrackedOperationId = TrackedOperationId.New(),
Channel = "OutboundApi",
Target = "ExternalSystemA",
SourceSite = "site1",
Status = "Delivered",
RetryCount = 0,
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow,
IngestedAtUtc = DateTime.UtcNow,
};
[Fact]
public void IngestAuditEventsCommand_WithRegisteredProxy_ForwardsAndRoutesReplyToSender()
{
var actor = CreateActor();
var auditProbe = CreateTestProbe();
actor.Tell(new RegisterAuditIngest(auditProbe.Ref));
var evt = SampleAuditEvent();
var cmd = new IngestAuditEventsCommand(new[] { evt });
actor.Tell(cmd);
// The audit-ingest proxy receives the command, with the original site
// sender preserved (Forward semantics).
auditProbe.ExpectMsg(cmd);
// When the proxy replies, the actor routes it back to the original sender.
var reply = new IngestAuditEventsReply(new[] { evt.EventId });
auditProbe.Reply(reply);
var received = ExpectMsg<IngestAuditEventsReply>();
Assert.Equal(new[] { evt.EventId }, received.AcceptedEventIds);
}
[Fact]
public void IngestAuditEventsCommand_WithNoProxyRegistered_RepliesEmptyAcceptedEventIds()
{
var actor = CreateActor();
actor.Tell(new IngestAuditEventsCommand(new[] { SampleAuditEvent() }));
var reply = ExpectMsg<IngestAuditEventsReply>();
Assert.Empty(reply.AcceptedEventIds);
}
[Fact]
public void IngestAuditEventsCommand_WhenProxyNeverReplies_PipesStatusFailureToSender()
{
// A short test-only Ask timeout (constructor seam) keeps the test fast —
// production uses the 30 s default.
var actor = CreateActor(auditIngestAskTimeout: TimeSpan.FromMilliseconds(200));
var auditProbe = CreateTestProbe();
actor.Tell(new RegisterAuditIngest(auditProbe.Ref));
var cmd = new IngestAuditEventsCommand(new[] { SampleAuditEvent() });
actor.Tell(cmd);
// The proxy receives the command but deliberately never replies.
auditProbe.ExpectMsg(cmd);
// The Ask times out; PipeTo forwards the faulted task as a Status.Failure
// to the original sender. This is the real transient signal the site's
// own Ask faults on — it is NOT swallowed into an empty ack.
var failure = ExpectMsg<Status.Failure>();
Assert.IsType<AskTimeoutException>(failure.Cause);
}
[Fact]
public void IngestCachedTelemetryCommand_WithRegisteredProxy_ForwardsAndRoutesReplyToSender()
{
var actor = CreateActor();
var auditProbe = CreateTestProbe();
actor.Tell(new RegisterAuditIngest(auditProbe.Ref));
var entry = new CachedTelemetryEntry(SampleAuditEvent(), SampleSiteCall());
var cmd = new IngestCachedTelemetryCommand(new[] { entry });
actor.Tell(cmd);
auditProbe.ExpectMsg(cmd);
var reply = new IngestCachedTelemetryReply(new[] { entry.Audit.EventId });
auditProbe.Reply(reply);
var received = ExpectMsg<IngestCachedTelemetryReply>();
Assert.Equal(new[] { entry.Audit.EventId }, received.AcceptedEventIds);
}
[Fact]
public void IngestCachedTelemetryCommand_WithNoProxyRegistered_RepliesEmptyAcceptedEventIds()
{
var actor = CreateActor();
var entry = new CachedTelemetryEntry(SampleAuditEvent(), SampleSiteCall());
actor.Tell(new IngestCachedTelemetryCommand(new[] { entry }));
var reply = ExpectMsg<IngestCachedTelemetryReply>();
Assert.Empty(reply.AcceptedEventIds);
}
}