using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; /// /// Verifies the config-gated AddHistorianProvisioning registration AND that a registered /// provisioner is actually reached by the address-space applier on a historized-tag deploy. This is the /// wiring the HistorianGateway-integration review found missing: PR #423 shipped /// GatewayTagProvisioner + unit tests but never registered it in DI nor passed it into the applier, /// so EnsureTags was dormant (every deploy used the no-op ). /// /// When the ServerHistorian section is absent or disabled the /// default seeded by AddOtOpcUaRuntime survives (the factory is never invoked); when it is enabled /// the factory's returned wins (last-registration-wins over the /// TryAddSingleton Null default) and — constructed into the applier the way /// WithOtOpcUaRuntimeActors does — its fires /// for an added historized tag. /// /// public sealed class AddHistorianProvisioningTests { /// Capturing double — records the requests it was handed and /// signals a when invoked so the fire-and-forget applier dispatch can be /// awaited deterministically. private sealed class CapturingProvisioner : IHistorianProvisioning { private readonly TaskCompletionSource _called = new(TaskCreationOptions.RunContinuationsAsynchronously); /// The requests the applier handed to . public List Seen { get; } = new(); /// Completes once has been invoked. public Task Called => _called.Task; /// public Task EnsureTagsAsync( IReadOnlyList requests, CancellationToken ct) { Seen.AddRange(requests); _called.TrySetResult(); return Task.FromResult(new HistorianProvisionResult(requests.Count, requests.Count, 0, 0)); } } private static IConfiguration ConfigFrom(Dictionary values) => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); private static IConfiguration EnabledConfig() => ConfigFrom(new Dictionary { ["ServerHistorian:Enabled"] = "true", ["ServerHistorian:Endpoint"] = "https://historian.example.com:5222", ["ServerHistorian:ApiKey"] = "histgw_x_y", }); [Fact] public void Section_absent_keeps_null_provisioning_and_factory_not_invoked() { var factoryInvoked = false; var services = new ServiceCollection(); services.AddOtOpcUaRuntime(); services.AddHistorianProvisioning(ConfigFrom(new Dictionary()), (_, _) => { factoryInvoked = true; return new CapturingProvisioner(); }); using var provider = services.BuildServiceProvider(); provider.GetRequiredService().ShouldBeSameAs(NullHistorianProvisioning.Instance); factoryInvoked.ShouldBeFalse(); } [Fact] public void Section_disabled_keeps_null_provisioning_and_factory_not_invoked() { var factoryInvoked = false; var services = new ServiceCollection(); services.AddOtOpcUaRuntime(); var config = ConfigFrom(new Dictionary { ["ServerHistorian:Enabled"] = "false" }); services.AddHistorianProvisioning(config, (_, _) => { factoryInvoked = true; return new CapturingProvisioner(); }); using var provider = services.BuildServiceProvider(); provider.GetRequiredService().ShouldBeSameAs(NullHistorianProvisioning.Instance); factoryInvoked.ShouldBeFalse(); } [Fact] public void Section_enabled_registers_factory_provisioner_over_the_null_default() { var prov = new CapturingProvisioner(); var services = new ServiceCollection(); services.AddOtOpcUaRuntime(); services.AddHistorianProvisioning(EnabledConfig(), (_, _) => prov); using var provider = services.BuildServiceProvider(); var resolved = provider.GetRequiredService(); resolved.ShouldBeSameAs(prov); resolved.ShouldNotBeSameAs(NullHistorianProvisioning.Instance); } [Fact] public void Section_enabled_passes_bound_options_to_factory() { ServerHistorianOptions? seen = null; var services = new ServiceCollection(); services.AddOtOpcUaRuntime(); var config = ConfigFrom(new Dictionary { ["ServerHistorian:Enabled"] = "true", ["ServerHistorian:Endpoint"] = "https://historian.example.com:5222", ["ServerHistorian:ApiKey"] = "histgw_x_y", ["ServerHistorian:UseTls"] = "true", ["ServerHistorian:CaCertificatePath"] = "/etc/ssl/gateway-ca.pem", }); services.AddHistorianProvisioning(config, (opts, _) => { seen = opts; return new CapturingProvisioner(); }); using var provider = services.BuildServiceProvider(); _ = provider.GetRequiredService(); seen.ShouldNotBeNull(); seen.Endpoint.ShouldBe("https://historian.example.com:5222"); seen.ApiKey.ShouldBe("histgw_x_y"); seen.UseTls.ShouldBeTrue(); seen.CaCertificatePath.ShouldBe("/etc/ssl/gateway-ca.pem"); } /// /// The end-to-end wiring proof the dormant-provisioner bug needed: register the provisioner via /// AddHistorianProvisioning, resolve it, construct the EXACTLY /// as WithOtOpcUaRuntimeActors does (provisioning: resolver.GetService<IHistorianProvisioning>()), /// then apply a plan that adds a historized tag and assert the gateway-backed /// actually fires (with the resolved historian name). /// [Fact] public async Task Registered_provisioner_is_invoked_by_the_applier_on_a_historized_deploy() { var prov = new CapturingProvisioner(); var services = new ServiceCollection(); services.AddOtOpcUaRuntime(); services.AddHistorianProvisioning(EnabledConfig(), (_, _) => prov); using var provider = services.BuildServiceProvider(); // Mirror the production construction in WithOtOpcUaRuntimeActors: resolve the provisioner and pass it in. var resolved = provider.GetService(); var applier = new AddressSpaceApplier( NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, provisioning: resolved); applier.Apply(PlanWithAddedTags( HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"), NonHistorizedTag(displayName: "Run", dataType: "Boolean"))); await prov.Called.WaitAsync(TimeSpan.FromSeconds(5)); prov.Seen.Count.ShouldBe(1); prov.Seen[0].TagName.ShouldBe("Pump1.Temp"); prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32); } private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName, Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName); private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType) => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null); private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new( AddedEquipment: Array.Empty(), RemovedEquipment: Array.Empty(), ChangedEquipment: Array.Empty(), AddedDrivers: Array.Empty(), RemovedDrivers: Array.Empty(), ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()) { AddedEquipmentTags = tags, }; }