Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs
Joseph Doherty 879925180b fix(driver-historian-wonderware-client): resolve Low code-review findings (Driver.Historian.Wonderware.Client-003,004,006,008,010)
- Driver.Historian.Wonderware.Client-003: replaced the mixed Interlocked
  + healthLock counters with RecordOutcome that touches _totalQueries
  and exactly one of _totalSuccesses / _totalFailures under one
  acquisition.
- Driver.Historian.Wonderware.Client-004: InvokeAndClassifyAsync routes
  transport + sidecar classification through a single RecordOutcome
  call; the legacy ReclassifySuccessAsFailure two-step is gone.
- Driver.Historian.Wonderware.Client-006: removed the dead
  ReconnectInitialBackoff / ReconnectMaxBackoff options and added a
  doc <remarks> stating the channel performs a single in-place
  reconnect; retry/backoff stays with the caller.
- Driver.Historian.Wonderware.Client-008: the audit-suppression comment
  block now records advisory titles, why neither applies, and the
  revisit trigger.
- Driver.Historian.Wonderware.Client-010: reworded Dispose() to claim
  deadlock-safety and added a GetHealthSnapshot summary documenting the
  single-channel collapse + counter invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:12:16 -04:00

586 lines
24 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();
}
// ===== Finding-009: missing edge-case tests =====
/// <summary>
/// (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.
/// </summary>
[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);
}
/// <summary>
/// (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.
/// </summary>
[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<Exception>(() =>
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
}
/// <summary>
/// (4) A stalled sidecar that never sends a reply must cause an
/// <see cref="OperationCanceledException"/> within the configured CallTimeout.
/// </summary>
[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<OperationCanceledException>(() =>
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
}
/// <summary>
/// (5) <see cref="HistoryAggregateType.Total"/> must throw
/// <see cref="NotSupportedException"/> because Wonderware AnalogSummary has no Total
/// aggregate column.
/// </summary>
[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<NotSupportedException>(() =>
client.ReadProcessedAsync("Tag",
DateTime.UtcNow, DateTime.UtcNow, TimeSpan.FromMinutes(1),
HistoryAggregateType.Total, CancellationToken.None));
}
/// <summary>
/// (6) When the sidecar replies with a <see cref="MessageKind"/> the client does not
/// expect (e.g. ReadRawReply where ReadAtTimeReply was expected), the client must throw
/// <see cref="InvalidDataException"/>.
/// </summary>
[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<InvalidDataException>(() =>
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
}
// ===== Finding-003 / Finding-004: health counter consistency =====
/// <summary>
/// (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.
/// </summary>
[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<InvalidOperationException>(() =>
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();
}
/// <summary>
/// (Finding 003) Concurrent calls + concurrent <see cref="WonderwareHistorianClient.GetHealthSnapshot"/>
/// 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.
/// </summary>
[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);
}
}