Files
lmxopcua/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Fakes/FakeSessionAdapter.cs
T
Joseph Doherty 2d8bb037f5 fix(client): failover runs under the subscription gate; ConnectAsync refuses when connected
R08 4.5: every subscription operation that awaits I/O and the failover's
teardown + reconnect + replay share one async gate, and every read/write of the
subscription fields (failover's null-outs included) is under _subscriptionLock.
A subscribe in flight when a failover starts is recorded first and then replayed
onto the new session instead of landing on the dying session's subscription. The
dead session is disposed before the gate is taken so a blocked call fails fast.
A DisconnectAsync during a failover is no longer undone by it. ConnectAsync on a
connected or reconnecting client throws instead of overwriting (and leaking) the
live session.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:30:35 -04:00

267 lines
11 KiB
C#

using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Fakes;
/// <summary>
/// Test double for <see cref="ISessionAdapter" /> used to simulate reads, writes, browsing, history, and failover callbacks.
/// </summary>
internal sealed class FakeSessionAdapter : ISessionAdapter
{
private readonly List<FakeSubscriptionAdapter> _createdSubscriptions = [];
private Action<bool>? _keepAliveCallback;
/// <summary>
/// Gets a value indicating whether the fake session has been closed through the client disconnect path.
/// </summary>
public bool Closed { get; private set; }
/// <summary>
/// Gets a value indicating whether the fake session has been disposed.
/// </summary>
public bool Disposed { get; private set; }
/// <summary>Gets the number of times ReadValueAsync has been called.</summary>
public int ReadCount { get; private set; }
/// <summary>Gets the number of times WriteValueAsync has been called.</summary>
public int WriteCount { get; private set; }
/// <summary>Gets the number of times BrowseAsync has been called.</summary>
public int BrowseCount { get; private set; }
/// <summary>Gets the number of times BrowseNextAsync has been called.</summary>
public int BrowseNextCount { get; private set; }
/// <summary>Gets the number of times HistoryReadRawAsync has been called.</summary>
public int HistoryReadRawCount { get; private set; }
/// <summary>Gets the number of times HistoryReadAggregateAsync has been called.</summary>
public int HistoryReadAggregateCount { get; private set; }
// Configurable responses
/// <summary>Gets or sets the data value returned by read operations.</summary>
public DataValue? ReadResponse { get; set; }
/// <summary>Gets or sets a function to generate read responses dynamically based on the node ID.</summary>
public Func<NodeId, DataValue>? ReadResponseFunc { get; set; }
/// <summary>Gets or sets the status code returned by write operations.</summary>
public StatusCode WriteResponse { get; set; } = StatusCodes.Good;
/// <summary>Gets or sets a value indicating whether read operations should throw an exception.</summary>
public bool ThrowOnRead { get; set; }
/// <summary>Gets or sets a value indicating whether write operations should throw an exception.</summary>
public bool ThrowOnWrite { get; set; }
/// <summary>Gets or sets a value indicating whether browse operations should throw an exception.</summary>
public bool ThrowOnBrowse { get; set; }
/// <summary>Gets or sets the browse references returned by browse operations.</summary>
public ReferenceDescriptionCollection BrowseResponse { get; set; } = [];
/// <summary>Gets or sets the continuation point for browse operations.</summary>
public byte[]? BrowseContinuationPoint { get; set; }
/// <summary>Gets or sets the browse references returned by browse-next operations.</summary>
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
/// <summary>Gets or sets the continuation point for browse-next operations.</summary>
public byte[]? BrowseNextContinuationPoint { get; set; }
/// <summary>Gets or sets the historical values returned by raw history-read operations.</summary>
public List<DataValue> HistoryReadRawResponse { get; set; } = [];
/// <summary>Gets or sets the historical values returned by aggregate history-read operations.</summary>
public List<DataValue> HistoryReadAggregateResponse { get; set; } = [];
/// <summary>Gets or sets a value indicating whether raw history-read operations should throw an exception.</summary>
public bool ThrowOnHistoryReadRaw { get; set; }
/// <summary>Gets or sets a value indicating whether aggregate history-read operations should throw an exception.</summary>
public bool ThrowOnHistoryReadAggregate { get; set; }
/// <summary>
/// Gets or sets the next fake subscription returned when the client creates a monitored-item subscription.
/// If unset, the fake builds a new subscription automatically.
/// </summary>
public FakeSubscriptionAdapter? NextSubscription { get; set; }
/// <summary>
/// Gets the fake subscriptions created by this session so tests can inspect replay and cleanup behavior.
/// </summary>
public IReadOnlyList<FakeSubscriptionAdapter> CreatedSubscriptions => _createdSubscriptions;
/// <inheritdoc />
public bool Connected { get; set; } = true;
/// <inheritdoc />
public string SessionId { get; set; } = "ns=0;i=12345";
/// <inheritdoc />
public string SessionName { get; set; } = "FakeSession";
/// <inheritdoc />
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
/// <inheritdoc />
public string ServerName { get; set; } = "FakeServer";
/// <inheritdoc />
public string SecurityMode { get; set; } = "None";
/// <inheritdoc />
public string SecurityPolicyUri { get; set; } = "http://opcfoundation.org/UA/SecurityPolicy#None";
/// <inheritdoc />
public NamespaceTable NamespaceUris { get; set; } = new();
/// <inheritdoc />
public void RegisterKeepAliveHandler(Action<bool> callback)
{
_keepAliveCallback = callback;
}
/// <inheritdoc />
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct)
{
ReadCount++;
if (ThrowOnRead)
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, "Node not found");
if (ReadResponseFunc != null)
return Task.FromResult(ReadResponseFunc(nodeId));
return Task.FromResult(ReadResponse ?? new DataValue(new Variant(0), StatusCodes.Good));
}
/// <inheritdoc />
public Task<StatusCode> WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct)
{
WriteCount++;
if (ThrowOnWrite)
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, "Node not found");
return Task.FromResult(WriteResponse);
}
/// <inheritdoc />
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
{
BrowseCount++;
if (ThrowOnBrowse)
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, "Node not found");
return Task.FromResult((BrowseContinuationPoint, BrowseResponse));
}
/// <inheritdoc />
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct)
{
BrowseNextCount++;
return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
{
HistoryReadRawCount++;
if (ThrowOnHistoryReadRaw)
throw new ServiceResultException(StatusCodes.BadHistoryOperationUnsupported, "History not supported");
return Task.FromResult<IReadOnlyList<DataValue>>(HistoryReadRawResponse);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs,
CancellationToken ct)
{
HistoryReadAggregateCount++;
if (ThrowOnHistoryReadAggregate)
throw new ServiceResultException(StatusCodes.BadHistoryOperationUnsupported, "History not supported");
return Task.FromResult<IReadOnlyList<DataValue>>(HistoryReadAggregateResponse);
}
/// <summary>
/// Optional gate that, when set, holds <see cref="CreateSubscriptionAsync" /> open until completed, so a test can keep
/// a subscribe in flight while something else (a failover) happens. Signalled via <see cref="CreateSubscriptionEntered" />.
/// </summary>
public TaskCompletionSource? CreateSubscriptionGate { get; set; }
/// <summary>Completed when <see cref="CreateSubscriptionAsync" /> is entered while a gate is set.</summary>
public TaskCompletionSource CreateSubscriptionEntered { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <inheritdoc />
public async Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct)
{
if (CreateSubscriptionGate is { } gate)
{
CreateSubscriptionEntered.TrySetResult();
await gate.Task;
}
lock (_createdSubscriptions)
{
var sub = NextSubscription ?? new FakeSubscriptionAdapter();
NextSubscription = null;
_createdSubscriptions.Add(sub);
return sub;
}
}
/// <summary>
/// Number of times <see cref="CallMethodAsync"/> was invoked so tests can assert
/// acknowledgment workflows reached the session adapter.
/// </summary>
public int CallMethodCount { get; private set; }
/// <summary>
/// When set, <see cref="CallMethodAsync"/> throws this exception — used to simulate
/// a bad method call status surfacing as a <see cref="ServiceResultException"/>.
/// </summary>
public Exception? CallMethodException { get; set; }
/// <summary>
/// Records the input arguments passed to each <see cref="CallMethodAsync"/> invocation.
/// Index 0 is the first call, index 1 the second, etc.
/// </summary>
public List<object[]> CallMethodInputArgs { get; } = [];
/// <summary>
/// Records the object node id passed to each <see cref="CallMethodAsync"/> invocation,
/// so tests can assert which condition object a method was invoked on.
/// </summary>
public List<NodeId> CallMethodObjectIds { get; } = [];
/// <summary>
/// Records the method node id passed to each <see cref="CallMethodAsync"/> invocation,
/// so tests can assert the correct Part 9 method (e.g. Enable/Disable) was invoked.
/// </summary>
public List<NodeId> CallMethodMethodIds { get; } = [];
/// <inheritdoc />
public Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
CancellationToken ct = default)
{
CallMethodCount++;
CallMethodObjectIds.Add(objectId);
CallMethodMethodIds.Add(methodId);
CallMethodInputArgs.Add(inputArguments);
if (CallMethodException != null)
throw CallMethodException;
return Task.FromResult<IList<object>?>(null);
}
/// <inheritdoc />
public Task CloseAsync(CancellationToken ct)
{
Closed = true;
Connected = false;
return Task.CompletedTask;
}
/// <summary>
/// Marks the fake session as disposed so tests can verify cleanup after disconnect or failover.
/// </summary>
public void Dispose()
{
Disposed = true;
Connected = false;
}
/// <summary>
/// Simulates a keep-alive event.
/// </summary>
/// <param name="isGood">Whether the keep-alive status is good.</param>
public void SimulateKeepAlive(bool isGood)
{
_keepAliveCallback?.Invoke(isGood);
}
}