using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; 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 AddServerHistorian registration: 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). /// public sealed class AddServerHistorianTests { /// A trivial read source the factory hands back when enabled; never actually invoked. private sealed class FakeHistorianDataSource : IHistorianDataSource { private static readonly HistoryReadResult EmptyRead = new(Array.Empty(), null); private static readonly HistoricalEventsResult EmptyEvents = new(Array.Empty(), null); public Task ReadRawAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => Task.FromResult(EmptyRead); public Task ReadProcessedAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => Task.FromResult(EmptyRead); public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => Task.FromResult(EmptyRead); public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => Task.FromResult(EmptyEvents); public HistorianHealthSnapshot GetHealthSnapshot() => new( TotalQueries: 0, TotalSuccesses: 0, TotalFailures: 0, ConsecutiveFailures: 0, LastSuccessTime: null, LastFailureTime: null, LastError: null, ProcessConnectionOpen: false, EventConnectionOpen: false, ActiveProcessNode: null, ActiveEventNode: null, Nodes: Array.Empty()); public void Dispose() { // Stateless fake; nothing to release. } } private static IConfiguration ConfigFrom(Dictionary values) => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); [Fact] public void Section_absent_keeps_null_source_and_factory_not_invoked() { var factoryInvoked = false; var services = new ServiceCollection(); services.AddOtOpcUaRuntime(); var config = ConfigFrom(new Dictionary()); services.AddServerHistorian(config, (_, _) => { factoryInvoked = true; return new FakeHistorianDataSource(); }); using var provider = services.BuildServiceProvider(); provider.GetRequiredService().ShouldBeSameAs(NullHistorianDataSource.Instance); factoryInvoked.ShouldBeFalse(); } [Fact] public void Section_disabled_keeps_null_source_and_factory_not_invoked() { var factoryInvoked = false; var services = new ServiceCollection(); services.AddOtOpcUaRuntime(); var config = ConfigFrom(new Dictionary { ["ServerHistorian:Enabled"] = "false", }); services.AddServerHistorian(config, (_, _) => { factoryInvoked = true; return new FakeHistorianDataSource(); }); using var provider = services.BuildServiceProvider(); provider.GetRequiredService().ShouldBeSameAs(NullHistorianDataSource.Instance); factoryInvoked.ShouldBeFalse(); } [Fact] public void Section_enabled_registers_factory_source() { var services = new ServiceCollection(); services.AddOtOpcUaRuntime(); var config = ConfigFrom(new Dictionary { ["ServerHistorian:Enabled"] = "true", ["ServerHistorian:Host"] = "historian.example.com", ["ServerHistorian:Port"] = "32569", ["ServerHistorian:SharedSecret"] = "s", }); services.AddServerHistorian(config, (_, _) => new FakeHistorianDataSource()); using var provider = services.BuildServiceProvider(); var resolved = provider.GetRequiredService(); resolved.ShouldBeOfType(); resolved.ShouldNotBeSameAs(NullHistorianDataSource.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:Host"] = "historian.example.com", ["ServerHistorian:Port"] = "12345", ["ServerHistorian:UseTls"] = "true", ["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF", ["ServerHistorian:SharedSecret"] = "s", }); services.AddServerHistorian(config, (opts, _) => { seen = opts; return new FakeHistorianDataSource(); }); using var provider = services.BuildServiceProvider(); _ = provider.GetRequiredService(); seen.ShouldNotBeNull(); seen.Host.ShouldBe("historian.example.com"); seen.Port.ShouldBe(12345); seen.UseTls.ShouldBeTrue(); seen.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF"); } [Fact] public void Validate_warns_on_empty_shared_secret_when_enabled() { var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "", Port = 32569 }; opts.Validate().ShouldContain(w => w.Contains("SharedSecret")); } [Fact] public void Validate_warns_on_non_positive_port_when_enabled() { var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 0 }; opts.Validate().ShouldContain(w => w.Contains("Port")); } [Fact] public void Validate_is_silent_when_correctly_configured() { new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 32569 }.Validate().ShouldBeEmpty(); } [Fact] public void Validate_is_silent_when_disabled() { new ServerHistorianOptions { Enabled = false, SharedSecret = "", Port = 0 }.Validate().ShouldBeEmpty(); } [Fact] public void Section_binds_host_port_tls_fields() { var config = ConfigFrom(new Dictionary { ["ServerHistorian:Host"] = "historian.example.com", ["ServerHistorian:Port"] = "12345", ["ServerHistorian:UseTls"] = "true", ["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF", }); var opts = config.GetSection(ServerHistorianOptions.SectionName).Get(); opts.ShouldNotBeNull(); opts.Host.ShouldBe("historian.example.com"); opts.Port.ShouldBe(12345); opts.UseTls.ShouldBeTrue(); opts.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF"); } }