junction/tests/Junction.Tests/Unit/OpcuaDriverFactoryTests.cs
dtrentin 88dfa74ebe feat: OPC UA protocol plugin (v0.4)
Second protocol plugin proving multi-protocol architecture. New
Junction.Protocols.OpcUa (multi-target net48;net8.0 — SDK has no ns2.0):
- OpcuaDriverFactory: config validation (EndpointUrl/SecurityMode/Policy/
  AuthMode/Username/Password/NodeIds/BrowseRoot/TimeoutSeconds), CONFIG_* codes.
- OpcuaDriver: ReadCurrentAsync (read configured nodes) + ProbeAsync (browse
  address space), linked-CTS timeout, Result mapping, opt-in FilterToMonitored
  — mirrors MtconnectDriver.
- IUaClient seam (SDK-free) injected for testability; UaClient wraps OPC UA
  Session (non-obsolete async API), Directory PKI store, anon/user-pass auth.

SDK: OPCFoundation.NetStandard.Opc.Ua.Client 1.5.378.156 (MIT), pinned exact,
transitive closure copied per-TFM into plugins/opcua/ (CopyLocalLockFileAssemblies
+ CopyOpcuaPlugin target). Host: AutoGenerateBindingRedirects. Core untouched —
protocol auto-discovered by plugin loader.

Projects: +Junction.Protocols.OpcUa, Junction.App (wiring), Junction.sln,
Junction.Tests (+23 tests: 13 factory + 10 driver via Mock<IUaClient>). 171 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:44:23 +02:00

176 lines
6.2 KiB
C#

using System;
using System.Collections.Generic;
using Junction.Domain.Models;
using Junction.Protocols.OpcUa;
using Xunit;
namespace Junction.Tests.Unit
{
/// <summary>
/// Config-validation tests for <see cref="OpcuaDriverFactory"/>, mirroring
/// MtconnectDriverFactory tests: required/absolute/scheme checks, enum checks, the
/// UsernamePassword credential rule and case-insensitive key lookup.
/// </summary>
public sealed class OpcuaDriverFactoryTests
{
private static readonly Guid MachineId = Guid.Parse("99999999-8888-7777-6666-555555555555");
private const string EndpointUrl = "opc.tcp://opcua-server.test:4840";
private static Machine MachineWithConfig(IReadOnlyDictionary<string, string>? config) =>
new Machine(MachineId, "PLC-01", "opcua", config, TimeSpan.FromSeconds(5));
[Fact]
public void ProtocolId_IsOpcua()
{
Assert.Equal("opcua", new OpcuaDriverFactory().ProtocolId);
}
[Fact]
public void MissingEndpointUrl_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>()));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_MISSING");
}
[Fact]
public void NonAbsoluteEndpointUrl_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
new Dictionary<string, string> { ["EndpointUrl"] = "not a uri" }));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_INVALID");
}
[Fact]
public void WrongScheme_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
new Dictionary<string, string> { ["EndpointUrl"] = "http://opcua-server.test:4840" }));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_SCHEME");
}
[Fact]
public void ValidEndpointUrl_ReturnsOkDriverWithOpcuaProtocolId()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
new Dictionary<string, string> { ["EndpointUrl"] = EndpointUrl }));
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Equal("opcua", result.Value.ProtocolId);
}
[Fact]
public void EndpointUrlKeyIsCaseInsensitive()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
new Dictionary<string, string> { ["endpointurl"] = EndpointUrl }));
Assert.True(result.IsSuccess);
}
[Fact]
public void InvalidSecurityMode_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["SecurityMode"] = "Bogus",
}));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_SECURITYMODE_INVALID");
}
[Fact]
public void InvalidAuthMode_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["AuthMode"] = "Kerberos",
}));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_AUTHMODE_INVALID");
}
[Fact]
public void UsernamePasswordWithoutUsername_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["AuthMode"] = "UsernamePassword",
["Password"] = "secret",
}));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_USERNAME_MISSING");
}
[Fact]
public void UsernamePasswordWithoutPassword_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["AuthMode"] = "UsernamePassword",
["Username"] = "admin",
}));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_PASSWORD_MISSING");
}
[Fact]
public void UsernamePasswordWithCredentials_ReturnsOk()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["AuthMode"] = "UsernamePassword",
["Username"] = "admin",
["Password"] = "secret",
}));
Assert.True(result.IsSuccess);
}
[Fact]
public void InvalidTimeout_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["TimeoutSeconds"] = "-5",
}));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_TIMEOUT_INVALID");
}
[Fact]
public void FullValidConfig_ReturnsOk()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["SecurityMode"] = "SignAndEncrypt",
["SecurityPolicy"] = "Basic256Sha256",
["AuthMode"] = "Anonymous",
["NodeIds"] = "ns=2;s=Temp, ns=2;s=Speed",
["BrowseRoot"] = "i=85",
["TimeoutSeconds"] = "15",
}));
Assert.True(result.IsSuccess);
Assert.Equal("opcua", result.Value.ProtocolId);
}
}
}