Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceRequestTimeoutTests.cs
Joseph Doherty 1f29b215c8 fix(driver-historian-wonderware): resolve Low code-review findings (Driver.Historian.Wonderware-004,005,007,008,010,011,012)
- Driver.Historian.Wonderware-004: ToHistorianEvent synthesises a fresh
  Guid when the upstream EventId is unparseable and logs the substitution
  instead of writing the historian with Guid.Empty.
- Driver.Historian.Wonderware-005: GetHealthSnapshot derives the
  connection-open booleans from the active-node fields so the snapshot
  is self-consistent without depending on the secondary lock.
- Driver.Historian.Wonderware-007: SID-mismatch branch in PipeServer now
  sends a HelloAck { Accepted=false, RejectReason } so the client sees a
  symmetric rejection.
- Driver.Historian.Wonderware-008: classify StartQuery failures —
  connection-class codes drop the connection, query-class codes throw
  QueryClassStartQueryException so the IPC layer surfaces Success=false.
- Driver.Historian.Wonderware-010: RequestTimeoutSeconds now enforced
  via BuildRequestCts linked to the caller's CancellationToken.
- Driver.Historian.Wonderware-011: refreshed XML docs to describe the
  current sidecar / named-pipe architecture (Galaxy.Host / Proxy
  references reframed as historical context).
- Driver.Historian.Wonderware-012: pinned the previously-uncovered
  HistorianDataSource behaviours with five new test files; also removed
  the stale empty tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests
  directory.

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

110 lines
4.6 KiB
C#

using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
/// <summary>
/// Driver.Historian.Wonderware-010 regression. <see cref="HistorianConfiguration.RequestTimeoutSeconds"/>
/// was documented as the "outer safety timeout applied to sync-over-async Historian
/// operations" but was never read or enforced — a hung <c>StartQuery</c> or a slow
/// <c>MoveNext</c> could block the single pipe-server connection thread indefinitely.
/// The fix wires it into the read paths via a linked <see cref="CancellationTokenSource"/>
/// so the documented safety net actually exists.
///
/// The SDK-touching read methods cannot be unit-driven without a live AVEVA Historian.
/// This test pins the helper that derives the effective timeout from the config — the
/// read methods invoke that helper, so a regression in either the helper or the wiring
/// would break the test.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HistorianDataSourceRequestTimeoutTests
{
[Fact]
public void Default_request_timeout_is_60_seconds()
{
new HistorianConfiguration().RequestTimeoutSeconds.ShouldBe(60);
}
[Fact]
public void Positive_request_timeout_is_used_verbatim()
{
InvokeBuildLinkedTokenSource(
new HistorianConfiguration { RequestTimeoutSeconds = 30 },
CancellationToken.None,
out var cts);
cts.ShouldNotBeNull();
// The helper must wire CancelAfter — easiest cross-check is to observe that the
// returned CTS is NOT already cancelled, and that disposing it is safe.
cts!.IsCancellationRequested.ShouldBeFalse();
cts.Dispose();
}
[Fact]
public void Zero_or_negative_request_timeout_is_treated_as_no_timeout()
{
// A zero/negative value means "no outer timeout" — the helper must still return a
// linked CTS so callers can use one code path, but it must not auto-cancel.
InvokeBuildLinkedTokenSource(
new HistorianConfiguration { RequestTimeoutSeconds = 0 },
CancellationToken.None,
out var cts);
cts.ShouldNotBeNull();
cts!.IsCancellationRequested.ShouldBeFalse();
// Give the runtime a moment — a misconfigured CancelAfter(0) would fire immediately.
Thread.Sleep(50);
cts.IsCancellationRequested.ShouldBeFalse("RequestTimeoutSeconds <= 0 must not auto-cancel");
cts.Dispose();
}
[Fact]
public async Task Small_timeout_cancels_the_linked_token()
{
// 50 ms timeout — sleep 250 ms then assert the linked CTS has fired.
InvokeBuildLinkedTokenSource(
new HistorianConfiguration { RequestTimeoutSeconds = 1 }, // smallest non-zero whole-second value
CancellationToken.None,
out var cts);
cts.ShouldNotBeNull();
// The wall-clock cost of waiting a full second per test is acceptable — this
// pins the actual CancelAfter wiring rather than just the conditional logic.
await Task.Delay(1500);
cts!.IsCancellationRequested.ShouldBeTrue("RequestTimeoutSeconds=1 must cancel within 1.5s");
cts.Dispose();
}
[Fact]
public void Inbound_cancellation_propagates_into_the_linked_token()
{
using var outer = new CancellationTokenSource();
InvokeBuildLinkedTokenSource(
new HistorianConfiguration { RequestTimeoutSeconds = 60 },
outer.Token,
out var cts);
cts.ShouldNotBeNull();
cts!.IsCancellationRequested.ShouldBeFalse();
outer.Cancel();
cts.IsCancellationRequested.ShouldBeTrue("cancelling the caller's CT must cancel the linked CTS");
cts.Dispose();
}
private static void InvokeBuildLinkedTokenSource(
HistorianConfiguration cfg, CancellationToken ct, out CancellationTokenSource? cts)
{
// The helper is internal so the InternalsVisibleTo on the data-source project lets
// us bind to it directly. Reflection keeps the test resilient if the method name is
// ever shortened.
var method = typeof(HistorianDataSource)
.GetMethod("BuildRequestCts", BindingFlags.Static | BindingFlags.NonPublic);
method.ShouldNotBeNull(
"HistorianDataSource.BuildRequestCts must exist — wires RequestTimeoutSeconds into the read paths");
cts = (CancellationTokenSource?)method!.Invoke(null, new object[] { cfg, ct });
}
}