using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using ArchestrA; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests { /// /// PR C.1 — covers , the aahClientManaged-bound /// alarm-event writer. The SDK-touching batch loop itself is exercised by the rig-gated /// Live_* tests (D.1); the unit tests below pin the parts that are SDK-type-free: /// /// connection-unavailable → whole batch deferred as RetryPlease; /// error-code mapping; /// read-only-vs-write shaping. /// /// [Trait("Category", "Unit")] public sealed class SdkAlarmHistorianWriteBackendTests { // ── Connection-unavailable path (deterministic, no SDK load) ────────── [Fact] public async Task Empty_batch_returns_empty_array() { var backend = new SdkAlarmHistorianWriteBackend( Config("any"), new ThrowingConnectionFactory()); var outcomes = await backend.WriteBatchAsync( Array.Empty(), CancellationToken.None); outcomes.ShouldBeEmpty(); } [Fact] public async Task Unreachable_node_defers_whole_batch_as_RetryPlease() { // No node can be connected — the backend must defer every event so the // lmxopcua-side SQLite store-and-forward sink retains the rows rather than // dropping them. var backend = new SdkAlarmHistorianWriteBackend( Config("unreachable"), new ThrowingConnectionFactory()); var events = new[] { AlarmEvent("E1"), AlarmEvent("E2"), AlarmEvent("E3") }; var outcomes = await backend.WriteBatchAsync(events, CancellationToken.None); outcomes.Length.ShouldBe(events.Length); outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease); } [Fact] public async Task Unreachable_node_large_batch_returns_one_outcome_per_event() { // Guards the outcome-array allocation: WriteBatchAsync must always return exactly // as many outcomes as input events, even on the whole-batch-deferred path. var backend = new SdkAlarmHistorianWriteBackend( Config("unreachable"), new ThrowingConnectionFactory()); var batch = Enumerable.Range(0, 1000).Select(i => AlarmEvent($"E{i}")).ToArray(); var outcomes = await backend.WriteBatchAsync(batch, CancellationToken.None); outcomes.Length.ShouldBe(1000); outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease); } [Fact] public async Task Connect_failure_marks_node_failed_in_picker() { // Every connect attempt throws → the picker should record the failure so the // node enters cooldown (cluster-failover plumbing). var cfg = Config("node-a"); var picker = new HistorianClusterEndpointPicker(cfg); var backend = new SdkAlarmHistorianWriteBackend(cfg, new ThrowingConnectionFactory(), picker); await backend.WriteBatchAsync(new[] { AlarmEvent("E1") }, CancellationToken.None); picker.HealthyNodeCount.ShouldBe(0, "the only node failed to connect and is now in cooldown"); } // ── ClassifyOutcome — error-code → outcome mapping ──────────────────── [Theory] [InlineData(HistorianAccessError.ErrorValue.Success, AlarmHistorianWriteOutcome.Ack)] [InlineData(HistorianAccessError.ErrorValue.FailedToConnect, AlarmHistorianWriteOutcome.RetryPlease)] [InlineData(HistorianAccessError.ErrorValue.FailedToCreateSession, AlarmHistorianWriteOutcome.RetryPlease)] [InlineData(HistorianAccessError.ErrorValue.NoReply, AlarmHistorianWriteOutcome.RetryPlease)] [InlineData(HistorianAccessError.ErrorValue.NotReady, AlarmHistorianWriteOutcome.RetryPlease)] [InlineData(HistorianAccessError.ErrorValue.Failure, AlarmHistorianWriteOutcome.RetryPlease)] [InlineData(HistorianAccessError.ErrorValue.NoData, AlarmHistorianWriteOutcome.RetryPlease)] [InlineData(HistorianAccessError.ErrorValue.InvalidArgument, AlarmHistorianWriteOutcome.PermanentFail)] [InlineData(HistorianAccessError.ErrorValue.ValidationFailed, AlarmHistorianWriteOutcome.PermanentFail)] [InlineData(HistorianAccessError.ErrorValue.NullPointerArgument, AlarmHistorianWriteOutcome.PermanentFail)] [InlineData(HistorianAccessError.ErrorValue.WriteToReadOnlyFile, AlarmHistorianWriteOutcome.PermanentFail)] [InlineData(HistorianAccessError.ErrorValue.NotImplemented, AlarmHistorianWriteOutcome.PermanentFail)] public void ClassifyOutcome_maps_error_code_to_expected_outcome( HistorianAccessError.ErrorValue code, AlarmHistorianWriteOutcome expected) { SdkAlarmHistorianWriteBackend.ClassifyOutcome(code).ShouldBe(expected); } // ── BuildConnectionArgs — read-only vs write shaping ────────────────── [Fact] public void BuildConnectionArgs_write_connection_is_not_read_only() { // The alarm-event write path must open ReadOnly=false; AddStreamedValue on a // read-only session fails with WriteToReadOnlyFile. var args = SdkHistorianConnectionFactory.BuildConnectionArgs( Config("h1"), HistorianConnectionType.Event, readOnly: false); args.ReadOnly.ShouldBeFalse(); args.ConnectionType.ShouldBe(HistorianConnectionType.Event); args.ServerName.ShouldBe("h1"); } [Fact] public void BuildConnectionArgs_query_connection_is_read_only() { var args = SdkHistorianConnectionFactory.BuildConnectionArgs( Config("h1"), HistorianConnectionType.Process, readOnly: true); args.ReadOnly.ShouldBeTrue(); args.ConnectionType.ShouldBe(HistorianConnectionType.Process); } [Fact] public void BuildConnectionArgs_non_integrated_security_carries_credentials() { var cfg = Config("h1"); cfg.IntegratedSecurity = false; cfg.UserName = "histuser"; cfg.Password = "histpass"; var args = SdkHistorianConnectionFactory.BuildConnectionArgs( cfg, HistorianConnectionType.Event, readOnly: false); args.IntegratedSecurity.ShouldBeFalse(); args.UserName.ShouldBe("histuser"); args.Password.ShouldBe("histpass"); } // ── Rig-gated integration tests ─────────────────────────────────────── // // The entry point (HistorianAccess.AddStreamedValue) is pinned and implemented; // these need a live AVEVA Historian and are un-skipped during the PR D.1 smoke. [Fact(Skip = "rig-required: needs a live AVEVA Historian — un-skip during the PR D.1 rollout smoke")] public async Task Live_single_event_roundtrip_returns_Ack() { var backend = new SdkAlarmHistorianWriteBackend(BuildRigConfig()); var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-E1") }, CancellationToken.None); outcomes.Length.ShouldBe(1); outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack); } [Fact(Skip = "rig-required: needs a live AVEVA Historian cluster (two nodes) — un-skip during the PR D.1 rollout smoke")] public async Task Live_cluster_failover_primary_bad_rotates_to_secondary() { var cfg = new HistorianConfiguration { Enabled = true, ServerNames = new List { "invalid-primary-node-deliberately-unreachable", Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost", }, Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568), IntegratedSecurity = true, FailureCooldownSeconds = 5, CommandTimeoutSeconds = 10, }; var backend = new SdkAlarmHistorianWriteBackend(cfg); var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-failover-E1") }, CancellationToken.None); outcomes.Length.ShouldBe(1); outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack); } // ── helpers ─────────────────────────────────────────────────────────── private static HistorianConfiguration Config(string server) => new HistorianConfiguration { Enabled = true, ServerName = server, Port = 32568, IntegratedSecurity = true, CommandTimeoutSeconds = 30, FailureCooldownSeconds = 60, }; private static AlarmHistorianEventDto AlarmEvent(string id) => new AlarmHistorianEventDto { EventId = id, SourceName = "TestSource", ConditionId = "TestSource.Level.HiHi", AlarmType = "AnalogLimitAlarm.HiHi", Message = "C.1 test alarm", Severity = 500, EventTimeUtcTicks = DateTime.UtcNow.Ticks, AckComment = null, }; private static HistorianConfiguration BuildRigConfig() => new HistorianConfiguration { Enabled = true, ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost", Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568), IntegratedSecurity = true, CommandTimeoutSeconds = 30, FailureCooldownSeconds = 60, }; private static int TryParseInt(string envName, int defaultValue) { var raw = Environment.GetEnvironmentVariable(envName); return int.TryParse(raw, out var parsed) ? parsed : defaultValue; } /// /// Fake factory whose every connect attempt throws — drives the /// connection-unavailable path without loading the native SDK. /// private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory { public HistorianAccess CreateAndConnect( HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true) => throw new InvalidOperationException($"simulated connect failure to {config.ServerName}"); } } }