using Moq; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests; /// /// Unit-only coverage of 's pre-connect /// validation. These tests do not require a live OPC UA endpoint and are safe to /// run without the opc-plc Docker fixture. /// [Trait("Category", "Unit")] public sealed class OpcUaClientDriverBrowserTests { private readonly OpcUaClientDriverBrowser _sut = new(); /// The DriverType key must match the AdminUI's persisted value. [Fact] public void DriverType_is_OpcUaClient() => _sut.DriverType.ShouldBe("OpcUaClient"); /// An empty endpoint must fail fast with a clear EndpointUrl-mentioning message. [Fact] public async Task OpenAsync_with_empty_endpoint_throws() { var json = """{"EndpointUrl":"","EndpointUrls":[]}"""; var ex = await Should.ThrowAsync( () => _sut.OpenAsync(json, TestContext.Current.CancellationToken)); ex.Message.ShouldContain("EndpointUrl"); } /// A JSON literal that deserializes to null must fail fast. [Fact] public async Task OpenAsync_with_null_json_throws() { var ex = await Should.ThrowAsync( () => _sut.OpenAsync("null", TestContext.Current.CancellationToken)); ex.Message.ShouldContain("null"); } /// Certificate auth is not supported by the browser; the failure message /// must say so explicitly rather than surfacing a downstream COM/SDK error. /// OpcUaAuthType.Certificate serializes as the numeric value 2 under the /// browser's default System.Text.Json options (no string-enum converter). [Fact] public async Task OpenAsync_with_certificate_auth_throws_clear_message() { var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":2}"""; var ex = await Should.ThrowAsync( () => _sut.OpenAsync(json, TestContext.Current.CancellationToken)); ex.Message.ShouldContain("Certificate"); } // ---- Driver.OpcUaClient.Browser-003: JsonStringEnumConverter must be present ---- /// /// AdminUI emits enum-valued driver-config fields (SecurityPolicy, SecurityMode, /// AuthType, TargetNamespaceKind) as their string names (e.g. /// "SecurityPolicy":"Basic256Sha256"). Without a /// on the browser's /// JsonSerializerOptions the deserialization throws a /// before the browser's own validation can /// run, giving the caller a confusing parse error instead of the expected /// domain-specific message. /// /// This test passes a JSON blob with "AuthType":"Certificate" (string form) and /// asserts that the browser's validation fires — i.e. deserialization succeeds and the /// resulting mentions "Certificate". Before the /// fix, a raw surfaces instead. /// [Fact] public async Task OpenAsync_with_string_enum_AuthType_deserializes_correctly() { // "AuthType":"Certificate" — STRING form, as AdminUI emits. var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}"""; // The browser should reach its own Certificate-auth guard and throw // InvalidOperationException with a message that mentions "Certificate". // Without JsonStringEnumConverter the deserialization itself throws JsonException, // which means this Should.ThrowAsync assertion fails. var ex = await Should.ThrowAsync( () => _sut.OpenAsync(json, TestContext.Current.CancellationToken)); // The browser's own guard must fire (not a JsonException from the deserializer). ex.Message.ShouldContain("Certificate"); } /// /// Companion to : /// confirms that /// still accepts the integer/numeric ordinal form that the live-fixture tests currently /// rely on, so no regression is introduced for existing numeric-authored configs. /// [Fact] public async Task OpenAsync_with_numeric_enum_AuthType_still_works() { // "AuthType":2 — numeric ordinal form (OpcUaAuthType.Certificate == 2). var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":2}"""; var ex = await Should.ThrowAsync( () => _sut.OpenAsync(json, TestContext.Current.CancellationToken)); // JsonStringEnumConverter accepts both string and numeric ordinal forms. ex.Message.ShouldContain("Certificate"); } // ---- Driver.OpcUaClient.Browser-001: AttributesAsync must refresh LastUsedUtc ---- /// /// must be updated on every call /// including , which the /// uses for idle eviction. Before the fix /// the method returned immediately without touching the property, causing a /// session that only received AttributesAsync calls to be evicted early. /// [Fact] public async Task AttributesAsync_updates_LastUsedUtc() { var ct = TestContext.Current.CancellationToken; // Arrange: build a minimal OpcUaClientBrowseSession without a live server. // AttributesAsync does not call ISession — MockBehavior.Loose is safe. var mockSession = new Mock(MockBehavior.Loose); var nsMap = NamespaceMap.FromTable(new NamespaceTable()); var sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder); var before = sut.LastUsedUtc; // Introduce a small delay so clock advances at least one tick. await Task.Delay(5, ct); // Act var attrs = await sut.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct); // Assert: LastUsedUtc must have been refreshed, and the result must be empty // (OPC UA picker treats variables as leaves, no attribute side-panel). attrs.ShouldBeEmpty(); sut.LastUsedUtc.ShouldBeGreaterThan(before, "AttributesAsync must refresh LastUsedUtc to satisfy the IBrowseSession contract"); } }