using System.Net; using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; /// /// Gitea #675: the Gateway backend's event-read bounds and direction, offline. /// /// A reverse (newest-first) read is refused before anything is sent — the gateway streams ascending only. /// The open upper bound a start-only forward read uses (DateTime.MaxValue, UTC kind) survives the /// PUBLISHED client package's proto conversion — captured on the wire by an in-process gRPC server — and the /// Windows FILETIME encoding the gateway converts it to. The live AVEVA leg (what the historian does with a /// year-9999 bound) stays open: it needs the VPN. /// /// public sealed class GatewayEventBoundsTests { private static readonly DateTime UtcMax = new(DateTime.MaxValue.Ticks, DateTimeKind.Utc); private static CancellationToken Ct => TestContext.Current.CancellationToken; [Fact] public async Task A_reverse_events_read_is_refused_and_sends_nothing() { var fake = new FakeHistorianGatewayClient(); IHistorianDataSource ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); ds.SupportsReverseEvents.ShouldBeFalse("the server must keep end-only reads forward on the default backend"); await Should.ThrowAsync(() => ds.ReadEventsAsync("Pump1", DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 10, reverse: true, Ct)); fake.ReadEventsCallCount.ShouldBe(0); // Forward through the same overload is the five-argument read, unchanged. await ds.ReadEventsAsync("Pump1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10, reverse: false, Ct); fake.ReadEventsCallCount.ShouldBe(1); } [Fact] public void UtcMax_round_trips_through_the_proto_Timestamp_and_FILETIME_encodings() { var ts = Timestamp.FromDateTime(UtcMax); ts.ToDateTime().ShouldBe(UtcMax); ts.ToDateTime().Kind.ShouldBe(DateTimeKind.Utc); // The gateway hands its bounds to the historian as FILETIME (100 ns since 1601) — in range up to year 30828. var fileTime = ts.ToDateTime().ToFileTimeUtc(); DateTime.FromFileTimeUtc(fileTime).ShouldBe(UtcMax); } [Fact] public async Task A_start_only_bound_of_UtcMax_reaches_the_wire_intact_through_the_published_client() { var capture = new CapturingReadService(); await using var server = await StartServerAsync(capture); using var adapter = HistorianGatewayClientAdapter.Create( new ServerHistorianOptions { Enabled = true, Endpoint = $"http://127.0.0.1:{server.Port}", ApiKey = "histgw_test_key", UseTls = false, }, NullLoggerFactory.Instance); IHistorianDataSource ds = new GatewayHistorianDataSource(adapter, NullLogger.Instance); var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var r = await ds.ReadEventsAsync("Pump1", start, UtcMax, 5, Ct); r.Events.ShouldBeEmpty(); capture.Request.ShouldNotBeNull(); capture.Request!.StartTime.ToDateTime().ShouldBe(start); capture.Request.EndTime.ToDateTime().ShouldBe(UtcMax); DateTime.FromFileTimeUtc(capture.Request.EndTime.ToDateTime().ToFileTimeUtc()).ShouldBe(UtcMax); } private sealed class CapturingReadService : HistorianRead.HistorianReadBase { public ReadEventsRequest? Request { get; private set; } public override Task ReadEvents( ReadEventsRequest request, IServerStreamWriter responseStream, ServerCallContext context) { Request = request; return Task.CompletedTask; } } private sealed class Server(WebApplication app, int port) : IAsyncDisposable { public int Port { get; } = port; public async ValueTask DisposeAsync() { await app.StopAsync(); await app.DisposeAsync(); } } private static async Task StartServerAsync(CapturingReadService service) { var builder = WebApplication.CreateSlimBuilder(); builder.Logging.ClearProviders(); builder.WebHost.ConfigureKestrel(k => k.Listen(IPAddress.Loopback, 0, o => o.Protocols = HttpProtocols.Http2)); builder.Services.AddGrpc(); builder.Services.AddSingleton(service); var app = builder.Build(); app.MapGrpcService(); await app.StartAsync(); var port = new Uri(app.Urls.First()).Port; return new Server(app, port); } }