/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ // CA2016: integration tests intentionally call cleanup in finally without forwarding the test // cancellation token. The test CT may already be cancelled (the [CancelAfter] timeout), which // would prevent cleanup from running. CloseAsync/DisposeAsync must complete regardless. #pragma warning disable CA2016 using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Client; using Opc.Ua.Client.Subscriptions; using Opc.Ua.Client.TestFramework; using ManagedSessionType = Opc.Ua.Client.ManagedSession; namespace Opc.Ua.Subscriptions.Tests { /// /// End-to-end integration tests for /// + against the /// in-process reference fixture server. Verifies that the V2 /// subscription engine, exposed through /// , can /// drive subscriptions and deliver data change notifications. /// [TestFixture] [Category("Client")] [Category("ClientBuilder")] [Category("ManagedSession")] [SetCulture("en-us")] [SetUICulture("en-us")] public class ManagedSessionSubscriptionManagerIntegrationTests : ClientTestFramework { [OneTimeSetUp] public override Task OneTimeSetUpAsync() { SupportsExternalServerUrl = true; SingleSession = false; return OneTimeSetUpCoreAsync(securityNone: true); } [OneTimeTearDown] public override Task OneTimeTearDownAsync() { return base.OneTimeTearDownAsync(); } [SetUp] public override Task SetUpAsync() { return base.SetUpAsync(); } [TearDown] public override Task TearDownAsync() { return base.TearDownAsync(); } [Test] [Order(100)] [CancelAfter(60_000)] public async Task BuilderCreatesManagedSessionWithV2Engine( CancellationToken ct) { ConfiguredEndpoint endpoint = await ClientFixture .GetEndpointAsync(ServerUrl, SecurityPolicies.None) .ConfigureAwait(false); ManagedSessionType session = await new ManagedSessionBuilder( ClientFixture.Config, Telemetry) .UseEndpoint(endpoint) .WithSessionName(nameof(BuilderCreatesManagedSessionWithV2Engine)) .WithSessionTimeout(TimeSpan.FromSeconds(60)) .ConnectAsync(ct) .ConfigureAwait(false); try { Assert.That(session, Is.Not.Null); Assert.That(session.Connected, Is.True); Assert.That( session.TryGetSubscriptionManager(out ISubscriptionManager? manager), Is.True); Assert.That(manager, Is.Not.Null); Assert.That(manager.Count, Is.Zero); TestContext.Out.WriteLine( "ManagedSession connected, SessionId: {0}", session.SessionId); } finally { await session.CloseAsync().ConfigureAwait(false); await session.DisposeAsync().ConfigureAwait(false); } } [Test] [Order(200)] [CancelAfter(60_000)] public async Task BuilderAddSubscriptionCreatesServerSubscription( CancellationToken ct) { ConfiguredEndpoint endpoint = await ClientFixture .GetEndpointAsync(ServerUrl, SecurityPolicies.None) .ConfigureAwait(false); ManagedSessionType session = await new ManagedSessionBuilder( ClientFixture.Config, Telemetry) .UseEndpoint(endpoint) .WithSessionName(nameof(BuilderAddSubscriptionCreatesServerSubscription)) .ConnectAsync(ct) .ConfigureAwait(false); var handler = new RecordingHandler(); try { ISubscription subscription = session.AddSubscription( handler, new Client.Subscriptions.SubscriptionOptions { PublishingInterval = TimeSpan.FromMilliseconds(500), KeepAliveCount = 10, LifetimeCount = 100, Priority = 0 }); Assert.That(subscription, Is.Not.Null); Assert.That(session.RequireSubscriptionManager().Count, Is.EqualTo(1)); // Wait for the subscription state machine to create // the subscription on the server (asynchronously after // Add returns). bool created = await WaitForAsync( () => subscription.Created, TimeSpan.FromSeconds(10), ct).ConfigureAwait(false); Assert.That( created, Is.True, "Subscription should be created on the server"); TestContext.Out.WriteLine( "V2 subscription created: PublishingInterval={0}, KeepAliveCount={1}, LifetimeCount={2}", subscription.CurrentPublishingInterval, subscription.CurrentKeepAliveCount, subscription.CurrentLifetimeCount); Assert.That( subscription.CurrentPublishingInterval, Is.EqualTo(TimeSpan.FromMilliseconds(500))); await subscription.DisposeAsync().ConfigureAwait(false); } finally { await session.CloseAsync().ConfigureAwait(false); await session.DisposeAsync().ConfigureAwait(false); } } [Test] [Order(250)] [CancelAfter(60_000)] public async Task SubscriptionWithMonitoredItemDeliversDataChanges( CancellationToken ct) { ConfiguredEndpoint endpoint = await ClientFixture .GetEndpointAsync(ServerUrl, SecurityPolicies.None) .ConfigureAwait(false); ManagedSessionType session = await new ManagedSessionBuilder( ClientFixture.Config, Telemetry) .UseEndpoint(endpoint) .WithSessionName(nameof(SubscriptionWithMonitoredItemDeliversDataChanges)) .WithSessionTimeout(TimeSpan.FromSeconds(60)) .ConnectAsync(ct) .ConfigureAwait(false); var handler = new RecordingHandler(); try { // Regression: a V2 subscription must have PublishingEnabled set // AND a monitored item added through the subscription for // data-change notifications to reach the handler. The // RedundantClient sample previously used the classic // Subscription.FastDataChangeCallback (ignored by the V2 engine) // and left PublishingEnabled at its false default, so it received // no notifications at all. ISubscription subscription = session.AddSubscription( handler, new Client.Subscriptions.SubscriptionOptions { PublishingInterval = TimeSpan.FromMilliseconds(500), PublishingEnabled = true, KeepAliveCount = 10, LifetimeCount = 100 }); Assert.That( subscription.TryAddMonitoredItem( "CurrentTime", VariableIds.Server_ServerStatus_CurrentTime, o => o with { SamplingInterval = TimeSpan.FromMilliseconds(500), QueueSize = 10 }, out _), Is.True); bool created = await WaitForAsync( () => subscription.Created, TimeSpan.FromSeconds(10), ct).ConfigureAwait(false); Assert.That(created, Is.True, "Subscription should be created on the server"); bool gotData = await handler .WaitForDataAsync(TimeSpan.FromSeconds(20), ct) .ConfigureAwait(false); Assert.That( gotData, Is.True, "A data-change notification should be delivered to the handler"); Assert.That(handler.DataChangeCount, Is.GreaterThan(0)); await subscription.DisposeAsync().ConfigureAwait(false); } finally { await session.CloseAsync().ConfigureAwait(false); await session.DisposeAsync().ConfigureAwait(false); } } private static async Task WaitForAsync( Func predicate, TimeSpan timeout, CancellationToken ct) { DateTime deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (predicate()) { return true; } await Task.Delay(50, ct).ConfigureAwait(false); } return predicate(); } [Test] [Order(300)] [CancelAfter(60_000)] public async Task ClassicEngineDoesNotExposeSubscriptionManager( CancellationToken ct) { ConfiguredEndpoint endpoint = await ClientFixture .GetEndpointAsync(ServerUrl, SecurityPolicies.None) .ConfigureAwait(false); ManagedSessionType session = await new ManagedSessionBuilder( ClientFixture.Config, Telemetry) .UseEndpoint(endpoint) .WithSessionName(nameof(ClassicEngineDoesNotExposeSubscriptionManager)) .UseSubscriptionEngine(ClassicSubscriptionEngineFactory.Instance) .ConnectAsync(ct) .ConfigureAwait(false); try { Assert.That(session.Connected, Is.True); Assert.That( session.TryGetSubscriptionManager(out ISubscriptionManager? manager), Is.False); Assert.That(manager, Is.Null); // The V2-only fluent surfaces are unavailable on the classic // engine and fail fast (InvalidOperationException) rather than // silently no-op'ing. Assert.Throws( () => session.AddSubscription( new RecordingHandler(), new Client.Subscriptions.SubscriptionOptions())); Assert.Throws( () => _ = session.DefaultStreaming); } finally { await session.CloseAsync().ConfigureAwait(false); await session.DisposeAsync().ConfigureAwait(false); } } private sealed class RecordingHandler : ISubscriptionNotificationHandler { private readonly TaskCompletionSource m_firstData = new(TaskCreationOptions.RunContinuationsAsynchronously); public int DataChangeCount; public int KeepAliveCount; public int EventCount; public ValueTask OnDataChangeNotificationAsync( ISubscription subscription, uint sequenceNumber, DateTime publishTime, ReadOnlyMemory notification, PublishState publishStateMask, IReadOnlyList stringTable) { Interlocked.Add(ref DataChangeCount, notification.Length); m_firstData.TrySetResult(true); return default; } public ValueTask OnEventDataNotificationAsync( ISubscription subscription, uint sequenceNumber, DateTime publishTime, ReadOnlyMemory notification, PublishState publishStateMask, IReadOnlyList stringTable) { Interlocked.Add(ref EventCount, notification.Length); return default; } public ValueTask OnKeepAliveNotificationAsync( ISubscription subscription, uint sequenceNumber, DateTime publishTime, PublishState publishStateMask) { Interlocked.Increment(ref KeepAliveCount); return default; } public ValueTask OnSubscriptionStateChangedAsync( ISubscription subscription, Client.Subscriptions.SubscriptionState state, PublishState publishStateMask, CancellationToken ct = default) { return default; } public async Task WaitForDataAsync( TimeSpan timeout, CancellationToken ct) { using var timeoutCts = new CancellationTokenSource(timeout); using var linked = CancellationTokenSource .CreateLinkedTokenSource(timeoutCts.Token, ct); using (linked.Token.Register(() => m_firstData.TrySetResult(false))) { return await m_firstData.Task.ConfigureAwait(false); } } } } }