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.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", StorageRateMs = 1000, // 0 makes the gateway's EnsureTags throw (storageRateMs must be > 0). }, }, 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 and assert THIS write's unique value round-tripped. The SQL write // can lag the read by a flush cadence, so poll briefly. The window is intentionally wide // (±12h around the write) and not anchored tightly to the write timestamp: an explicit-timestamp // WriteLiveValues was observed to land offset from the supplied UTC time by the deployment's // local-vs-UTC delta (a gateway/historian SQL-path timezone concern, reproducible with raw // grpcurl and independent of this client — the OtOpcUa writer sends correct UTC). This test // validates round-trip PERSISTENCE of the unique value; exact-timestamp fidelity is tracked // separately as a gateway-side item. 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.FromHours(12), writeUtc + TimeSpan.FromHours(12), maxValuesPerNode: 50_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}' (gateway needs RuntimeDb:Enabled=true and the tag EnsureTags-provisioned)."); 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 (the // live event view has variable flush latency), so poll generously. await using var dataSource = _fx.CreateDataSource(); IReadOnlyList events = Array.Empty(); var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60); 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(2), ct); } while (DateTime.UtcNow < deadline); // The source-filtered READBACK of OtOpcUa's own just-sent alarm now round-trips: the gateway // threads the wire event's SourceName into the `source_object` event property, so an ad-hoc // SendEvent is recorded in dbo.Events WITH a Source_Object and a source-filtered ReadEvents // finds it (gateway C4 fix, 2026-06-27; pending.md C4 / histsdk #3). Previously this branch // skipped because ad-hoc sends landed with Source_Object=NULL; that limitation is now closed, // so the round-trip is asserted. events.ShouldNotBeEmpty( $"a source-filtered ReadEvents must return the just-sent alarm for source '{source}': the gateway now " + "populates Source_Object from the event SourceName (C4 fix), so OtOpcUa's own alarm sends round-trip."); 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}."); } /// /// Alarm send contract — the OtOpcUa responsibility in isolation: an /// maps to a wire event the gateway accepts and SendEvent returns . /// This is the half that must always hold (the readback half is historian-gated; see /// ). Regression guard for the event-id mapping — /// a client-supplied wire Id makes the gateway's SendEvent handler throw, so the mapper /// must leave it unset. /// [Fact] [Trait("Category", "LiveIntegration")] public async Task Alarm_SendEvent_is_acked() { 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 contract test."); var ct = TestContext.Current.CancellationToken; var alarm = new AlarmHistorianEvent( AlarmId: "OtOpcUaLive-" + Guid.NewGuid().ToString("N"), EquipmentPath: _fx.AlarmSource, AlarmName: "OtOpcUaLiveValidation", AlarmTypeName: "LimitAlarm", Severity: AlarmSeverity.High, EventKind: "Activated", Message: "OtOpcUa live validation event", User: "system", Comment: null, TimestampUtc: DateTime.UtcNow); 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 (the AlarmEventMapper must NOT set the wire event Id — the gateway rejects a client-supplied id)."); } /// /// issue #441 — Boolean historization leg. Through the real /// , EnsureTags a /// request for the dedicated Boolean sandbox tag (mapped to Int2 — the historian's /// Int1 analog-tag creation path is server-degenerate, see issue #441 / HistorianGateway /// #11, so Boolean historizes as a small 0/1 integer), assert it is ensured (not failed), then /// WriteLiveValues a 1.0 and read it back over a wide window. The ±12h read window /// + timezone caveat mirror . /// [Fact] [Trait("Category", "LiveIntegration")] public async Task EnsureTags_boolean_sandbox_provisions_as_Int2() { if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!); if (_fx.BoolSandboxTag is null) Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a writable Boolean sandbox tag (e.g. HistGW.LiveTest.BoolSandbox) to run the Boolean historization leg."); var ct = TestContext.Current.CancellationToken; var tag = _fx.BoolSandboxTag; // EnsureTags (Boolean → Int2) through the REAL provisioner seam (the same code path deploys use). await using var writeClient = _fx.CreateClient(); var provisioner = new GatewayTagProvisioner(writeClient, NullLogger.Instance); var provision = await provisioner.EnsureTagsAsync( new[] { new HistorianTagProvisionRequest(tag, DriverDataType.Boolean, null, "OtOpcUa live validation Boolean sandbox") }, ct); provision.Ensured.ShouldBe(1, "the Boolean sandbox tag must be ensured as Int2 (needs the gateway on 0.2.0+ contracts + an API key carrying historian:tags:write)."); provision.Failed.ShouldBe(0); // WriteLiveValues a Boolean-as-1.0 and read it back (SQL live path can lag a flush cadence → poll). const ushort goodQuality = 192; // OPC-DA "Good" floor. var writeUtc = DateTime.UtcNow; const double written = 1.0; var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger.Instance); var acked = await valueWriter.WriteLiveValuesAsync( tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct); acked.ShouldBeTrue("the Boolean live write must be acked (gateway RuntimeDb:Enabled=true + EnsureTags-provisioned tag)."); 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.FromHours(12), writeUtc + TimeSpan.FromHours(12), maxValuesPerNode: 50_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 Boolean sample ({written}) should read back from '{tag}'."); TestContext.Current.SendDiagnosticMessage( $"Boolean round-trip: EnsureTags(Int2) + WriteLiveValues '{tag}'={written} → read back at {hit!.SourceTimestampUtc:O}."); } /// /// archreview 06/U-7 retype probe — answers the assessment note's open question: what does the /// deployed AVEVA Historian do when EnsureTags (an upsert) is asked to change an /// existing tag's type? Against the dedicated Boolean sandbox tag only (never the shared /// Float write sandbox), EnsureTags first as Float then as Int2 (both supported /// analog types — Int2 is the type Boolean now historizes as; the previously-probed /// Int1 is a documented server-degenerate exclusion, issue #441) and assert the second /// call's per-tag outcome is deterministic and observable — either Success=true /// (retype / tag-versioning accepted) or Success=false with a non-empty Error (mapped /// to the provisioner's failed tally). The assertion accepts both because the contract /// guarantees only the observability, not the policy; the observed behavior is emitted as a /// diagnostic so the run documents which of accepted-versioned / accepted-retyped / rejected the /// historian exhibits (fed back into docs/Historian.md after the first live run). /// [Fact] [Trait("Category", "LiveIntegration")] public async Task EnsureTags_type_change_outcome_is_observable() { if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!); if (_fx.BoolSandboxTag is null) Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a dedicated Boolean sandbox tag to run the retype probe."); var ct = TestContext.Current.CancellationToken; var tag = _fx.BoolSandboxTag; await using var client = _fx.CreateClient(); // First establish the tag as Float, then ask EnsureTags to retype it to Int2. await client.EnsureTagsAsync( new[] { new HistorianTagDefinition { TagName = tag, DataType = HistorianDataType.Float, EngineeringUnit = string.Empty, Description = "OtOpcUa live validation retype probe (stage 1: Float)", StorageRateMs = 1000, // 0 makes the gateway's EnsureTags throw (storageRateMs must be > 0). }, }, ct); var retype = await client.EnsureTagsAsync( new[] { new HistorianTagDefinition { TagName = tag, DataType = HistorianDataType.Int2, EngineeringUnit = string.Empty, Description = "OtOpcUa live validation retype probe (stage 2: Int2)", StorageRateMs = 1000, // 0 makes the gateway's EnsureTags throw (storageRateMs must be > 0). }, }, ct); var outcome = retype.Results.ShouldHaveSingleItem(); var observable = outcome.Success || !string.IsNullOrEmpty(outcome.Error); observable.ShouldBeTrue( "the retype outcome must be deterministic + observable: either Success=true (retype/version accepted) " + "or Success=false with a non-empty Error (mapped to the provisioner's failed tally)."); TestContext.Current.SendDiagnosticMessage( $"retype probe '{tag}' Float→Int2: Success={outcome.Success}, Error='{outcome.Error}'. " + "(records the deployed historian's in-place analog-retype policy — feed this back into docs/Historian.md)."); } }