using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; 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 — pins the contract: /// /// /// Unit: the placeholder backend returns /// for every slot so the lmxopcua-side store-and-forward sink retains events rather than /// dropping them while D.1 is unresolved. /// /// /// Integration (rig-gated): once D.1 pins the live SDK entry point the Skip attribute is /// removed. The live test writes a synthetic batch to a real AVEVA Historian and asserts /// the cluster picker rotates from a broken primary to a healthy secondary. /// /// /// [Trait("Category", "Unit")] public sealed class SdkAlarmHistorianWriteBackendTests { // ── Placeholder-mode tests (no rig required) ───────────────────────── [Fact] public async Task Placeholder_returns_RetryPlease_for_every_slot_so_queue_is_preserved() { // The SDK call-site in SdkAlarmHistorianWriteBackend is not yet pinned (PR D.1). // Until D.1 swaps in the live call, the backend must return RetryPlease for every // event so the lmxopcua-side SqliteStoreAndForwardSink retains the rows instead of // dropping them — same effect as the NullAlarmHistorianSink fallback, but each // slot is individually addressable for the drain worker. var cfg = new HistorianConfiguration { ServerName = "placeholder-test", Enabled = true }; var backend = new SdkAlarmHistorianWriteBackend(cfg); 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 Placeholder_returns_empty_array_for_empty_batch() { var cfg = new HistorianConfiguration { ServerName = "placeholder-test", Enabled = true }; var backend = new SdkAlarmHistorianWriteBackend(cfg); var outcomes = await backend.WriteBatchAsync(Array.Empty(), CancellationToken.None); outcomes.ShouldBeEmpty(); } [Fact] public async Task Placeholder_returns_same_count_as_input_for_large_batch() { // Guards against an off-by-one error in the placeholder array allocation — // WriteBatchAsync must always return exactly as many outcomes as input events. var cfg = new HistorianConfiguration { ServerName = "placeholder-test", Enabled = true }; var backend = new SdkAlarmHistorianWriteBackend(cfg); 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); } // ── Rig-gated integration tests ─────────────────────────────────────── // // The tests below need a live AVEVA Historian install and are gated with // Skip="rig-required". Once PR D.1 pins the SDK entry point, remove the // Skip attribute and add them to the integration test run profile. [Fact(Skip = "rig-required: needs a live AVEVA Historian + aahClientManaged SDK — enable in PR D.1")] public async Task Live_single_event_roundtrip_returns_Ack() { // Spec (PR C.1, Tests): "1 / 100 / 1000 events through a fake aahClientManaged // writer; assert per-row outcome list parallel to input order." // // This slice exercises the *live* SDK path. The fake-backend variant at // AahClientManagedAlarmEventWriterTests covers the same assertion without the rig. var cfg = BuildRigConfig(); var backend = new SdkAlarmHistorianWriteBackend(cfg); 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) — enable in PR D.1")] public async Task Live_cluster_failover_primary_bad_rotates_to_secondary() { // Spec (PR C.1, Tests): "Cluster failover: primary node returns // BadCommunicationError; picker rotates to secondary; assert eventual success." // // Configure the first server name to point at a deliberately unreachable node // and the second to the real Historian; the picker should mark the first node // failed and succeed via the second. var cfg = new HistorianConfiguration { Enabled = true, ServerNames = new System.Collections.Generic.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); // The backend must succeed (Ack) via the secondary even though the primary was bad. outcomes.Length.ShouldBe(1); outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack); } private static AlarmHistorianEventDto AlarmEvent(string id) => new AlarmHistorianEventDto { EventId = id, SourceName = "TestSource", ConditionId = "TestSource.Level.HiHi", AlarmType = "AnalogLimitAlarm.HiHi", Message = "C.1 integration 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; } } }