using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests; public sealed class QuestDbHistorianProviderTests { private static QuestDbHistorianProvider Make() => new(new QuestDbHistorianOptions { ConnectionString = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading", }); [Theory] [InlineData("Raw")] [InlineData("AtTime")] [InlineData("Processed")] [InlineData("Events")] [InlineData("Modified")] public async Task A_connection_failure_is_exposed_as_a_transport_failure(string kind) { using var provider = new QuestDbHistorianProvider(new QuestDbHistorianOptions { ConnectionString = "Host=127.0.0.1;Port=1;Username=admin;Password=test;Database=qdb;Timeout=1;Pooling=False;ServerCompatibilityMode=NoTypeLoading", AutoCreateSchema = false }); using var source = provider.CreateDataSource(new NullSp()); var t = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); Func read = kind switch { "Raw" => async () => await source.ReadRawAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken), "AtTime" => async () => await source.ReadAtTimeAsync("T", [t], TestContext.Current.CancellationToken), "Processed" => async () => await source.ReadProcessedAsync("T", t, t.AddHours(1), TimeSpan.FromSeconds(10), HistoryAggregateType.Average, TestContext.Current.CancellationToken), "Events" => async () => await source.ReadEventsAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken), _ => async () => await source.ReadModifiedAsync("T", t, t.AddHours(1), 10, null, TestContext.Current.CancellationToken) }; var error = await Should.ThrowAsync(read); error.InnerException.ShouldNotBeNull(); // A cached bootstrap outage must remain retryable on subsequent reads as well. (await Should.ThrowAsync(read)).InnerException.ShouldNotBeNull(); } [Fact] public void Name_and_all_six_kinds() { var p = Make(); p.Name.ShouldBe("QuestDb"); p.SupportedKinds.ShouldBe(Enum.GetValues(), ignoreOrder: true); p.SupportedKinds.Count.ShouldBe(6); } [Fact] public void Creates_all_five_seams_without_touching_questdb() { var sp = new NullSp(); var p = Make(); p.CreateDataSource(sp).ShouldBeOfType(); p.CreateValueWriter(sp).ShouldBeOfType(); p.CreateAlarmWriter(sp).ShouldBeOfType(); p.CreateProvisioning(sp).ShouldBeOfType(); p.CreateUpdater(sp).ShouldBeOfType(); } [Fact] public void Supported_kinds_are_a_frozen_set() => Make().SupportedKinds.ShouldBeAssignableTo>(); /// /// An invalid table prefix must not fail provider construction (the host resolves the provider even when no /// consumer is enabled); it surfaces only when a seam is actually built. /// [Fact] public void An_invalid_prefix_fails_only_when_a_seam_is_built() { var p = new QuestDbHistorianProvider(new QuestDbHistorianOptions { ConnectionString = "Host=x", TablePrefix = "bad;prefix" }); p.Name.ShouldBe("QuestDb"); Should.Throw(() => p.CreateDataSource(new NullSp())); } [Fact] public void Every_seam_shares_one_connection_bootstrapper_catalog_and_wal_wait() { var sp = new NullSp(); var p = Make(); var a = (QuestDbHistorianDataSource)p.CreateDataSource(sp); var b = (QuestDbHistorianDataSource)p.CreateDataSource(sp); var w = p.CreateValueWriter(sp); var u = p.CreateUpdater(sp)!; var prov = p.CreateProvisioning(sp); var alarm = p.CreateAlarmWriter(sp); Field(a, "_conn").ShouldBeSameAs(Field(b, "_conn")); Field(a, "_conn").ShouldBeSameAs(Field(w, "_conn")); Field(a, "_conn").ShouldBeSameAs(Field(u, "_conn")); Field(a, "_conn").ShouldBeSameAs(Field(alarm, "_conn")); Field(a, "_bootstrap").ShouldBeSameAs(Field(b, "_bootstrap")); Field(a, "_bootstrap").ShouldBeSameAs(Field(prov, "_bootstrap")); Field(a, "_bootstrap").ShouldBeSameAs(Field(alarm, "_bootstrap")); Field(a, "_catalog").ShouldBeSameAs(Field(b, "_catalog")); Field(a, "_catalog").ShouldBeSameAs(Field(w, "_catalog")); Field(a, "_catalog").ShouldBeSameAs(Field(u, "_tags")); Field(a, "_catalog").ShouldBeSameAs(Field(prov, "_catalog")); Field(w, "_wal").ShouldBeSameAs(Field(u, "_wal")); Field(w, "_wal").ShouldBeSameAs(Field(prov, "_wal")); Field(w, "_wal").ShouldBeSameAs(Field(alarm, "_wal")); } [Fact] public async Task Disposing_the_provider_disposes_the_shared_pool_and_is_idempotent() { var sp = new NullSp(); var p = Make(); var conn = (QuestDbConnection)Field(p.CreateDataSource(sp), "_conn")!; var pool = (Npgsql.NpgsqlDataSource)Field(conn, "_dataSource")!; await p.DisposeAsync(); Should.Throw(() => pool.CreateConnection().Open()); await p.DisposeAsync(); p.Dispose(); Should.Throw(() => p.CreateDataSource(sp)); } [Fact] public async Task Disposing_an_unused_provider_is_a_no_op() => await Make().DisposeAsync(); [Fact] public void The_constructor_rejects_null_options() => Should.Throw(() => new QuestDbHistorianProvider(null!)); private static object? Field(object o, string name) => o.GetType().GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(o); private sealed class NullSp : IServiceProvider { public object? GetService(Type serviceType) => null; } }