feat(historian-gateway): GatewayAlarmHistorianWriter — SendEvent + gRPC->outcome mapping

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-26 17:27:03 -04:00
parent 555bd477f1
commit d3081a659f
2 changed files with 326 additions and 0 deletions
@@ -0,0 +1,185 @@
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<GatewayAlarmHistorianWriter>.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<AlarmHistorianEvent>(), TestContext.Current.CancellationToken);
Assert.Empty(outcomes);
}
}