using Akka.Actor; using Akka.TestKit; using Akka.TestKit.Xunit2; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// /// Behaviour of that does not need a real gRPC pipeline: /// the readiness gate, the fire-and-forget heartbeat contract, the ingest-timeout status /// mapping, and the shared audit-ingest timeout constant. The auth interceptor + real transport /// are proven separately in CentralControlEndToEndTests (Host.Tests). /// public class CentralControlGrpcServiceTests : TestKit { private static ServerCallContext NewContext(CancellationToken ct = default) { var context = Substitute.For(); context.CancellationToken.Returns(ct); return context; } private CentralControlGrpcService CreateService(CommunicationOptions? options = null) => new( NullLogger.Instance, Options.Create(options ?? new CommunicationOptions())); [Fact] public async Task BeforeSetReady_AUnaryCall_IsUnavailable() { // Nothing was dispatched, so Unavailable is the right status — it tells a site transport // the call never ran and a cross-node retry is safe. var service = CreateService(); var ex = await Assert.ThrowsAsync( () => service.SubmitNotification(new NotificationSubmitDto(), NewContext())); Assert.Equal(StatusCode.Unavailable, ex.StatusCode); } [Fact] public async Task BeforeSetReady_ANonEmptyIngestBatch_IsUnavailable() { var service = CreateService(); var batch = new AuditEventBatch(); batch.Events.Add(NewAuditDto()); var ex = await Assert.ThrowsAsync( () => service.IngestAuditEvents(batch, NewContext())); Assert.Equal(StatusCode.Unavailable, ex.StatusCode); } [Fact] public async Task AfterSetReady_AUnaryCall_ReachesTheActor() { var stub = Sys.ActorOf(Props.Create(() => new StubActor())); var service = CreateService(); service.SetReady(stub); var ack = await service.SubmitNotification(NewNotificationDto(), NewContext()); Assert.True(ack.Accepted); } [Fact] public async Task Heartbeat_BeforeSetReady_SucceedsAndIsDropped() { // Fire-and-forget end-to-end: a heartbeat must never fault the site's timer, so even // with no actor wired the call returns OK rather than Unavailable. var service = CreateService(); var reply = await service.Heartbeat(NewHeartbeatDto(), NewContext()); Assert.NotNull(reply); } [Fact] public async Task Heartbeat_AfterSetReady_TellsTheActor_AndNeverAsks() { // A black-hole actor that never replies would hang an Ask forever; the heartbeat still // returns immediately, proving it is a Tell, not an Ask. var blackHole = Sys.ActorOf(Props.Create(() => new NeverRepliesActor())); var service = CreateService(); service.SetReady(blackHole); var reply = await service.Heartbeat(NewHeartbeatDto(), NewContext()); Assert.NotNull(reply); } [Fact] public async Task WhenTheActorNeverReplies_TheCall_IsDeadlineExceeded_NotUnavailable() { // The message WAS delivered (Unavailable would wrongly invite a duplicate retry on the // peer node); a timeout is DeadlineExceeded, which callers never cross-node-retry. var blackHole = Sys.ActorOf(Props.Create(() => new NeverRepliesActor())); var service = CreateService(new CommunicationOptions { NotificationForwardTimeout = TimeSpan.FromMilliseconds(200), }); service.SetReady(blackHole); var ex = await Assert.ThrowsAsync( () => service.SubmitNotification(NewNotificationDto(), NewContext())); Assert.Equal(StatusCode.DeadlineExceeded, ex.StatusCode); } [Fact] public async Task EmptyIngestBatch_ShortCircuits_EvenBeforeSetReady() { // An empty batch is a no-op the actor need never see; it must not depend on readiness. var service = CreateService(); var ack = await service.IngestAuditEvents(new AuditEventBatch(), NewContext()); Assert.Empty(ack.AcceptedEventIds); } [Fact] public void TheAuditIngestTimeout_IsTheOneSharedConstant() { // The plan calls SiteStreamGrpcServer.AuditIngestAskTimeout "one source of truth" shared // between the two audit-ingest transports; the central service must not re-declare 30s. Assert.Equal(TimeSpan.FromSeconds(30), SiteStreamGrpcServer.AuditIngestAskTimeout); } // The mapper reads SiteEnqueuedAt unconditionally, so a DTO that reaches mapping must carry // a timestamp. Only DTOs that get past the readiness/auth gate map, so the negative tests // above can pass a bare DTO. private static NotificationSubmitDto NewNotificationDto() => new() { NotificationId = Guid.NewGuid().ToString(), ListName = "ops", SiteEnqueuedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime( DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)), }; private static HeartbeatDto NewHeartbeatDto() => new() { SiteId = "site-a", NodeHostname = "node-a", IsActive = true, Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime( DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)), }; private static AuditEventDto NewAuditDto() => new() { EventId = Guid.NewGuid().ToString(), OccurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime( DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)), Channel = "ApiOutbound", Kind = "ApiCall", Status = "Delivered", SourceSiteId = "site-a", }; private sealed class StubActor : ReceiveActor { public StubActor() { Receive(msg => Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null))); } } /// Swallows every message and never replies, so an Ask against it times out. private sealed class NeverRepliesActor : ReceiveActor { public NeverRepliesActor() => ReceiveAny(_ => { }); } }