fix(client-shared): resolve Medium code-review findings (Client.Shared-001, -002, -007, -008)

Client.Shared-001: lowered the OnAlarmEventNotification early-return guard
from <6 to <1; per-index field guards already default missing fields safely.
Client.Shared-002: GetRedundancyInfoAsync replaces unguarded unboxing casts
with StatusCode.IsGood + Convert.ToInt32/ToByte, defaulting on bad reads.
Client.Shared-007: alarm fallback Task.Run guards on ReferenceEquals(session,
_session) and drops stale alarms on ObjectDisposedException after failover.
Client.Shared-008: WriteValueAsync rejects type inference from bad/null reads;
ValueConverter wraps parse failures in a descriptive FormatException.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 08:11:23 -04:00
parent aa142f6dd4
commit 7e54e1e4a0
5 changed files with 267 additions and 30 deletions

View File

@@ -293,6 +293,46 @@ public class OpcUaClientServiceTests : IDisposable
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42));
}
/// <summary>
/// Verifies that writing a string to a node whose current read returns a bad status
/// surfaces a clear error instead of writing a mistyped string value (Client.Shared-008).
/// </summary>
[Fact]
public async Task WriteValueAsync_StringValueWithBadReadStatus_ThrowsInvalidOperationException()
{
var session = new FakeSessionAdapter
{
ReadResponse = new DataValue(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
ex.Message.ShouldContain("Cannot infer target type");
}
/// <summary>
/// Verifies that writing a string to a node whose read returns bad status and null Value
/// surfaces a clear error for both the bad-status case (Client.Shared-008).
/// </summary>
[Fact]
public async Task WriteValueAsync_StringValueWithBadStatus_MessageMentionsNode()
{
var session = new FakeSessionAdapter
{
ReadResponse = new DataValue(StatusCodes.BadNotReadable)
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
ex.Message.ShouldContain("ns=2;s=MyNode");
}
// --- Browse tests ---
/// <summary>
@@ -842,6 +882,120 @@ public class OpcUaClientServiceTests : IDisposable
_service.GetRedundancyInfoAsync());
}
/// <summary>
/// Verifies that RedundancySupport boxed as a different numeric type (e.g. short) is handled
/// without InvalidCastException — defensive Convert.ToInt32 coercion (Client.Shared-002).
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_RedundancySupportBoxedAsShort_DoesNotThrow()
{
var session = new FakeSessionAdapter
{
ReadResponseFunc = nodeId =>
{
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
// Boxed as short instead of int — simulates a non-conforming server
return new DataValue(new Variant((short)RedundancySupport.Warm), StatusCodes.Good);
if (nodeId == VariableIds.Server_ServiceLevel)
return new DataValue(new Variant((int)200), StatusCodes.Good); // int instead of byte
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
}
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var info = await _service.GetRedundancyInfoAsync();
info.Mode.ShouldBe("Warm");
info.ServiceLevel.ShouldBe((byte)200);
}
/// <summary>
/// Verifies that a bad-status response for RedundancySupport/ServiceLevel falls back to defaults
/// rather than throwing (Client.Shared-002).
/// </summary>
[Fact]
public async Task GetRedundancyInfoAsync_BadStatusOnRequiredReads_ReturnsDefaults()
{
var session = new FakeSessionAdapter
{
ReadResponseFunc = nodeId =>
{
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
return new DataValue(StatusCodes.BadNotReadable); // bad status, null Value
if (nodeId == VariableIds.Server_ServiceLevel)
return new DataValue(StatusCodes.BadNotReadable);
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
}
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var info = await _service.GetRedundancyInfoAsync();
info.Mode.ShouldBe("None");
info.ServiceLevel.ShouldBe((byte)0);
}
// --- Alarm truncated-fields tests (Client.Shared-001) ---
/// <summary>
/// Verifies that an alarm event with fewer than 6 fields (but at least 1) is still raised
/// with available fields — the old hard &lt;6 early return silently dropped it (Client.Shared-001).
/// </summary>
[Fact]
public async Task OnAlarmEvent_TruncatedFields_StillRaisesEvent()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
AlarmEventArgs? received = null;
_service.AlarmEvent += (_, e) => received = e;
await _service.SubscribeAlarmsAsync();
var handle = fakeSub.ActiveHandles.First();
// Only 3 fields — EventId, EventType, SourceName — fewer than the old < 6 threshold
var fields = new EventFieldList
{
EventFields =
[
new Variant(new byte[] { 9, 8, 7 }), // 0: EventId
new Variant(ObjectTypeIds.AlarmConditionType), // 1: EventType
new Variant("PartialSource") // 2: SourceName
]
};
fakeSub.SimulateEvent(handle, fields);
received.ShouldNotBeNull();
received!.SourceName.ShouldBe("PartialSource");
received.Severity.ShouldBe((ushort)0); // default — field 5 not present
}
/// <summary>
/// Verifies that a null or empty event field list is silently ignored (defensive guard).
/// </summary>
[Fact]
public async Task OnAlarmEvent_EmptyFields_DoesNotRaiseEvent()
{
var fakeSub = new FakeSubscriptionAdapter();
var session = new FakeSessionAdapter { NextSubscription = fakeSub };
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings());
var eventCount = 0;
_service.AlarmEvent += (_, _) => eventCount++;
await _service.SubscribeAlarmsAsync();
var handle = fakeSub.ActiveHandles.First();
fakeSub.SimulateEvent(handle, new EventFieldList { EventFields = [] });
eventCount.ShouldBe(0);
}
// --- Failover tests ---
/// <summary>