195 lines
7.2 KiB
C#
195 lines
7.2 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
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;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
|
|
|
/// <summary>
|
|
/// Audit Log (#23) site→central ingest routing on <see cref="CentralControlGrpcService"/>.
|
|
/// <para>
|
|
/// The service Asks the <c>audit-log-ingest</c> singleton proxy DIRECTLY. It used to relay
|
|
/// through <see cref="CentralCommunicationActor"/>, which re-Asked the same proxy with the same
|
|
/// 30 s <see cref="SiteStreamGrpcServer.AuditIngestAskTimeout"/> — a second hop whose inner Ask
|
|
/// expired at the same instant as the outer one, so it could only add latency. These tests pin
|
|
/// the direct dispatch, the wiring-race reply, and the fact that the relay is gone.
|
|
/// </para>
|
|
/// </summary>
|
|
public class CentralControlGrpcServiceAuditIngestTests : TestKit
|
|
{
|
|
private static ServerCallContext NewContext(CancellationToken ct = default)
|
|
{
|
|
var context = Substitute.For<ServerCallContext>();
|
|
context.CancellationToken.Returns(ct);
|
|
return context;
|
|
}
|
|
|
|
private static CentralControlGrpcService CreateService() => new(
|
|
NullLogger<CentralControlGrpcService>.Instance,
|
|
Options.Create(new CommunicationOptions()));
|
|
|
|
[Fact]
|
|
public async Task IngestAuditEvents_AsksTheIngestProxy_AndNeverTheCommunicationActor()
|
|
{
|
|
var ingest = CreateTestProbe();
|
|
var control = CreateTestProbe();
|
|
var service = CreateService();
|
|
service.SetReady(control.Ref);
|
|
service.SetAuditIngestActor(ingest.Ref);
|
|
|
|
var evt = SampleAuditEvent();
|
|
var batch = new AuditEventBatch();
|
|
batch.Events.Add(AuditEventDtoMapper.ToDto(evt));
|
|
|
|
var call = service.IngestAuditEvents(batch, NewContext());
|
|
|
|
var received = ingest.ExpectMsg<IngestAuditEventsCommand>();
|
|
Assert.Equal(evt.EventId, Assert.Single(received.Events).EventId);
|
|
ingest.Reply(new IngestAuditEventsReply(new[] { evt.EventId }));
|
|
|
|
var ack = await call;
|
|
Assert.Equal(evt.EventId.ToString(), Assert.Single(ack.AcceptedEventIds));
|
|
|
|
// The old relay hop is gone: the control-plane actor sees nothing at all.
|
|
control.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IngestCachedTelemetry_AsksTheIngestProxy_AndNeverTheCommunicationActor()
|
|
{
|
|
var ingest = CreateTestProbe();
|
|
var control = CreateTestProbe();
|
|
var service = CreateService();
|
|
service.SetReady(control.Ref);
|
|
service.SetAuditIngestActor(ingest.Ref);
|
|
|
|
var evt = SampleAuditEvent();
|
|
var batch = new CachedTelemetryBatch();
|
|
batch.Packets.Add(new CachedTelemetryPacket
|
|
{
|
|
AuditEvent = AuditEventDtoMapper.ToDto(evt),
|
|
Operational = SiteCallDtoMapper.ToDto(SampleSiteCall()),
|
|
});
|
|
|
|
var call = service.IngestCachedTelemetry(batch, NewContext());
|
|
|
|
var received = ingest.ExpectMsg<IngestCachedTelemetryCommand>();
|
|
Assert.Single(received.Entries);
|
|
ingest.Reply(new IngestCachedTelemetryReply(new[] { evt.EventId }));
|
|
|
|
var ack = await call;
|
|
Assert.Equal(evt.EventId.ToString(), Assert.Single(ack.AcceptedEventIds));
|
|
control.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IngestAuditEvents_BeforeTheIngestProxyIsWired_ReturnsAnEmptyAck()
|
|
{
|
|
// The singleton starts moments after SetReady, so this window is real. An empty ack
|
|
// (NOT a fault, NOT Unavailable) leaves the site's rows Pending for the next drain —
|
|
// byte-for-byte what the removed relay replied when its proxy was still null.
|
|
var service = CreateService();
|
|
service.SetReady(CreateTestProbe().Ref);
|
|
|
|
var batch = new AuditEventBatch();
|
|
batch.Events.Add(AuditEventDtoMapper.ToDto(SampleAuditEvent()));
|
|
|
|
var ack = await service.IngestAuditEvents(batch, NewContext());
|
|
|
|
Assert.Empty(ack.AcceptedEventIds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IngestCachedTelemetry_BeforeTheIngestProxyIsWired_ReturnsAnEmptyAck()
|
|
{
|
|
var service = CreateService();
|
|
service.SetReady(CreateTestProbe().Ref);
|
|
|
|
var batch = new CachedTelemetryBatch();
|
|
batch.Packets.Add(new CachedTelemetryPacket
|
|
{
|
|
AuditEvent = AuditEventDtoMapper.ToDto(SampleAuditEvent()),
|
|
Operational = SiteCallDtoMapper.ToDto(SampleSiteCall()),
|
|
});
|
|
|
|
var ack = await service.IngestCachedTelemetry(batch, NewContext());
|
|
|
|
Assert.Empty(ack.AcceptedEventIds);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetAuditIngestActor_IsIndependentOfSetReady()
|
|
{
|
|
var service = CreateService();
|
|
Assert.False(service.IsAuditIngestBound);
|
|
|
|
service.SetAuditIngestActor(CreateTestProbe().Ref);
|
|
|
|
Assert.True(service.IsAuditIngestBound);
|
|
Assert.False(service.IsReady);
|
|
}
|
|
|
|
[Fact]
|
|
public void CentralCommunicationActor_NoLongerRelaysIngestCommands()
|
|
{
|
|
// Regression pin for the removed hop: the actor has no ingest receive at all, so an
|
|
// ingest command reaching it is an unhandled message with no reply — not a silent
|
|
// second path that could drift from the direct one.
|
|
var actor = CreateCentralCommunicationActor();
|
|
|
|
actor.Tell(new IngestAuditEventsCommand(new[] { SampleAuditEvent() }), TestActor);
|
|
actor.Tell(
|
|
new IngestCachedTelemetryCommand(
|
|
new[] { new CachedTelemetryEntry(SampleAuditEvent(), SampleSiteCall()) }),
|
|
TestActor);
|
|
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
}
|
|
|
|
private IActorRef CreateCentralCommunicationActor()
|
|
{
|
|
var siteRepo = Substitute.For<ISiteRepository>();
|
|
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new List<Commons.Entities.Sites.Site>());
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddScoped(_ => siteRepo);
|
|
var sp = services.BuildServiceProvider();
|
|
|
|
var transport = Substitute.For<ISiteCommandTransport>();
|
|
return Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport)));
|
|
}
|
|
|
|
private static AuditEvent SampleAuditEvent() =>
|
|
ScadaBridgeAuditEventFactory.Create(
|
|
channel: AuditChannel.ApiOutbound,
|
|
kind: AuditKind.ApiCall,
|
|
status: AuditStatus.Delivered,
|
|
sourceSiteId: "site-a");
|
|
|
|
private static SiteCall SampleSiteCall() => new()
|
|
{
|
|
TrackedOperationId = TrackedOperationId.New(),
|
|
Channel = "OutboundApi",
|
|
Target = "ExternalSystemA",
|
|
SourceSite = "site-a",
|
|
Status = "Delivered",
|
|
RetryCount = 0,
|
|
CreatedAtUtc = DateTime.UtcNow,
|
|
UpdatedAtUtc = DateTime.UtcNow,
|
|
IngestedAtUtc = DateTime.UtcNow,
|
|
};
|
|
}
|