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 SendEvent → ReadEvents round-trip.
+/// - HISTGW_GATEWAY_ALLOW_UNTRUSTED — true 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}.");
+ }
+}