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; /// /// Audit Log (#23) site→central ingest routing on . /// /// The service Asks the audit-log-ingest singleton proxy DIRECTLY. It used to relay /// through , which re-Asked the same proxy with the same /// 30 s — 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. /// /// public class CentralControlGrpcServiceAuditIngestTests : TestKit { private static ServerCallContext NewContext(CancellationToken ct = default) { var context = Substitute.For(); context.CancellationToken.Returns(ct); return context; } private static CentralControlGrpcService CreateService() => new( NullLogger.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(); 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(); 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(); siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(new List()); var services = new ServiceCollection(); services.AddScoped(_ => siteRepo); var sp = services.BuildServiceProvider(); var transport = Substitute.For(); 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, }; }