Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientWriteHealthTests.cs
T
Joseph Doherty f0d807e7b7 fix(opcuaclient): review r0 minors m1-m10
- m1: the form hint now says ProbeTimeoutSeconds can only shorten Test Connect's
  10 s budget (passing the configured value as the button's budget is shared code -
  follow-up).
- m2: a superseded init no longer overwrites _options (generation-checked under
  _probeLock) or the cert-validation handler (handed back and published with the
  session; detached when not installed; a previous one detached on replace).
- m3: an ack none of whose condition ids resolves throws BadNodeIdUnknown + Warning.
- m4: an ack with no reply (transport failure / non-caller timeout) degrades.
- m5: no session before the gate is the same ServiceResultException(BadNotConnected).
- m6: the caller-cancelled-write deviation is documented.
- m7: a reply during a published outage advances the stamp but does not write
  Healthy; the keep-alive recovery ends the outage.
- m8: test for a non-caller OperationCanceledException write -> Degraded.
- m9: the browse session owns and disposes the user certificate it loaded
  (OpcUaUserIdentityFactory.LoadCertificate; a key-less cert is disposed before the
  throw); AdminUI-host certificate path documented.
- m10: a subscribe overtaken by a shutdown / re-init removes its half-built
  subscription instead of registering it (data and alarm).
- Sync Dispose drains each gate for 500 ms (was 2 s).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:42:29 -04:00

164 lines
8.0 KiB
C#

using System.Text.Json;
using Moq;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// The health contract for the write path and the steady keep-alive: a write RESPONSE — whatever per-node
/// statuses it carries, a refusal included — is the server answering, so it restores Healthy and advances
/// <see cref="DriverHealth.LastSuccessfulRead"/>; a write that got NO response degrades health with the stamp
/// preserved; a caller-cancelled write is not a comms fault. A good keep-alive clears a Degraded left by a failed
/// read/write (it used to only move the stamp, leaving a subscription-only driver Degraded forever).
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientWriteHealthTests
{
private const string SpeedRef = "Plant/dev/Speed";
private static readonly DateTime Stale = new(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly IReadOnlyList<RawTagEntry> Tags =
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
private static (OpcUaClientDriver Driver, Mock<ISession> Session) NewConnectedDriver(DriverHealth health)
{
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-write-health");
var session = new Mock<ISession>(MockBehavior.Loose);
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
drv.SetSessionForTest(session.Object);
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
drv.SetHealthForTest(health);
return (drv, session);
}
private static ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest[] Write() => [new(SpeedRef, 7)];
/// <summary>A refused write (BadNotWritable) is a reply: Healthy again, stamp advanced.</summary>
[Fact]
public async Task A_refused_write_is_a_reply_and_restores_healthy()
{
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read failed"));
using var _ = drv;
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new WriteResponse
{
ResponseHeader = new ResponseHeader(),
Results = [StatusCodes.BadNotWritable],
DiagnosticInfos = [],
});
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
results.Single().StatusCode.ShouldBe(StatusCodes.BadNotWritable, "the upstream refusal still passes through verbatim");
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Healthy);
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
h.LastError.ShouldBeNull();
}
/// <summary>A write with no reply (transport failure) degrades health and preserves the stamp.</summary>
[Fact]
public async Task A_write_with_no_reply_degrades_and_preserves_the_stamp()
{
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
using var _ = drv;
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
results.Single().StatusCode.ShouldBe(0x80050000u); // BadCommunicationError
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Degraded);
h.LastSuccessfulRead.ShouldBe(Stale, "the comms-loss watchdog measures silence from this stamp");
}
/// <summary>
/// A write the CALLER cancelled says nothing about the upstream: health is left alone. (The positive control is
/// the test above — the same throw-path does write health when the failure is the transport's.)
/// </summary>
[Fact]
public async Task A_caller_cancelled_write_leaves_health_alone()
{
var healthy = new DriverHealth(DriverState.Healthy, Stale, null);
var (drv, session) = NewConnectedDriver(healthy);
using var _ = drv;
using var cts = new CancellationTokenSource();
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
.Returns(async (RequestHeader _, WriteValueCollection _, CancellationToken ct) =>
{
cts.Cancel();
await Task.Delay(Timeout.Infinite, ct);
return new WriteResponse();
});
var results = await drv.WriteAsync(Write(), cts.Token);
results.Single().StatusCode.ShouldBe(0x800A0000u); // BadTimeout — outcome unknown
drv.GetHealth().ShouldBe(healthy);
}
/// <summary>A good keep-alive is a reply: it clears a Degraded a failed read left behind.</summary>
[Fact]
public void A_good_keep_alive_clears_a_degraded_left_by_a_failed_read()
{
var (drv, _) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read: BadTimeout"));
using var __ = drv;
drv.RaiseKeepAliveForTest(StatusCodes.Good);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Healthy);
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
h.LastError.ShouldBeNull();
}
/// <summary>
/// Review m8: a write that TIMED OUT (an OperationCanceledException the caller did not cause) is a call with no
/// reply — Degraded, stamp preserved. The genuine positive control for the caller-cancelled test above (same
/// catch branch, the caller's token is the only difference).
/// </summary>
[Fact]
public async Task A_write_that_timed_out_without_caller_cancellation_degrades()
{
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
using var _ = drv;
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException("SDK request timeout"));
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
results.Single().StatusCode.ShouldBe(0x800A0000u); // BadTimeout
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(Stale);
}
/// <summary>
/// Review m7: a reply that was in flight across a bad keep-alive advances the stamp (it is evidence) but does not
/// declare the published outage over — health stays Degraded until the keep-alive recovers.
/// </summary>
[Fact]
public async Task A_reply_during_a_published_outage_advances_the_stamp_but_keeps_degraded()
{
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
using var _ = drv;
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the outage is published");
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new WriteResponse { ResponseHeader = new ResponseHeader(), Results = [StatusCodes.Good], DiagnosticInfos = [] });
await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
drv.RaiseKeepAliveForTest(StatusCodes.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "the keep-alive recovery is what ends the outage");
}
}