/* ========================================================================
* Copyright (c) 2005-2026 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/
* ======================================================================*/
using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server.RuntimeNodeSet;
namespace Opc.Ua.Server.Tests.RuntimeNodeSet
{
///
/// Unit tests for covering
/// constructor argument validation,
/// population, options snapshot semantics, cycle detection, and duplicate model URI
/// rejection.
///
[TestFixture]
[Category("RuntimeNodeSet")]
[Parallelizable(ParallelScope.All)]
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public sealed class RuntimeNodeSetNodeManagerFactoryTests
{
private const string kUriA = "urn:test:ModelA";
private const string kUriB = "urn:test:ModelB";
private static StreamRuntimeNodeSetSource MakeStreamSource(string modelUri, string xml)
{
return RuntimeNodeSetSource.FromStream(
modelUri,
_ => new ValueTask(
new MemoryStream(Encoding.UTF8.GetBytes(xml))),
[modelUri]);
}
private static string MakeMinimalXml(string modelUri)
{
return
"\r\n" +
"\r\n" +
" \r\n" +
" \r\n" +
" \r\n" +
"";
}
private static string MakeCycleXml(string ownUri, string requiresUri)
{
return
"\r\n" +
"\r\n" +
" \r\n" +
" \r\n" +
" \r\n" +
" \r\n" +
" \r\n" +
"";
}
///
/// Constructor must throw when options is null.
///
[Test]
public void ConstructorRejectsNullOptions()
{
Assert.That(
() => new RuntimeNodeSetNodeManagerFactory(null!),
Throws.ArgumentNullException.With.Property("ParamName").EqualTo("options"));
}
///
/// Constructor must throw when is empty.
///
[Test]
public void ConstructorRejectsEmptySourceList()
{
var options = new RuntimeNodeSetOptions
{
Sources = []
};
Assert.That(
() => new RuntimeNodeSetNodeManagerFactory(options),
Throws.InvalidOperationException.With.Message.Contains("At least one"));
}
///
/// Constructor must throw when two sources declare the same model namespace URI.
/// Duplicates are detected eagerly before the server starts.
///
[Test]
public void ConstructorRejectsDuplicateModelUrisAcrossSources()
{
StreamRuntimeNodeSetSource sourceA = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
StreamRuntimeNodeSetSource sourceB = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
var options = new RuntimeNodeSetOptions
{
Sources = [sourceA, sourceB]
};
Assert.That(
() => new RuntimeNodeSetNodeManagerFactory(options),
Throws.InvalidOperationException.With.Message.Contains(kUriA));
}
///
/// Constructor must throw when
/// names a URI that is not owned by any configured source.
///
[Test]
public void ConstructorRejectsDefaultNsNotOwnedByAnySources()
{
StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
var options = new RuntimeNodeSetOptions
{
Sources = [source],
DefaultNamespaceUri = "urn:not:owned"
};
Assert.That(
() => new RuntimeNodeSetNodeManagerFactory(options),
Throws.InvalidOperationException.With.Message.Contains("not owned"));
}
///
/// aggregates the
/// model URIs from all configured sources in declaration order.
///
[Test]
public void NamespacesUrisAggregatesAllSourceModelUris()
{
StreamRuntimeNodeSetSource sourceA = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
StreamRuntimeNodeSetSource sourceB = MakeStreamSource(kUriB, MakeMinimalXml(kUriB));
var options = new RuntimeNodeSetOptions
{
Sources = [sourceA, sourceB]
};
var factory = new RuntimeNodeSetNodeManagerFactory(options);
Assert.That(factory.NamespacesUris.Count, Is.EqualTo(2));
Assert.That(factory.NamespacesUris[0], Is.EqualTo(kUriA));
Assert.That(factory.NamespacesUris[1], Is.EqualTo(kUriB));
}
///
/// Mutating after the factory is
/// constructed must not change the factory's
/// .
///
[Test]
public void NamespacesUrisSnapshotAtConstruction()
{
StreamRuntimeNodeSetSource sourceA = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
var options = new RuntimeNodeSetOptions
{
Sources = [sourceA]
};
var factory = new RuntimeNodeSetNodeManagerFactory(options);
Assert.That(factory.NamespacesUris.Count, Is.EqualTo(1));
// Mutate the options after construction — must not affect the factory.
options.Sources = [];
Assert.That(factory.NamespacesUris.Count, Is.EqualTo(1));
Assert.That(factory.NamespacesUris[0], Is.EqualTo(kUriA));
}
///
/// A single source with a valid
/// that matches one of the owned URIs is accepted without error.
///
[Test]
public void ValidDefaultNamespaceUriIsAccepted()
{
StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
var options = new RuntimeNodeSetOptions
{
Sources = [source],
DefaultNamespaceUri = kUriA
};
Assert.That(
() => new RuntimeNodeSetNodeManagerFactory(options),
Throws.Nothing);
}
///
/// When two sources form a mutual dependency cycle,
/// throws
/// with a message that names the
/// participating sources.
///
[Test]
public async Task CreateAsyncThrowsOnCycleInIncludedSourcesAsync()
{
// Source A requires B, source B requires A → cycle.
StreamRuntimeNodeSetSource sourceA = RuntimeNodeSetSource.FromStream(
kUriA,
_ => new ValueTask(
new MemoryStream(Encoding.UTF8.GetBytes(MakeCycleXml(kUriA, kUriB)))),
[kUriA]);
StreamRuntimeNodeSetSource sourceB = RuntimeNodeSetSource.FromStream(
kUriB,
_ => new ValueTask(
new MemoryStream(Encoding.UTF8.GetBytes(MakeCycleXml(kUriB, kUriA)))),
[kUriB]);
var options = new RuntimeNodeSetOptions
{
Sources = [sourceA, sourceB]
};
var factory = new RuntimeNodeSetNodeManagerFactory(options);
Mock mockServer = CreateMinimalServerMock();
InvalidOperationException ex = Assert.ThrowsAsync(
async () => await factory.CreateAsync(
mockServer.Object,
new ApplicationConfiguration(),
default).ConfigureAwait(false));
Assert.That(ex.Message, Does.Contain("circular"));
}
///
/// When two sources in a group declare the same ModelUri inside their
/// NodeSet XML (as opposed to in the declared metadata), CreateAsync
/// rejects them with .
///
[Test]
public async Task CreateAsyncThrowsOnDuplicateModelUriInParsedDocumentsAsync()
{
// Both sources declare kUriA in their XML but the factory metadata
// declares different URIs to bypass the constructor check. When the
// documents are parsed, the ModelUri from the XML overrides the
// declared URIs and the factory detects the duplicate.
const string kUriExtra = "urn:extra:X";
StreamRuntimeNodeSetSource source1 = RuntimeNodeSetSource.FromStream(
kUriA,
_ => new ValueTask(
new MemoryStream(Encoding.UTF8.GetBytes(MakeMinimalXml(kUriA)))),
[kUriA]);
// Use a second source that declares a different URI at the factory
// level but whose embedded XML resolves to the same ModelUri as source1.
// This creates a conflict detected during ParseAndSortAsync.
StreamRuntimeNodeSetSource source2 = RuntimeNodeSetSource.FromStream(
kUriExtra,
_ => new ValueTask(
new MemoryStream(Encoding.UTF8.GetBytes(MakeMinimalXml(kUriA)))),
[kUriExtra]);
var options = new RuntimeNodeSetOptions
{
Sources = [source1, source2]
};
var factory = new RuntimeNodeSetNodeManagerFactory(options);
Mock mockServer = CreateMinimalServerMock();
// Parsing fails because the XML in source2 declares kUriA, which
// does not match the declared URI kUriExtra.
await Assert.ThatAsync(
() => factory.CreateAsync(
mockServer.Object,
new ApplicationConfiguration(),
default).AsTask(),
Throws.TypeOf()).ConfigureAwait(false);
}
///
/// rejects a
/// null server.
///
[Test]
public void CreateAsyncRejectsNullServer()
{
StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
var factory = new RuntimeNodeSetNodeManagerFactory(
new RuntimeNodeSetOptions { Sources = [source] });
Assert.That(
() => factory.CreateAsync(null!, new ApplicationConfiguration(), default).AsTask(),
Throws.ArgumentNullException.With.Property("ParamName").EqualTo("server"));
}
///
/// A single-source group with no fluent callback creates a runtime node
/// manager whose owned namespace matches the source.
///
[Test]
public async Task CreateAsyncReturnsManagerForSingleSourceAsync()
{
StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
var factory = new RuntimeNodeSetNodeManagerFactory(
new RuntimeNodeSetOptions { Sources = [source] });
IAsyncNodeManager manager = await factory.CreateAsync(
BuildMockServer().Object,
new ApplicationConfiguration(),
CancellationToken.None).ConfigureAwait(false);
using (manager as IDisposable)
{
Assert.That(manager, Is.Not.Null);
}
}
///
/// When a fluent callback is present and an explicit default namespace is
/// configured, the manager is created using that namespace.
///
[Test]
public async Task CreateAsyncWithExplicitDefaultNamespaceAsync()
{
StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions
{
Sources = [source],
DefaultNamespaceUri = kUriA,
Configure = _ => { }
});
IAsyncNodeManager manager = await factory.CreateAsync(
BuildMockServer().Object,
new ApplicationConfiguration(),
CancellationToken.None).ConfigureAwait(false);
using (manager as IDisposable)
{
Assert.That(manager, Is.Not.Null);
}
}
///
/// When a fluent callback is present but no explicit default namespace is
/// set, a single-source group infers the namespace from that source.
///
[Test]
public async Task CreateAsyncInfersDefaultNamespaceForSingleSourceAsync()
{
StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions
{
Sources = [source],
Configure = _ => { }
});
IAsyncNodeManager manager = await factory.CreateAsync(
BuildMockServer().Object,
new ApplicationConfiguration(),
CancellationToken.None).ConfigureAwait(false);
using (manager as IDisposable)
{
Assert.That(manager, Is.Not.Null);
}
}
///
/// A single source that declares two models where one requires the other is
/// well-formed and must not be reported as a circular dependency.
///
[Test]
public async Task CreateAsyncAcceptsIntraDocumentModelDependencyAsync()
{
const string xml =
"\r\n" +
"\r\n" +
" \r\n" +
" \r\n" +
" \r\n" +
" \r\n" +
" \r\n" +
" \r\n" +
"";
StreamRuntimeNodeSetSource source = RuntimeNodeSetSource.FromStream(
"combined",
_ => new ValueTask(new MemoryStream(Encoding.UTF8.GetBytes(xml))),
[kUriA, kUriB]);
var factory = new RuntimeNodeSetNodeManagerFactory(
new RuntimeNodeSetOptions { Sources = [source] });
IAsyncNodeManager manager = await factory.CreateAsync(
BuildMockServer().Object,
new ApplicationConfiguration(),
CancellationToken.None).ConfigureAwait(false);
using (manager as IDisposable)
{
Assert.That(manager, Is.Not.Null);
}
}
///
/// With a fluent callback and two dependent sources, the default namespace
/// is inferred as the unique leaf model (the one not required by another).
///
[Test]
public async Task CreateAsyncInfersLeafNamespaceForDependentSourcesAsync()
{
// Source A requires B, so A is the leaf and B is a dependency.
StreamRuntimeNodeSetSource sourceA = MakeStreamSource(kUriA, MakeCycleXml(kUriA, kUriB));
StreamRuntimeNodeSetSource sourceB = MakeStreamSource(kUriB, MakeMinimalXml(kUriB));
var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions
{
Sources = [sourceA, sourceB],
Configure = _ => { }
});
IAsyncNodeManager manager = await factory.CreateAsync(
BuildMockServer().Object,
new ApplicationConfiguration(),
CancellationToken.None).ConfigureAwait(false);
using (manager as IDisposable)
{
Assert.That(manager, Is.Not.Null);
}
}
///
/// With a fluent callback and two independent leaf models, the default
/// namespace cannot be inferred and creation fails with a clear error.
///
[Test]
public void CreateAsyncThrowsWhenDefaultNamespaceAmbiguous()
{
StreamRuntimeNodeSetSource sourceA = MakeStreamSource(kUriA, MakeMinimalXml(kUriA));
StreamRuntimeNodeSetSource sourceB = MakeStreamSource(kUriB, MakeMinimalXml(kUriB));
var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions
{
Sources = [sourceA, sourceB],
Configure = _ => { }
});
Assert.That(
() => factory.CreateAsync(
BuildMockServer().Object,
new ApplicationConfiguration(),
default).AsTask(),
Throws.InvalidOperationException.With.Message.Contains("infer"));
}
///
/// When a source declares more owned namespaces than the parsed NodeSet
/// defines, creation fails during validation.
///
[Test]
public void CreateAsyncThrowsWhenDeclaredNamespaceCountMismatch()
{
StreamRuntimeNodeSetSource source = RuntimeNodeSetSource.FromStream(
"mismatch",
_ => new ValueTask(
new MemoryStream(Encoding.UTF8.GetBytes(MakeMinimalXml(kUriA)))),
[kUriA, kUriB]);
var factory = new RuntimeNodeSetNodeManagerFactory(
new RuntimeNodeSetOptions { Sources = [source] });
Assert.That(
() => factory.CreateAsync(
BuildMockServer().Object,
new ApplicationConfiguration(),
default).AsTask(),
Throws.InvalidOperationException);
}
///
/// Creates a minimal mock sufficient for
/// to attempt
/// parsing (a real logger is injected so diagnostic output works).
///
private static Mock CreateMinimalServerMock()
{
var mockLoggerFactory = LoggerFactory.Create(b => b.AddDebug());
var mockTelemetry = new Mock();
mockTelemetry.SetupGet(t => t.LoggerFactory).Returns(mockLoggerFactory);
var mockServer = new Mock();
mockServer.SetupGet(s => s.Telemetry).Returns(mockTelemetry.Object);
return mockServer;
}
///
/// Creates an mock with the namespace table
/// and system context required to fully construct a runtime node manager
/// during .
///
private static Mock BuildMockServer()
{
var mockLoggerFactory = LoggerFactory.Create(b => b.AddDebug());
var mockTelemetry = new Mock();
mockTelemetry.SetupGet(t => t.LoggerFactory).Returns(mockLoggerFactory);
var mockServer = new Mock();
mockServer.SetupGet(s => s.Telemetry).Returns(mockTelemetry.Object);
mockServer.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
var systemContext = new ServerSystemContext(mockServer.Object);
mockServer.SetupGet(s => s.DefaultSystemContext).Returns(systemContext);
return mockServer;
}
}
}