using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// lmxopcua#881 over a real SDK client session: the Boolean Server/ServerCapabilities/HistoryServerCapabilities /// properties follow the wired backend. The data source AND the updater are built through /// exactly as the Host builds them (Program.cs wires /// provider.CreateUpdater), so each expectation is what that backend really serves: /// /// Gateway — data + event reads; no HistoryUpdate path (its provider returns no updater); AVEVA keeps no /// per-sample server time. /// SqlServer / QuestDb — data + event reads; Insert / Replace / Update / DeleteRaw / DeleteAtTime /// (QuestDB deletes are tombstones); every sample stores its server time. /// None of them — event or annotation updates: the server dispatches none. /// /// public sealed class HistoryServerCapabilitiesWireTests { private static readonly (string Name, NodeId Id)[] Flags = [ ("AccessHistoryDataCapability", VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability), ("AccessHistoryEventsCapability", VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability), ("ServerTimestampSupported", VariableIds.HistoryServerCapabilities_ServerTimestampSupported), ("InsertDataCapability", VariableIds.HistoryServerCapabilities_InsertDataCapability), ("ReplaceDataCapability", VariableIds.HistoryServerCapabilities_ReplaceDataCapability), ("UpdateDataCapability", VariableIds.HistoryServerCapabilities_UpdateDataCapability), ("DeleteRawCapability", VariableIds.HistoryServerCapabilities_DeleteRawCapability), ("DeleteAtTimeCapability", VariableIds.HistoryServerCapabilities_DeleteAtTimeCapability), ("InsertEventCapability", VariableIds.HistoryServerCapabilities_InsertEventCapability), ("ReplaceEventCapability", VariableIds.HistoryServerCapabilities_ReplaceEventCapability), ("UpdateEventCapability", VariableIds.HistoryServerCapabilities_UpdateEventCapability), ("DeleteEventCapability", VariableIds.HistoryServerCapabilities_DeleteEventCapability), ("InsertAnnotationCapability", VariableIds.HistoryServerCapabilities_InsertAnnotationCapability), ]; private static readonly string[] ReadOnlyHistory = ["AccessHistoryDataCapability", "AccessHistoryEventsCapability"]; private static readonly string[] UpdatableHistory = [ "AccessHistoryDataCapability", "AccessHistoryEventsCapability", "ServerTimestampSupported", "InsertDataCapability", "ReplaceDataCapability", "UpdateDataCapability", "DeleteRawCapability", "DeleteAtTimeCapability", ]; /// The flags expected TRUE per backend; every other flag in must read false. private static string[] ExpectedTrue(string backend) => backend == "Gateway" ? ReadOnlyHistory : UpdatableHistory; private static IHistorianBackendProvider Provider(string backend) { var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["ServerHistorian:SqlServer:ConnectionString"] = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True", ["ServerHistorian:QuestDb:ConnectionString"] = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb", }).Build(); var options = new ServerHistorianOptions { Backend = backend, Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false, }; // Constructing the seams opens no connection; nothing here reaches a database or a gateway. return HistorianBackendRegistry.Resolve(options, config); } private static void Wire(ServerEventRig rig, string backend) { var sp = new ServiceCollection().BuildServiceProvider(); var provider = Provider(backend); rig.Nm.HistorianDataSource = provider.CreateDataSource(sp); rig.Nm.HistorianDataUpdater = provider.CreateUpdater(sp); } [Theory] [InlineData("Gateway")] [InlineData("SqlServer")] [InlineData("QuestDb")] public async Task Each_backend_advertises_exactly_what_it_serves(string backend) { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync($"HistCaps{backend}", encrypted: false, ct); try { Wire(rig, backend); rig.Enforce(false, [Key()]); using var session = await rig.ConnectAsync(anonymous: true, ct); var read = await ReadFlagsAsync(session, ct); var expected = ExpectedTrue(backend); foreach (var (name, _) in Flags) read[name].ShouldBe(expected.Contains(name), $"{backend}: {name}"); } finally { await rig.DisposeAsync(); } } [Fact] public async Task With_no_historian_wired_every_flag_stays_false() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("HistCapsNull", encrypted: false, ct); try { // The Host's disabled-historian wiring: the Null source and no updater, assigned explicitly. rig.Nm.HistorianDataSource = null!; rig.Nm.HistorianDataUpdater = null; rig.Enforce(false, [Key()]); using var session = await rig.ConnectAsync(anonymous: true, ct); (await ReadFlagsAsync(session, ct)).Values.ShouldAllBe(v => v == false); } finally { await rig.DisposeAsync(); } } [Fact] public async Task Rewiring_SqlServer_to_Gateway_retracts_the_update_flags_on_the_same_session() { // Positive/negative pairing on one live session: the same nodes read true under SqlServer and false under the // Gateway, so neither expectation can pass on a server that never writes the properties. var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("HistCapsRewire", encrypted: false, ct); try { rig.Enforce(false, [Key()]); using var session = await rig.ConnectAsync(anonymous: true, ct); Wire(rig, "SqlServer"); var sql = await ReadFlagsAsync(session, ct); sql["InsertDataCapability"].ShouldBeTrue(); sql["DeleteRawCapability"].ShouldBeTrue(); sql["ServerTimestampSupported"].ShouldBeTrue(); sql["AccessHistoryDataCapability"].ShouldBeTrue(); Wire(rig, "Gateway"); var gateway = await ReadFlagsAsync(session, ct); gateway["InsertDataCapability"].ShouldBeFalse(); gateway["DeleteRawCapability"].ShouldBeFalse(); gateway["ServerTimestampSupported"].ShouldBeFalse(); gateway["AccessHistoryDataCapability"].ShouldBeTrue("read access is not an update flag"); rig.Nm.HistorianDataSource = null!; rig.Nm.HistorianDataUpdater = null; (await ReadFlagsAsync(session, ct))["AccessHistoryDataCapability"].ShouldBeFalse("unwiring retracts read access too"); } finally { await rig.DisposeAsync(); } } private static async Task> ReadFlagsAsync(ISession session, CancellationToken ct) { var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, Flags.Select(f => new ReadValueId { NodeId = f.Id, AttributeId = Attributes.Value }).ToArray(), ct); var results = response.Results.ToList(); var flags = new Dictionary(StringComparer.Ordinal); for (var i = 0; i < Flags.Length; i++) { StatusCode.IsGood(results[i].StatusCode).ShouldBeTrue($"{Flags[i].Name}: {results[i].StatusCode}"); flags[Flags[i].Name] = results[i].ClrValue() is true; } return flags; } }