From b32436902a6752feafb2999720a3256fa1ff731b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 19:01:36 -0400 Subject: [PATCH] test(historian-gateway): env-gated live validation vs wonder-sql-vd03 (read/write/alarm round-trips) Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../Live/GatewayLiveFixture.cs | 166 ++++++++++++++ .../Live/GatewayLiveIntegrationTests.cs | 204 ++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs new file mode 100644 index 00000000..eda48b62 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs @@ -0,0 +1,166 @@ +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Live; + +/// +/// Env-gated fixture for the live validation suite that exercises the gateway-backed +/// read / write / alarm paths against a real, running ZB.MOM.WW.HistorianGateway +/// sidecar (typically wonder-sql-vd03 on the corporate VPN). Mirrors the +/// HistorianGateway repo's GatewayIntegrationFixture env-gating convention and this +/// repo's OpcPlcFixture reachability-probe pattern: the fixture is cheap at +/// construction (reads env vars + one short TCP probe) and records a +/// so tests call Assert.Skip(SkipReason) and report as Skipped (not Failed) +/// when the suite is not configured or the gateway is unreachable. +/// +/// +/// +/// Env vars consumed (skip-gate + config source): +/// +/// HISTGW_GATEWAY_ENDPOINT — absolute gateway URI, e.g. https://wonder-sql-vd03:5222. Required; absent ⇒ all tests skip. +/// HISTGW_GATEWAY_APIKEY — the histgw_<id>_<secret> key (must carry historian:read + historian:write scopes). Required; absent ⇒ all tests skip. +/// HISTGW_TEST_TAG — an existing Galaxy / historian tag for the read round-trip. +/// HISTGW_WRITE_SANDBOX_TAG — a Float sandbox tag the write round-trip may EnsureTags + write (e.g. HistGW.LiveTest.Sandbox). +/// HISTGW_ALARM_SOURCE — a source name for the alarm SendEventReadEvents round-trip. +/// HISTGW_GATEWAY_ALLOW_UNTRUSTEDtrue to accept a self-signed dev cert (optional). +/// +/// +/// +/// VPN-gated. wonder-sql-vd03 is reachable only on the VPN. When the endpoint +/// is configured but the host does not accept a TCP connection within +/// , is set to a message that prompts +/// the operator to connect the VPN — the suite skips rather than hangs. +/// +/// +/// Never connects from the fixture. The gRPC channel is built lazily by the package +/// client, so constructing an adapter performs no network I/O. The fixture's only network +/// touch is the bounded TCP reachability probe. +/// +/// +public sealed class GatewayLiveFixture +{ + private const string EnvEndpoint = "HISTGW_GATEWAY_ENDPOINT"; + private const string EnvApiKey = "HISTGW_GATEWAY_APIKEY"; + private const string EnvTestTag = "HISTGW_TEST_TAG"; + private const string EnvWriteSandboxTag = "HISTGW_WRITE_SANDBOX_TAG"; + private const string EnvAlarmSource = "HISTGW_ALARM_SOURCE"; + private const string EnvAllowUntrusted = "HISTGW_GATEWAY_ALLOW_UNTRUSTED"; + + /// Bounded deadline for the TCP reachability probe (keeps an unreachable VPN from hanging the run). + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(3); + + /// Short per-call deadline so a misconfigured / unreachable gateway fails fast instead of hanging. + private static readonly TimeSpan CallTimeout = TimeSpan.FromSeconds(20); + + private readonly string? _endpoint; + private readonly string? _apiKey; + private readonly bool _allowUntrusted; + + /// + /// Reads the env config and runs one bounded TCP reachability probe. On any gap + /// (missing endpoint/key, malformed URI, or unreachable host) is set + /// and the suite skips cleanly. + /// + public GatewayLiveFixture() + { + _endpoint = Trimmed(EnvEndpoint); + _apiKey = Trimmed(EnvApiKey); + TestTag = Trimmed(EnvTestTag); + WriteSandboxTag = Trimmed(EnvWriteSandboxTag); + AlarmSource = Trimmed(EnvAlarmSource); + _allowUntrusted = string.Equals( + Trimmed(EnvAllowUntrusted), "true", StringComparison.OrdinalIgnoreCase); + + if (_endpoint is null || _apiKey is null) + { + SkipReason = + $"Skipped: set {EnvEndpoint} (e.g. https://wonder-sql-vd03:5222) and {EnvApiKey} " + + $"(a histgw__ key with historian:read + historian:write scopes) to run the live validation suite."; + return; + } + + if (!Uri.TryCreate(_endpoint, UriKind.Absolute, out var uri)) + { + SkipReason = $"Skipped: {EnvEndpoint}='{_endpoint}' is not an absolute URI (expected e.g. https://wonder-sql-vd03:5222)."; + return; + } + + // Bounded TCP probe — a configured-but-unreachable gateway (VPN down) skips, never hangs. + SkipReason = ProbeReachable(uri.Host, uri.Port) + ? null + : $"Skipped: gateway {uri.Host}:{uri.Port} (from {EnvEndpoint}) did not accept a TCP connection within " + + $"{ProbeTimeout.TotalSeconds:0}s. It is reachable only on the corporate VPN — connect the VPN (host wonder-sql-vd03) and re-run."; + } + + /// Non-null when the suite must skip (unconfigured, malformed endpoint, or unreachable host). + public string? SkipReason { get; } + + /// Convenience flag: true when env config is absent / malformed / unreachable. + public bool NotConfigured => SkipReason is not null; + + /// The existing Galaxy / historian tag for the read round-trip (HISTGW_TEST_TAG); null when unset. + public string? TestTag { get; } + + /// The Float sandbox tag for the write round-trip (HISTGW_WRITE_SANDBOX_TAG); null when unset. + public string? WriteSandboxTag { get; } + + /// The source name for the alarm round-trip (HISTGW_ALARM_SOURCE); null when unset. + public string? AlarmSource { get; } + + /// + /// Builds the bound from env. Only valid when + /// is false (the endpoint + key are non-null by then). + /// + public ServerHistorianOptions BuildOptions() + { + var useTls = Uri.TryCreate(_endpoint, UriKind.Absolute, out var uri) + && string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + + return new ServerHistorianOptions + { + Enabled = true, + Endpoint = _endpoint!, + ApiKey = _apiKey!, + UseTls = useTls, + AllowUntrustedServerCertificate = _allowUntrusted, + CallTimeout = CallTimeout, + }; + } + + /// + /// Creates a fresh real adapter over the package gateway client. Each caller owns and disposes + /// its own adapter (the data source / writers each take exclusive ownership of their client). + /// + public HistorianGatewayClientAdapter CreateClient() => + HistorianGatewayClientAdapter.Create(BuildOptions(), NullLoggerFactory.Instance); + + /// Creates a fresh real over its own adapter. + public GatewayHistorianDataSource CreateDataSource() => + new(CreateClient(), NullLogger.Instance); + + private static string? Trimmed(string envVar) + { + var value = Environment.GetEnvironmentVariable(envVar); + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + /// + /// Bounded, never-throwing TCP connectivity probe to :. + /// Returns true only on a connection accepted within . + /// + private static bool ProbeReachable(string host, int port) + { + try + { + using var client = new TcpClient(); + var connect = client.ConnectAsync(host, port); + return connect.Wait(ProbeTimeout) && client.Connected; + } + catch + { + // Timeout, connection refused, DNS failure, … → unreachable (skip, never fail/hang). + return false; + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs new file mode 100644 index 00000000..78eaec64 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs @@ -0,0 +1,204 @@ +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.Core.Abstractions.Historian; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Live; + +/// +/// End-to-end live validation of the gateway-backed historian backend against a real, running +/// ZB.MOM.WW.HistorianGateway sidecar (typically wonder-sql-vd03 on the VPN). This is +/// the validation gate the operator runs on the VPN before the Wonderware backend's retirement is +/// trusted — every path exercised here is a real driver component, not a fake: +/// +/// — the read + ReadEvents path. +/// — the EnsureTags seam. +/// — the recorder's WriteLiveValues path. +/// — the alarm SendEvent path. +/// +/// +/// Env-gated + skip-clean. Every test calls Assert.Skip via the fixture when the +/// suite is unconfigured / the gateway is unreachable, and again when its own required tag / +/// source env var is absent — so dotnet test --filter "Category=LiveIntegration" stays +/// green offline (all skip, none fail). See for the env vars. +/// +/// +/// Gateway prerequisites (when run on the VPN): the target gateway must run with +/// RuntimeDb:Enabled=true (the WriteLiveValues SQL path) and +/// RuntimeDb:EventReadsEnabled=true (the SQL ReadEvents path), and the API key +/// must carry the historian:read + historian:write scopes. +/// +/// +[Trait("Category", "LiveIntegration")] +public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IClassFixture +{ + private readonly GatewayLiveFixture _fx = fixture; + + /// + /// Read round-trip — ReadRaw for an existing tag over the last hour through the real + /// . Asserts the read completes without throwing and + /// returns a (possibly empty) sample set: a sparse tag legitimately has zero samples in the + /// window, so the meaningful live signal is "the gateway answered, not faulted". + /// + [Fact] + [Trait("Category", "LiveIntegration")] + public async Task Galaxy_tag_read_round_trip() + { + if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!); + if (_fx.TestTag is null) + Assert.Skip("Skipped: set HISTGW_TEST_TAG to an existing Galaxy/historian tag to run the read round-trip."); + + var ct = TestContext.Current.CancellationToken; + await using var dataSource = _fx.CreateDataSource(); + + var endUtc = DateTime.UtcNow; + var startUtc = endUtc - TimeSpan.FromHours(1); + + var result = await dataSource.ReadRawAsync(_fx.TestTag, startUtc, endUtc, maxValuesPerNode: 1000, ct); + + result.ShouldNotBeNull(); + result.Samples.Count.ShouldBeGreaterThanOrEqualTo(0, "a live ReadRaw must answer (zero samples is a valid sparse-tag result, not a fault)"); + + TestContext.Current.SendDiagnosticMessage( + $"read round-trip: ReadRaw('{_fx.TestTag}', last 1h) returned {result.Samples.Count} sample(s)."); + } + + /// + /// Write round-trip — EnsureTags (Float) → WriteLiveValues (a known value via the + /// real recorder writer) → ReadRaw the recent window and assert the written sample is + /// present. Requires the gateway running RuntimeDb:Enabled=true and that EnsureTags + /// provisioned the tag (the SQL live-write path only accepts provisioned analog tags). The write + /// value is an exact-in-float integer so the float-precision round-trip compares cleanly. + /// + [Fact] + [Trait("Category", "LiveIntegration")] + public async Task Write_then_read_on_sandbox_tag() + { + if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!); + if (_fx.WriteSandboxTag is null) + Assert.Skip("Skipped: set HISTGW_WRITE_SANDBOX_TAG to a writable Float sandbox tag (e.g. HistGW.LiveTest.Sandbox) to run the write round-trip."); + + var ct = TestContext.Current.CancellationToken; + var tag = _fx.WriteSandboxTag; + + // A value that is exactly representable in float32 (integer < 2^24) so the analog + // store/read round-trip is not muddied by single-precision rounding. The millisecond + // component keeps consecutive runs from colliding on the same value. + const ushort goodQuality = 192; // OPC-DA "Good" floor. + var writeUtc = DateTime.UtcNow; + double written = 1_000_000 + writeUtc.Millisecond; + + // EnsureTags (Float) through the real adapter seam — create-or-update, idempotent for an + // already-provisioned sandbox tag. + await using var writeClient = _fx.CreateClient(); + var ensure = await writeClient.EnsureTagsAsync( + new[] + { + new HistorianTagDefinition + { + TagName = tag, + DataType = HistorianDataType.Float, + EngineeringUnit = string.Empty, + Description = "OtOpcUa live validation sandbox", + }, + }, + ct); + ensure.ShouldNotBeNull(); + + // WriteLiveValues through the real recorder writer (SQL live-write path). + var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger.Instance); + var acked = await valueWriter.WriteLiveValuesAsync( + tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct); + acked.ShouldBeTrue( + "the live write must be acked — needs the gateway running RuntimeDb:Enabled=true and the tag EnsureTags-provisioned."); + + // Read the written value back over a recent window. The SQL write can lag the read by a flush + // cadence, so poll briefly rather than asserting on the first read. + await using var dataSource = _fx.CreateDataSource(); + DataValueSnapshot? hit = null; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); + do + { + var read = await dataSource.ReadRawAsync( + tag, writeUtc - TimeSpan.FromMinutes(5), DateTime.UtcNow + TimeSpan.FromMinutes(1), maxValuesPerNode: 10_000, ct); + hit = read.Samples.FirstOrDefault(s => s.Value is double d && Math.Abs(d - written) < 0.5); + if (hit is not null) break; + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + while (DateTime.UtcNow < deadline); + + hit.ShouldNotBeNull( + $"the written sample ({written}) should be readable back from '{tag}' within the recent window (gateway needs RuntimeDb:Enabled=true)."); + + TestContext.Current.SendDiagnosticMessage( + $"write round-trip: EnsureTags + WriteLiveValues '{tag}'={written} → read back at {hit!.SourceTimestampUtc:O}."); + } + + /// + /// Alarm round-trip — SendEvent for the configured source through the real + /// , then ReadEvents the recent window for that + /// source through the real and assert the event is + /// present. Requires the gateway running RuntimeDb:EventReadsEnabled=true (the SQL + /// alarm-history read path). Presence is asserted as "at least one event for the source surfaced + /// in the post-send window" (the data source filters by source); the exact AlarmId / message + /// match is surfaced as a diagnostic, since the SQL event store may re-key the row. + /// + [Fact] + [Trait("Category", "LiveIntegration")] + public async Task Alarm_SendEvent_then_ReadEvents() + { + if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!); + if (_fx.AlarmSource is null) + Assert.Skip("Skipped: set HISTGW_ALARM_SOURCE to a source name to run the alarm SendEvent → ReadEvents round-trip."); + + var ct = TestContext.Current.CancellationToken; + var source = _fx.AlarmSource; + var alarmId = "OtOpcUaLive-" + Guid.NewGuid().ToString("N"); + var eventUtc = DateTime.UtcNow; + + var alarm = new AlarmHistorianEvent( + AlarmId: alarmId, + EquipmentPath: source, // becomes the wire event's SourceName / SQL Source_Object filter key. + AlarmName: "OtOpcUaLiveValidation", + AlarmTypeName: "LimitAlarm", + Severity: AlarmSeverity.High, + EventKind: "Activated", + Message: "OtOpcUa live validation event", + User: "system", + Comment: null, + TimestampUtc: eventUtc); + + // SendEvent through the real alarm writer (never throws — returns a per-event outcome). + using var alarmClient = _fx.CreateClient(); + var alarmWriter = new GatewayAlarmHistorianWriter(alarmClient, NullLogger.Instance); + var outcomes = await alarmWriter.WriteBatchAsync(new[] { alarm }, ct); + outcomes.ShouldHaveSingleItem().ShouldBe( + HistorianWriteOutcome.Ack, + "the alarm SendEvent must be acked — needs the gateway write scope (historian:write) and SendEvent path."); + + // Read the event back over a recent window for the source. The SQL event write can lag, so poll. + await using var dataSource = _fx.CreateDataSource(); + IReadOnlyList events = Array.Empty(); + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); + do + { + var read = await dataSource.ReadEventsAsync( + source, eventUtc - TimeSpan.FromMinutes(5), DateTime.UtcNow + TimeSpan.FromMinutes(1), maxEvents: 0, ct); + events = read.Events; + if (events.Count > 0) break; + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + while (DateTime.UtcNow < deadline); + + events.Count.ShouldBeGreaterThan(0, + $"the SendEvent for source '{source}' should be readable back via ReadEvents (gateway needs RuntimeDb:EventReadsEnabled=true)."); + + var exactMatch = events.Any(e => string.Equals(e.EventId, alarmId, StringComparison.Ordinal)); + TestContext.Current.SendDiagnosticMessage( + $"alarm round-trip: SendEvent source='{source}' id={alarmId} → ReadEvents returned {events.Count} event(s); exact-id match={exactMatch}."); + } +}