using MessagePack; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests; /// /// End-to-end tests for : every interface method /// round-trips through a real named pipe against the in-process /// , which reuses the client's own byte-identical framing /// code. Covers byte→uint quality mapping, BadNoData propagation for null aggregate /// buckets, alarm-write per-event status flow, Hello handshake rejection on bad secret, /// and reconnect after a transport drop. /// public sealed class WonderwareHistorianClientTests { private const string Secret = "test-secret-123"; private static string UniquePipeName() => $"otopcua-historian-test-{Guid.NewGuid():N}"; private static WonderwareHistorianClientOptions OptsFor(string pipe) => new( PipeName: pipe, SharedSecret: Secret, PeerName: "test", ConnectTimeout: TimeSpan.FromSeconds(2), CallTimeout: TimeSpan.FromSeconds(2)); [Fact] public async Task ReadRawAsync_RoundTripsSamples_AndMapsQualityByteToOpcUaStatusCode() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret) { OnReadRaw = req => new ReadRawReply { Success = true, Samples = [ new HistorianSampleDto { ValueBytes = MessagePackSerializer.Serialize(42.0), Quality = 192, // Good TimestampUtcTicks = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc).Ticks, }, new HistorianSampleDto { ValueBytes = MessagePackSerializer.Serialize(43.5), Quality = 8, // Bad_NotConnected TimestampUtcTicks = new DateTime(2026, 4, 29, 12, 0, 1, DateTimeKind.Utc).Ticks, }, ], }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); var result = await client.ReadRawAsync("Tank.Level", new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc), 100, CancellationToken.None); result.ContinuationPoint.ShouldBeNull(); result.Samples.Count.ShouldBe(2); result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good result.Samples[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc)); result.Samples[1].StatusCode.ShouldBe(0x808A0000u); // Bad_NotConnected } [Fact] public async Task ReadProcessedAsync_NullBuckets_MapToBadNoData() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret) { OnReadProcessed = _ => new ReadProcessedReply { Success = true, Buckets = [ new HistorianAggregateSampleDto { Value = 50.0, TimestampUtcTicks = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc).Ticks }, new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = new DateTime(2026, 4, 29, 0, 1, 0, DateTimeKind.Utc).Ticks }, ], }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); var result = await client.ReadProcessedAsync("Tank.Level", new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 4, 29, 0, 2, 0, DateTimeKind.Utc), TimeSpan.FromMinutes(1), HistoryAggregateType.Average, CancellationToken.None); result.Samples.Count.ShouldBe(2); result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good result.Samples[0].Value.ShouldBe(50.0); result.Samples[1].StatusCode.ShouldBe(0x800E0000u); // BadNoData result.Samples[1].Value.ShouldBeNull(); } [Fact] public async Task ReadAtTimeAsync_PreservesTimestampOrder() { var pipe = UniquePipeName(); var t1 = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc); var t2 = new DateTime(2026, 4, 29, 2, 0, 0, DateTimeKind.Utc); await using var server = new FakeSidecarServer(pipe, Secret) { OnReadAtTime = req => new ReadAtTimeReply { Success = true, Samples = req.TimestampsUtcTicks .Select(ticks => new HistorianSampleDto { Quality = 192, TimestampUtcTicks = ticks }) .ToArray(), }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); var result = await client.ReadAtTimeAsync("Tank.Level", new[] { t1, t2 }, CancellationToken.None); result.Samples.Count.ShouldBe(2); result.Samples[0].SourceTimestampUtc.ShouldBe(t1); result.Samples[1].SourceTimestampUtc.ShouldBe(t2); } [Fact] public async Task ReadAtTimeAsync_PartialAndReorderedReply_AlignsByTimestamp_AndFillsGapsAsBad() { var pipe = UniquePipeName(); var t1 = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc); var t2 = new DateTime(2026, 4, 29, 2, 0, 0, DateTimeKind.Utc); var t3 = new DateTime(2026, 4, 29, 3, 0, 0, DateTimeKind.Utc); await using var server = new FakeSidecarServer(pipe, Secret) { // Sidecar returns only t3 and t1 (out of order), drops t2 entirely. A // contract-compliant client must realign by timestamp and synthesize a // Bad-quality snapshot for the missing t2. OnReadAtTime = _ => new ReadAtTimeReply { Success = true, Samples = [ new HistorianSampleDto { ValueBytes = MessagePackSerializer.Serialize(3.0), Quality = 192, TimestampUtcTicks = t3.Ticks, }, new HistorianSampleDto { ValueBytes = MessagePackSerializer.Serialize(1.0), Quality = 192, TimestampUtcTicks = t1.Ticks, }, ], }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); var result = await client.ReadAtTimeAsync("Tank.Level", new[] { t1, t2, t3 }, CancellationToken.None); // Result MUST be the same length and order as the request. result.Samples.Count.ShouldBe(3); result.Samples[0].SourceTimestampUtc.ShouldBe(t1); result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good result.Samples[0].Value.ShouldBe(1.0); // t2 was not returned by the sidecar → Bad-quality gap snapshot at the requested time. result.Samples[1].SourceTimestampUtc.ShouldBe(t2); result.Samples[1].StatusCode.ShouldBe(0x80000000u); // Bad result.Samples[1].Value.ShouldBeNull(); result.Samples[2].SourceTimestampUtc.ShouldBe(t3); result.Samples[2].StatusCode.ShouldBe(0x00000000u); // Good result.Samples[2].Value.ShouldBe(3.0); } [Fact] public async Task ReadEventsAsync_PreservesEventFields() { var pipe = UniquePipeName(); var eid = Guid.NewGuid().ToString("N"); await using var server = new FakeSidecarServer(pipe, Secret) { OnReadEvents = _ => new ReadEventsReply { Success = true, Events = [ new HistorianEventDto { EventId = eid, Source = "Tank.HiHi", EventTimeUtcTicks = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc).Ticks, ReceivedTimeUtcTicks = new DateTime(2026, 4, 29, 1, 0, 1, DateTimeKind.Utc).Ticks, DisplayText = "Level high-high", Severity = 800, }, ], }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); var result = await client.ReadEventsAsync("Tank.HiHi", new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc), 100, CancellationToken.None); result.Events.Count.ShouldBe(1); result.Events[0].EventId.ShouldBe(eid); result.Events[0].SourceName.ShouldBe("Tank.HiHi"); result.Events[0].Message.ShouldBe("Level high-high"); result.Events[0].Severity.ShouldBe((ushort)800); } [Fact] public async Task ReadRawAsync_ServerError_ThrowsInvalidOperation() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret) { OnReadRaw = _ => new ReadRawReply { Success = false, Error = "historian unreachable" }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); var ex = await Should.ThrowAsync(() => client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None)); ex.Message.ShouldContain("historian unreachable"); } [Fact] public async Task WriteBatchAsync_PerEventOk_MapsToAckOrRetryPlease() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret) { OnWriteAlarmEvents = req => new WriteAlarmEventsReply { Success = true, PerEventOk = req.Events.Select(e => e.EventId != "ev-fail").ToArray(), }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); var batch = new[] { new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "operator", null, DateTime.UtcNow), new AlarmHistorianEvent("ev-fail", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Acknowledged", "msg", "operator", null, DateTime.UtcNow), }; var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); outcomes.Count.ShouldBe(2); outcomes[0].ShouldBe(HistorianWriteOutcome.Ack); outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease); } [Fact] public async Task WriteBatchAsync_WholeCallFailure_ReturnsRetryPleaseForEveryEvent() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret) { OnWriteAlarmEvents = _ => new WriteAlarmEventsReply { Success = false, Error = "historian event-store down", PerEventOk = new bool[2], }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); var batch = new[] { new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Cleared", "msg", "u", null, DateTime.UtcNow), }; var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); outcomes.Count.ShouldBe(2); outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease); outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease); } [Fact] public async Task Hello_BadSecret_ThrowsUnauthorizedAccess() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, "different-secret"); await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); var ex = await Should.ThrowAsync(() => client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None)); ex.Message.ShouldContain("shared-secret-mismatch"); } [Fact] public async Task Reconnect_AfterTransportDrop_RetriesOnce() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret) { // First connection drops after handshake → client retries on next call. DisconnectAfterHandshake = true, OnReadRaw = req => new ReadRawReply { Success = true, Samples = [new HistorianSampleDto { Quality = 192, TimestampUtcTicks = req.StartUtcTicks }], }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); // First call: handshake + dropped. Reconnect kicks in inside the channel; second // attempt within the same InvokeAsync succeeds. From the caller's perspective it's // one ReadRawAsync that returns a sample. var result = await client.ReadRawAsync("Tag", new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc), 100, CancellationToken.None); result.Samples.Count.ShouldBe(1); } [Fact] public async Task GetHealthSnapshot_TracksSuccessAndFailureCounts() { var pipe = UniquePipeName(); var failNext = false; await using var server = new FakeSidecarServer(pipe, Secret) { OnReadRaw = _ => failNext ? new ReadRawReply { Success = false, Error = "boom" } : new ReadRawReply { Success = true }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); await client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None); failNext = true; await Should.ThrowAsync(() => client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None)); var snap = client.GetHealthSnapshot(); snap.TotalQueries.ShouldBe(2); snap.TotalSuccesses.ShouldBe(1); snap.TotalFailures.ShouldBe(1); snap.ConsecutiveFailures.ShouldBe(1); snap.LastError.ShouldNotBeNull(); snap.ProcessConnectionOpen.ShouldBeTrue(); } // ===== Finding-009: missing edge-case tests ===== /// /// (2) A transport drop during a write (the catch path in WriteBatchAsync) must return /// RetryPlease for every event in the batch — never throw, never PermanentFail. /// [Fact] public async Task WriteBatchAsync_TransportDropDuringWrite_ReturnsRetryPleaseForEveryEvent() { var pipe = UniquePipeName(); // Server disconnects before replying to the write request. The client's single retry // reconnects; on the second attempt the server is still armed to disconnect, so both // attempts fail and the catch block fires. await using var server = new FakeSidecarServer(pipe, Secret) { DisconnectBeforeReply = true, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); var batch = new[] { new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Cleared", "msg", "u", null, DateTime.UtcNow), }; // WriteBatchAsync must not throw — it absorbs transport failures as RetryPlease. var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); outcomes.Count.ShouldBe(2); outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease); outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease); } /// /// (3) When both the first attempt and the single retry fail (the "second attempt also /// fails" path in InvokeAsync), the exception propagates to the caller. /// [Fact] public async Task InvokeAsync_BothAttemptsFailTransport_PropagatesException() { var pipe = UniquePipeName(); // DisconnectBeforeReply stays true so both the first attempt and the single retry // inside InvokeAsync are dropped, causing the second ExchangeAsync to throw. await using var server = new FakeSidecarServer(pipe, Secret) { DisconnectBeforeReply = true, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); // ReadRawAsync uses Invoke, which propagates the exception when both attempts fail. await Should.ThrowAsync(() => client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None)); } /// /// (4) A stalled sidecar that never sends a reply must cause an /// within the configured CallTimeout. /// [Fact] public async Task ReadRawAsync_StalledSidecar_TimesOutWithOperationCanceledException() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret) { StallAfterRequest = true, }; await server.StartAsync(); var opts = new WonderwareHistorianClientOptions( PipeName: pipe, SharedSecret: Secret, PeerName: "test", ConnectTimeout: TimeSpan.FromSeconds(2), CallTimeout: TimeSpan.FromMilliseconds(500)); // short timeout for test speed await using var client = new WonderwareHistorianClient(opts); // The stall means neither the first nor the retry can complete, so the timeout // linked-token should cancel the operation. await Should.ThrowAsync(() => client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None)); } /// /// (5) must throw /// because Wonderware AnalogSummary has no Total /// aggregate column. /// [Fact] public async Task ReadProcessedAsync_TotalAggregate_ThrowsNotSupported() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret); await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); await Should.ThrowAsync(() => client.ReadProcessedAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, TimeSpan.FromMinutes(1), HistoryAggregateType.Total, CancellationToken.None)); } /// /// (6) When the sidecar replies with a the client does not /// expect (e.g. ReadRawReply where ReadAtTimeReply was expected), the client must throw /// . /// [Fact] public async Task ReadRawAsync_SidecarRepliesWithWrongKind_ThrowsInvalidDataException() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret) { // Force the server to reply with ReadAtTimeReply instead of ReadRawReply. ReplyWithWrongKind = MessageKind.ReadAtTimeReply, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); await Should.ThrowAsync(() => client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None)); } // ===== Finding-003 / Finding-004: health counter consistency ===== /// /// (Finding 003 + 004) A sidecar-level failure must be classified once: TotalSuccesses /// must stay at 0, TotalFailures must become 1, and TotalQueries / TotalSuccesses / /// TotalFailures must all be updated under the same lock so a concurrent snapshot can /// never observe inflated successes or out-of-band TotalQueries. This pins behaviour so /// a future regression to the "RecordSuccess then undo via ReclassifySuccessAsFailure" /// dance is caught. /// [Fact] public async Task GetHealthSnapshot_SidecarFailure_NeverInflatesSuccessCounter() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret) { OnReadRaw = _ => new ReadRawReply { Success = false, Error = "boom" }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); await Should.ThrowAsync(() => client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None)); var snap = client.GetHealthSnapshot(); snap.TotalQueries.ShouldBe(1); snap.TotalSuccesses.ShouldBe(0); snap.TotalFailures.ShouldBe(1); snap.ConsecutiveFailures.ShouldBe(1); snap.LastError.ShouldNotBeNull(); } /// /// (Finding 003) Concurrent calls + concurrent /// reads must observe consistent counters. Specifically, TotalSuccesses + TotalFailures /// must equal TotalQueries at every observed snapshot (no torn read between an /// Interlocked-incremented TotalQueries and a lock-protected outcome counter). The /// channel serializes calls, so the test is observable: each completed query strictly /// increments either successes or failures by one. /// [Fact] public async Task GetHealthSnapshot_ConcurrentCallsAndReads_CountersAreInternallyConsistent() { var pipe = UniquePipeName(); await using var server = new FakeSidecarServer(pipe, Secret) { OnReadRaw = _ => new ReadRawReply { Success = true }, }; await server.StartAsync(); await using var client = new WonderwareHistorianClient(OptsFor(pipe)); using var stop = new CancellationTokenSource(); var readerSawInconsistent = false; #pragma warning disable xUnit1051 // Internal Task.Run loop drives a polling stress test; cancellation flows via stop.IsCancellationRequested below. var reader = Task.Run(() => { while (!stop.IsCancellationRequested) { var snap = client.GetHealthSnapshot(); // Every completed call increments TotalQueries AND exactly one of // TotalSuccesses or TotalFailures under the same lock; an in-flight call // has not yet incremented any of them. So TotalQueries should always equal // the sum of TotalSuccesses + TotalFailures (no in-between state visible). if (snap.TotalSuccesses + snap.TotalFailures != snap.TotalQueries) { readerSawInconsistent = true; } } }); #pragma warning restore xUnit1051 for (var i = 0; i < 50; i++) { await client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, TestContext.Current.CancellationToken); } stop.Cancel(); await reader; readerSawInconsistent.ShouldBeFalse( "GetHealthSnapshot exposed TotalQueries that disagreed with the sum of TotalSuccesses + TotalFailures — counters are not updated under a single lock."); var final = client.GetHealthSnapshot(); final.TotalQueries.ShouldBe(50); final.TotalSuccesses.ShouldBe(50); final.TotalFailures.ShouldBe(0); } }