Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs
Joseph Doherty 5499b817c8 fix(driver-historian-wonderware-client): resolve High code-review finding (Driver.Historian.Wonderware.Client-001)
WonderwareHistorianClient.ReadAtTimeAsync passed the sidecar's reply.Samples
straight through ToSnapshots, which violated the IHistorianDataSource
contract: the result MUST be the same length and order as the requested
timestampsUtc, with gaps returned as Bad-quality snapshots. If the sidecar
dropped or reordered samples, OPC UA HistoryReadAtTime would silently
misalign values with timestamps.

Add an AlignAtTimeSnapshots helper that indexes the returned samples by
timestamp ticks, builds the result array at timestampsUtc.Count in request
order, and emits a Bad-quality (0x80000000) snapshot for any requested
timestamp the sidecar did not return.

Add the ReadAtTimeAsync_PartialAndReorderedReply_AlignsByTimestamp_AndFillsGapsAsBad
regression test where the fake returns a partial, reordered sample set.

Update code-reviews/Driver.Historian.Wonderware.Client/findings.md: -001
Resolved, open-finding count 10 -> 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:59:40 -04:00

367 lines
15 KiB
C#

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;
/// <summary>
/// End-to-end tests for <see cref="WonderwareHistorianClient"/>: every interface method
/// round-trips through a real named pipe against the in-process
/// <see cref="FakeSidecarServer"/>, 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.
/// </summary>
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<object>(42.0),
Quality = 192, // Good
TimestampUtcTicks = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc).Ticks,
},
new HistorianSampleDto
{
ValueBytes = MessagePackSerializer.Serialize<object>(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<object>(3.0),
Quality = 192, TimestampUtcTicks = t3.Ticks,
},
new HistorianSampleDto
{
ValueBytes = MessagePackSerializer.Serialize<object>(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<InvalidOperationException>(() =>
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<UnauthorizedAccessException>(() =>
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<InvalidOperationException>(() =>
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();
}
}