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(_ => { });
}
}