7fd5cb2b56
ClusterClient→gRPC migration Phase 4 (docs/plans/2026-07-22-clusterclient-to-grpc-plan.md). Phases 2/3 proved both directions on gRPC; this removes the Akka transport underneath. Deleted: - AkkaCentralTransport, AkkaSiteTransport (+ their dedicated tests) - ISiteClientFactory + DefaultSiteClientFactory; CentralCommunicationActor legacy ctor + SelectTransport (Host now builds GrpcSiteTransport and injects it) - ClusterClient creation + both ClusterClientReceptionist.RegisterService calls in AkkaHostedService; the RegisterCentralClient message + receive block - CommunicationOptions.CentralContactPoints; the CentralTransport/SiteTransport coexistence flags; the CentralTransportMode/SiteTransportKind enums gRPC is now the only site↔central transport (site→central CentralControlService via GrpcCentralTransport; central→site SiteCommandService via GrpcSiteTransport), both built unconditionally by the Host. NoOpCentralTransport is the fail-loud null-default so TestKit command-dispatch suites still construct the site actor without a wired transport; production always injects GrpcCentralTransport. Config: CentralGrpcEndpoints is now unconditional — CommunicationOptionsValidator rejects blank entries (role-agnostic), and StartupValidator requires a Site node to list >=1 endpoint (fail-fast, mirrors GrpcPsk). Rig configs moved CentralContactPoints -> CentralGrpcEndpoints (docker x6, docker-env2 x2, Host default, deploy/wonder-app-vd03). Kept Akka.Cluster.Tools (ClusterSingleton still used). Tests: build 0/0; Communication.Tests 640, Host.Tests 421 green. Removed the ClusterClient.Send per-site-routing tests (covered by the transport suites), swapped the ISiteClientFactory-based ctors to a substitute ISiteCommandTransport, converted the audit-push integration relay to an in-process bridge transport. Docs: Component-Communication/Host/StoreAndForward, components/Communication, topology-guide, grpc_streams (SUPERSEDED note), the frame-size known-issue (retired amendment), and CLAUDE.md transport decisions. Not included: the dead IntegrationCallRequest path (#32) is a separate user-owned behavioral decision — SiteEnvelope routing is transport-agnostic so it still compiles.
152 lines
5.8 KiB
C#
152 lines
5.8 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 ClusterClient ingest routing on
|
|
/// <see cref="CentralCommunicationActor"/>. A site ClusterClient delivers
|
|
/// <see cref="IngestAuditEventsCommand"/> / <see cref="IngestCachedTelemetryCommand"/>
|
|
/// to the receptionist-registered 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);
|
|
}
|
|
}
|