Files
Joseph Doherty 73362a056a
v2-ci / unit-tests (push) Has been cancelled
fix(questdb): disable Npgsql GSS encryption probe unless the connection string sets it (lmxopcua#758)
QuestDB speaks plain PG-wire; Npgsql's default GSS Encryption Mode=Prefer made
Linux central boots log 'Cannot load library libgssapi_krb5.so.2'. An explicit
'GSS Encryption Mode' in the connection string is kept. Not re-verified on a
Linux host here; the Postgres Sql driver is left alone (Kerberos may be wanted).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 12:30:51 -05:00

203 lines
7.9 KiB
C#

using System.Net.Sockets;
using Npgsql;
using NpgsqlTypes;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
public sealed class QuestDbConnectionTests
{
private static DateTime Bound(DateTime v, bool clamp = false)
{
var cmd = new NpgsqlCommand();
var p = QuestDbConnection.BindTimestamp(cmd, v, clamp);
p.NpgsqlDbType.ShouldBe(NpgsqlDbType.TimestampTz);
var bound = (DateTime)p.Value!;
bound.Kind.ShouldBe(DateTimeKind.Utc);
return bound;
}
[Fact]
public void Round_truncates_sub_microsecond_ticks()
{
var t = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc).AddTicks(1234567);
QuestDbConnection.Round(t).ShouldBe(new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc).AddTicks(1234560));
}
[Fact]
public void Round_truncates_rather_than_rounds_before_2000()
{
var t = new DateTime(1990, 5, 5, 0, 0, 0, DateTimeKind.Utc).AddTicks(1234565);
QuestDbConnection.Round(t).Ticks.ShouldBe(t.Ticks - 5);
}
[Fact]
public void Round_treats_Unspecified_as_UTC()
{
var u = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Unspecified);
var r = QuestDbConnection.Round(u);
r.Kind.ShouldBe(DateTimeKind.Utc);
r.Ticks.ShouldBe(u.Ticks);
}
[Fact]
public void Round_converts_Local_to_UTC()
{
var local = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Local);
var r = QuestDbConnection.Round(local);
r.Kind.ShouldBe(DateTimeKind.Utc);
r.ShouldBe(local.ToUniversalTime());
}
[Fact]
public void Round_keeps_a_UTC_value_that_is_already_aligned()
{
var t = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
QuestDbConnection.Round(t).ShouldBe(t);
}
[Theory]
[InlineData(0)] // MinValue itself
[InlineData(5)] // truncates to MinValue
public void BindTimestamp_never_emits_MinValue(long ticks)
{
var b = Bound(new DateTime(ticks, DateTimeKind.Utc));
b.ShouldNotBe(DateTime.MinValue);
b.Ticks.ShouldBe(10);
b.ShouldBe(new DateTime(10, DateTimeKind.Utc)); // 0001-01-01T00:00:00.000001 (T0-confirmed)
}
[Fact]
public void BindTimestamp_never_emits_MaxValue()
{
var b = Bound(DateTime.MaxValue);
b.ShouldNotBe(DateTime.MaxValue);
b.ShouldBe(new DateTime(9999, 12, 31, 23, 59, 59, DateTimeKind.Utc).AddTicks(9999990)); // T0-confirmed bound
Bound(DateTime.MinValue).ShouldNotBe(DateTime.MinValue);
}
[Fact]
public void BindTimestamp_never_emits_either_extreme_for_any_kind()
{
foreach (var kind in new[] { DateTimeKind.Utc, DateTimeKind.Unspecified, DateTimeKind.Local })
foreach (var t in new[] { new DateTime(DateTime.MinValue.Ticks, kind), new DateTime(DateTime.MaxValue.Ticks, kind) })
{
var b = Bound(t);
b.ShouldNotBe(DateTime.MinValue);
b.ShouldNotBe(DateTime.MaxValue);
}
}
[Fact]
public void BindTimestamp_keeps_a_pre_1970_range_bound_unless_asked_to_clamp()
{
var pre = new DateTime(1960, 1, 1, 0, 0, 0, DateTimeKind.Utc);
Bound(pre).ShouldBe(pre);
Bound(pre, clamp: true).ShouldBe(DateTime.UnixEpoch);
Bound(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), clamp: true).Year.ShouldBe(2026);
}
[Fact]
public void IsStorable_is_false_only_before_1970()
{
QuestDbConnection.IsStorable(DateTime.UnixEpoch).ShouldBeTrue();
QuestDbConnection.IsStorable(DateTime.UnixEpoch.AddTicks(-1)).ShouldBeFalse();
QuestDbConnection.IsStorable(new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc)).ShouldBeFalse();
QuestDbConnection.IsStorable(DateTime.MinValue).ShouldBeFalse();
QuestDbConnection.IsStorable(DateTime.MaxValue).ShouldBeTrue();
QuestDbConnection.IsStorable(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)).ShouldBeTrue();
}
[Fact]
public void BindByte_binds_smallint()
{
var p = QuestDbConnection.BindByte(new NpgsqlCommand(), 200);
p.NpgsqlDbType.ShouldBe(NpgsqlDbType.Smallint);
p.Value.ShouldBe((short)200);
}
[Fact]
public void IsTransient_classifies_transport_failures_not_server_rejections()
{
QuestDbConnection.IsTransient(new SocketException()).ShouldBeTrue();
QuestDbConnection.IsTransient(new IOException("x")).ShouldBeTrue();
QuestDbConnection.IsTransient(new TimeoutException()).ShouldBeTrue();
QuestDbConnection.IsTransient(new NpgsqlException("x", new IOException())).ShouldBeTrue();
QuestDbConnection.IsTransient(new InvalidOperationException("x")).ShouldBeFalse();
QuestDbConnection.IsTransient(new OperationCanceledException()).ShouldBeFalse();
}
[Fact]
public void T_prefixes_the_table_base_name()
{
var c = new QuestDbConnection(new QuestDbHistorianOptions { ConnectionString = "Host=127.0.0.1;Port=1", TablePrefix = "p_" });
c.T("sample").ShouldBe("p_sample");
c.SampleTable.ShouldBe("p_sample");
c.EventTable.ShouldBe("p_event");
c.TagTable.ShouldBe("p_tag");
}
// ---- pool size (final review I-1) -------------------------------------------------------------------------------
private static int PoolOf(string cs)
{
using var c = new QuestDbConnection(new QuestDbHistorianOptions { ConnectionString = cs });
return c.EffectiveMaxPoolSize;
}
// ---- GSS probe (#758) -------------------------------------------------------------------------------------------
private static Npgsql.GssEncryptionMode GssOf(string cs)
{
using var c = new QuestDbConnection(new QuestDbHistorianOptions { ConnectionString = cs });
return c.EffectiveGssEncryptionMode;
}
[Fact]
public void An_unset_GSS_Encryption_Mode_is_disabled() =>
GssOf("Host=127.0.0.1;Port=1").ShouldBe(Npgsql.GssEncryptionMode.Disable);
[Theory]
[InlineData("Host=127.0.0.1;Port=1;GSS Encryption Mode=Prefer", Npgsql.GssEncryptionMode.Prefer)]
[InlineData("Host=127.0.0.1;Port=1;GssEncryptionMode=Require", Npgsql.GssEncryptionMode.Require)]
public void An_explicit_GSS_Encryption_Mode_is_kept(string cs, Npgsql.GssEncryptionMode expected) =>
GssOf(cs).ShouldBe(expected);
[Fact]
public void An_unset_Maximum_Pool_Size_is_capped_at_24() =>
PoolOf("Host=127.0.0.1;Port=1").ShouldBe(24);
[Fact]
public void The_default_cap_constant_is_24() => QuestDbConnection.DefaultMaxPoolSize.ShouldBe(24);
[Theory]
[InlineData("Host=127.0.0.1;Port=1;Maximum Pool Size=10", 10)]
[InlineData("Host=127.0.0.1;Port=1;Maximum Pool Size=80", 80)]
[InlineData("Host=127.0.0.1;Port=1;maximum pool size=30", 30)]
[InlineData("Host=127.0.0.1;Port=1;MaxPoolSize=80", 80)]
public void An_explicit_Maximum_Pool_Size_is_preserved(string cs, int expected) =>
PoolOf(cs).ShouldBe(expected);
[Fact]
public void The_effective_pool_size_is_logged_once_at_Information()
{
var log = new InfoLogger();
using var c = new QuestDbConnection(new QuestDbHistorianOptions { ConnectionString = "Host=127.0.0.1;Port=1" }, log);
log.Messages.Count.ShouldBe(1);
log.Messages[0].ShouldContain("24");
}
private sealed class InfoLogger : Microsoft.Extensions.Logging.ILogger
{
public List<string> Messages { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
{
if (logLevel == Microsoft.Extensions.Logging.LogLevel.Information) Messages.Add(formatter(state, exception));
}
}
}