using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Xunit; using ZB.MOM.WW.HistorianGateway.Client; using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; public sealed class GatewayAlarmHistorianWriterTests { private static AlarmHistorianEvent Evt(string id) => new( id, "Area/Pump", "N", "LimitAlarm", AlarmSeverity.High, "Activated", "m", "u", null, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); private static GatewayAlarmHistorianWriter Writer(FakeHistorianGatewayClient fake) => new(fake, NullLogger.Instance); [Fact] public async Task All_acked_when_SendEvent_succeeds() { var fake = new FakeHistorianGatewayClient { SendEventResult = new WriteAck { Success = true } }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A"), Evt("B") }, TestContext.Current.CancellationToken); Assert.All(outcomes, o => Assert.Equal(HistorianWriteOutcome.Ack, o)); // One SendEvent per event so a single poison event cannot fail the whole batch. Assert.Equal(2, fake.SendEventCallCount); Assert.Equal(2, outcomes.Count); } [Fact] public async Task Queued_ack_is_treated_as_Ack() { // A store-forward-queued send is durably accepted by the gateway → do not re-drain. var fake = new FakeHistorianGatewayClient { SendEventResult = new WriteAck { Success = false, Queued = true } }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A") }, TestContext.Current.CancellationToken); Assert.Equal(HistorianWriteOutcome.Ack, outcomes[0]); } // ---- Typed published-client exception hierarchy (production reality) ------------------------ [Fact] public async Task Typed_Unavailable_is_RetryPlease() { var fake = new FakeHistorianGatewayClient { SendEventThrows = new HistorianGatewayUnavailableException("down"), }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A") }, TestContext.Current.CancellationToken); Assert.Equal(HistorianWriteOutcome.RetryPlease, outcomes[0]); } [Fact] public async Task Typed_Authentication_is_RetryPlease() { var fake = new FakeHistorianGatewayClient { SendEventThrows = new HistorianGatewayAuthenticationException("bad key"), }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A") }, TestContext.Current.CancellationToken); Assert.Equal(HistorianWriteOutcome.RetryPlease, outcomes[0]); } [Fact] public async Task Typed_Authorization_is_RetryPlease() { var fake = new FakeHistorianGatewayClient { SendEventThrows = new HistorianGatewayAuthorizationException("no scope"), }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A") }, TestContext.Current.CancellationToken); Assert.Equal(HistorianWriteOutcome.RetryPlease, outcomes[0]); } [Fact] public async Task Base_typed_exception_with_inner_permanent_status_is_PermanentFail() { // The published client maps a permanent gRPC code (InvalidArgument) onto the base // HistorianGatewayException carrying the original RpcException as InnerException. var inner = new RpcException(new Status(StatusCode.InvalidArgument, "malformed")); var fake = new FakeHistorianGatewayClient { SendEventThrows = new HistorianGatewayException("malformed", inner), }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A") }, TestContext.Current.CancellationToken); Assert.Equal(HistorianWriteOutcome.PermanentFail, outcomes[0]); } [Fact] public async Task Bare_base_typed_exception_is_PermanentFail() { // No classifiable inner status → default to PermanentFail to avoid infinite drain loops. var fake = new FakeHistorianGatewayClient { SendEventThrows = new HistorianGatewayException("unclassifiable"), }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A") }, TestContext.Current.CancellationToken); Assert.Equal(HistorianWriteOutcome.PermanentFail, outcomes[0]); } // ---- Defensive raw RpcException path -------------------------------------------------------- [Fact] public async Task Raw_Unavailable_is_RetryPlease() { var fake = new FakeHistorianGatewayClient { SendEventThrows = new RpcException(new Status(StatusCode.Unavailable, "down")), }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A") }, TestContext.Current.CancellationToken); Assert.Equal(HistorianWriteOutcome.RetryPlease, outcomes[0]); } [Theory] [InlineData(StatusCode.DeadlineExceeded)] [InlineData(StatusCode.ResourceExhausted)] [InlineData(StatusCode.Aborted)] [InlineData(StatusCode.Internal)] [InlineData(StatusCode.Unauthenticated)] [InlineData(StatusCode.PermissionDenied)] public async Task Raw_transient_or_auth_status_is_RetryPlease(StatusCode code) { var fake = new FakeHistorianGatewayClient { SendEventThrows = new RpcException(new Status(code, "x")), }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A") }, TestContext.Current.CancellationToken); Assert.Equal(HistorianWriteOutcome.RetryPlease, outcomes[0]); } [Theory] [InlineData(StatusCode.InvalidArgument)] [InlineData(StatusCode.FailedPrecondition)] [InlineData(StatusCode.OutOfRange)] [InlineData(StatusCode.Unimplemented)] public async Task Raw_permanent_status_is_PermanentFail(StatusCode code) { var fake = new FakeHistorianGatewayClient { SendEventThrows = new RpcException(new Status(code, "x")), }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A") }, TestContext.Current.CancellationToken); Assert.Equal(HistorianWriteOutcome.PermanentFail, outcomes[0]); } [Fact] public async Task Unknown_exception_is_PermanentFail() { var fake = new FakeHistorianGatewayClient { SendEventThrows = new InvalidOperationException("boom") }; var outcomes = await Writer(fake).WriteBatchAsync(new[] { Evt("A") }, TestContext.Current.CancellationToken); Assert.Equal(HistorianWriteOutcome.PermanentFail, outcomes[0]); } [Fact] public async Task Empty_batch_returns_empty() { var outcomes = await Writer(new FakeHistorianGatewayClient()) .WriteBatchAsync(Array.Empty(), TestContext.Current.CancellationToken); Assert.Empty(outcomes); } }