using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; /// /// Verifies the config-gated AddAlarmHistorian registration: when the /// AlarmHistorian section is absent or disabled the /// default survives; when it is enabled a real wins /// (last-registration-wins over the TryAddSingleton Null default). /// public sealed class AlarmHistorianRegistrationTests { /// A no-op writer the factory hands the Sqlite sink; never actually invoked in these tests. private sealed class FakeWriter : IAlarmHistorianWriter { public Task> WriteBatchAsync( IReadOnlyList batch, CancellationToken cancellationToken) => Task.FromResult>( batch.Select(_ => HistorianWriteOutcome.Ack).ToList()); } /// Seed the Null default exactly the way AddOtOpcUaRuntime does, then add logging. private static ServiceCollection BaseServices() { var services = new ServiceCollection(); services.AddLogging(); services.TryAddSingleton(NullAlarmHistorianSink.Instance); return services; } private static IConfiguration ConfigFrom(Dictionary values) => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); [Fact] public void Section_absent_keeps_null_sink() { var services = BaseServices(); var config = ConfigFrom(new Dictionary()); services.AddAlarmHistorian(config, (_, _) => new FakeWriter()); using var provider = services.BuildServiceProvider(); provider.GetRequiredService().ShouldBeOfType(); } [Fact] public void Section_disabled_keeps_null_sink() { var services = BaseServices(); var config = ConfigFrom(new Dictionary { ["AlarmHistorian:Enabled"] = "false", }); services.AddAlarmHistorian(config, (_, _) => new FakeWriter()); using var provider = services.BuildServiceProvider(); provider.GetRequiredService().ShouldBeOfType(); } [Fact] public void Section_enabled_registers_sqlite_sink() { var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); var dbPath = Path.Combine(tempDir, "alarm-historian-test.db"); try { var services = BaseServices(); var config = ConfigFrom(new Dictionary { ["AlarmHistorian:Enabled"] = "true", ["AlarmHistorian:DatabasePath"] = dbPath, }); services.AddAlarmHistorian(config, (_, _) => new FakeWriter()); using (var provider = services.BuildServiceProvider()) { provider.GetRequiredService().ShouldBeOfType(); } // dispose stops the drain loop + releases the SQLite file handle } finally { try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ } } } [Fact] public void Section_binds_drain_capacity_and_retention_knobs() { var config = ConfigFrom(new Dictionary { ["AlarmHistorian:Enabled"] = "true", ["AlarmHistorian:DrainIntervalSeconds"] = "11", ["AlarmHistorian:Capacity"] = "500", ["AlarmHistorian:DeadLetterRetentionDays"] = "7", }); var opts = config.GetSection(AlarmHistorianOptions.SectionName).Get(); opts.ShouldNotBeNull(); opts.DrainIntervalSeconds.ShouldBe(11); opts.Capacity.ShouldBe(500); opts.DeadLetterRetentionDays.ShouldBe(7); } [Fact] public void Validate_warns_on_empty_shared_secret_when_enabled() { var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "", DatabasePath = "/var/h.db" }; opts.Validate().ShouldContain(w => w.Contains("SharedSecret")); } [Fact] public void Validate_warns_on_relative_database_path_when_enabled() { var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "alarm-historian.db" }; opts.Validate().ShouldContain(w => w.Contains("DatabasePath")); } [Fact] public void Validate_is_silent_when_correctly_configured() { new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db" }.Validate().ShouldBeEmpty(); } [Fact] public void Validate_is_silent_when_disabled() { new AlarmHistorianOptions { Enabled = false, SharedSecret = "" }.Validate().ShouldBeEmpty(); } [Fact] public void Validate_warns_on_non_positive_drain_interval() { var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", DrainIntervalSeconds = 0 }; opts.Validate().ShouldContain(w => w.Contains("DrainIntervalSeconds")); } [Fact] public void Validate_warns_on_non_positive_capacity() { var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", Capacity = 0 }; opts.Validate().ShouldContain(w => w.Contains("Capacity")); } [Fact] public void Validate_warns_on_non_positive_retention() { var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", DeadLetterRetentionDays = 0 }; opts.Validate().ShouldContain(w => w.Contains("DeadLetterRetentionDays")); } [Fact] public void Validate_accumulates_multiple_warnings() { // relative path + empty secret ⇒ both warnings, not short-circuited on the first. var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "", DatabasePath = "alarm-historian.db" }; var warnings = opts.Validate(); warnings.ShouldContain(w => w.Contains("SharedSecret")); warnings.ShouldContain(w => w.Contains("DatabasePath")); warnings.Count.ShouldBeGreaterThanOrEqualTo(2); } [Fact] public void Section_binds_tcp_host_port_tls_fields() { var config = ConfigFrom(new Dictionary { ["AlarmHistorian:Host"] = "historian.example.com", ["AlarmHistorian:Port"] = "12345", ["AlarmHistorian:UseTls"] = "true", ["AlarmHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF", }); var opts = config.GetSection(AlarmHistorianOptions.SectionName).Get(); opts.ShouldNotBeNull(); opts.Host.ShouldBe("historian.example.com"); opts.Port.ShouldBe(12345); opts.UseTls.ShouldBeTrue(); opts.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF"); } }