fix(opcuaclient): review fixes - drop driver-side OperationLimits chunking (SDK batches), diagnostics as OTel counter, safe discovery selection, options validation at load (#273 #275 #276 #277 #278)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:55:53 -04:00
parent 935ba8d1d4
commit 1219b7b13e
13 changed files with 406 additions and 652 deletions
@@ -20,7 +20,6 @@ public sealed class OpcUaClientTuningAndDiscoveryTests
o.SubscriptionMaxNotificationsPerPublish.ShouldBe(0u);
o.SubscriptionPriority.ShouldBe((byte)0);
o.SubscriptionPublishingInterval.ShouldBeNull();
o.HonorServerOperationLimits.ShouldBeTrue();
o.DiscoveryUrl.ShouldBeNull();
o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse();
o.CertificateValidation.IssuerStorePath.ShouldBeNull();
@@ -33,8 +32,7 @@ public sealed class OpcUaClientTuningAndDiscoveryTests
var o = OpcUaClientDriverFactoryExtensions.ParseOptions("d", """
{"subscriptionPublishingInterval":"00:00:02","subscriptionKeepAliveCount":20,
"subscriptionLifetimeCount":600,"subscriptionMaxNotificationsPerPublish":500,
"subscriptionPriority":9,"honorServerOperationLimits":false,
"discoveryUrl":"opc.tcp://lds:4840",
"subscriptionPriority":9, "discoveryUrl":"opc.tcp://lds:4840","discoveryServerUri":"urn:plant:l1",
"certificateValidation":{"rejectUnknownRevocationStatus":true,"issuerStorePath":"/pki/issuers"}}
""");
@@ -43,8 +41,8 @@ public sealed class OpcUaClientTuningAndDiscoveryTests
o.SubscriptionLifetimeCount.ShouldBe(600u);
o.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u);
o.SubscriptionPriority.ShouldBe((byte)9);
o.HonorServerOperationLimits.ShouldBeFalse();
o.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840");
o.DiscoveryServerUri.ShouldBe("urn:plant:l1");
o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue();
o.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers");
@@ -119,21 +117,82 @@ public sealed class OpcUaClientTuningAndDiscoveryTests
}
[Fact]
public void ResolveEndpointCandidates_prepends_discovered_urls_and_dedups_case_insensitively()
public void ResolveEndpointCandidates_puts_configured_endpoints_before_discovered_and_dedups()
{
var o = new OpcUaClientDriverOptions { EndpointUrls = ["opc.tcp://Static:1", "opc.tcp://found:2"] };
var merged = OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://found:2", "opc.tcp://new:3"]);
merged.ShouldBe(["opc.tcp://found:2", "opc.tcp://new:3", "opc.tcp://Static:1"]);
merged.ShouldBe(["opc.tcp://Static:1", "opc.tcp://found:2", "opc.tcp://new:3"]);
}
[Fact]
public void ResolveEndpointCandidates_uses_the_single_EndpointUrl_after_discovered_when_no_list()
public void ResolveEndpointCandidates_uses_the_single_EndpointUrl_before_discovered_when_no_list()
{
var o = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:9" };
OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://d:1"]).ShouldBe(["opc.tcp://d:1", "opc.tcp://only:9"]);
OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://d:1"]).ShouldBe(["opc.tcp://only:9", "opc.tcp://d:1"]);
}
private static OpcUaClientDriver.DiscoveredEndpoint De(string appUri, string url) => new(appUri, url);
[Fact]
public void SelectDiscoveredUrls_with_one_distinct_server_uses_it_and_dedups()
{
var urls = OpcUaClientDriver.SelectDiscoveredUrls(
[De("urn:a", "opc.tcp://a:1"), De("urn:a", "opc.tcp://A:1"), De("urn:a", "opc.tcp://a:2")], null);
urls.ShouldBe(["opc.tcp://a:1", "opc.tcp://a:2"]);
}
[Fact]
public void SelectDiscoveredUrls_with_several_servers_and_no_filter_fails_naming_them()
{
var ex = Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.SelectDiscoveredUrls(
[De("urn:line1", "opc.tcp://l1:1"), De("urn:line2", "opc.tcp://l2:1")], null));
ex.Message.ShouldContain("urn:line1");
ex.Message.ShouldContain("urn:line2");
ex.Message.ShouldContain("DiscoveryServerUri");
}
[Fact]
public void SelectDiscoveredUrls_filter_picks_only_that_server_even_when_several_are_listed()
{
var urls = OpcUaClientDriver.SelectDiscoveredUrls(
[De("urn:line1", "opc.tcp://l1:1"), De("urn:line2", "opc.tcp://l2:1"), De("urn:line2", "opc.tcp://l2:2")],
"urn:line2");
urls.ShouldBe(["opc.tcp://l2:1", "opc.tcp://l2:2"]);
}
[Fact]
public void SelectDiscoveredUrls_filter_matching_nothing_yields_empty_not_another_server()
{
OpcUaClientDriver.SelectDiscoveredUrls([De("urn:line1", "opc.tcp://l1:1")], "urn:other").ShouldBeEmpty();
OpcUaClientDriver.SelectDiscoveredUrls([], null).ShouldBeEmpty();
}
// ---- #273: validation at options load ----
[Theory]
[InlineData("""{"subscriptionKeepAliveCount":0}""")]
[InlineData("""{"subscriptionKeepAliveCount":10,"subscriptionLifetimeCount":29}""")]
[InlineData("""{"discoveryUrl":"not a uri"}""")]
public void ParseOptions_rejects_out_of_range_tuning_with_a_clear_message(string json)
{
var ex = Should.Throw<InvalidOperationException>(
() => OpcUaClientDriverFactoryExtensions.ParseOptions("drv-bad", json));
ex.Message.ShouldContain("drv-bad");
ex.Message.ShouldContain("invalid");
}
[Fact]
public void Factory_CreateInstance_fails_fast_on_a_bad_tuning_config()
{
Should.Throw<InvalidOperationException>(() => OpcUaClientDriverFactoryExtensions.CreateInstance(
"drv-bad", """{"subscriptionLifetimeCount":5}"""));
}
[Fact]