using System.Data; using System.Data.Common; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// /// Proves — the AdminUI "Test Connect" liveness check — against the real /// SQLite fixture. A probe opens a connection, runs the dialect's SELECT 1, and reports green with /// latency or red with a reason; it never throws (the contract). /// The probe parses the SAME factory DTO shape as (R2-11 /// factory parity, mirroring ModbusDriverProbe), so a config that Tests-green and a config that /// Deploys are the same config. It resolves the connectionStringRef the same way too — and the /// tests below assert the resolved connection string never reaches the red-result message. /// public sealed class SqlDriverProbeTests { [Fact] public async Task Probe_liveConnection_returnsGreen() { using var fixture = new SqlitePollFixture(); var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); var result = await probe.ProbeAsync( ConfigJson(fixture), TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeTrue(); result.Latency.ShouldNotBeNull(); result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero); } [Fact] public async Task Probe_declaresTheSqlDriverType() => SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()) .DriverType.ShouldBe(SqlDriver.DriverTypeName); [Fact] public async Task Probe_aBadConnectionString_returnsRed_withoutThrowing() { // A connection string that resolves fine but points at nothing openable. The probe must catch the // provider's failure and hand back a red result, never propagate it. var probe = SqlDriverProbe.ForTest( new FailingFactory("simulated open failure"), new SqliteDialect()); var result = await probe.ProbeAsync( ConfigJson(connectionString: "Data Source=/no/such/place.db"), TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNullOrWhiteSpace(); result.Latency.ShouldBeNull(); } [Fact] public async Task Probe_missingConnectionStringRef_returnsRed_notAThrow() { var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()); var result = await probe.ProbeAsync( """{"provider":"SqlServer"}""", TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldContain("connectionStringRef"); } [Fact] public async Task Probe_configThatIsNotJson_returnsRed_notAThrow() { var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect()); var result = await probe.ProbeAsync( "definitely not json", TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNullOrWhiteSpace(); } [Fact] public async Task Probe_providerNumberSpelling_parses_soTestConnectMatchesDeploy() { // R2-11: the probe and the factory read the SAME DTO with the SAME converter, so a numerically // serialised provider Test-Connects exactly as it Deploys. using var fixture = new SqlitePollFixture(); var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); var result = await probe.ProbeAsync( ConfigWithProviderAsNumber(fixture), TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeTrue(); } [Fact] public async Task Probe_neverPutsTheResolvedConnectionStringIntoTheMessage() { const string secret = "Password=DoNotLeakMeIntoAProbeMessage"; var connectionString = $"Data Source=/no/such.db;{secret}"; // The realistic leak shape: a real provider (e.g. SqlException) embeds the connection target in its // OWN exception message. This fake reproduces that — it throws with the connection string in the // message — so a probe that surfaced ex.Message would leak, and this test would then go red. var probe = SqlDriverProbe.ForTest(new LeakyFailingFactory(), new SqliteDialect()); var result = await probe.ProbeAsync( ConfigJson(connectionString: connectionString), TimeSpan.FromSeconds(5), CancellationToken.None); result.Ok.ShouldBeFalse(); result.Message.ShouldNotBeNull(); result.Message!.ShouldNotContain("DoNotLeakMeIntoAProbeMessage"); } [Fact] public async Task Probe_honoursCancellation_asRed_notAThrow() { // A cancelled probe is still a probe result, not an exception the AdminUI has to catch. using var fixture = new SqlitePollFixture(); var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect()); using var cancelled = new CancellationTokenSource(); await cancelled.CancelAsync(); var result = await probe.ProbeAsync(ConfigJson(fixture), TimeSpan.FromSeconds(5), cancelled.Token); result.Ok.ShouldBeFalse(); } // ---- helpers ---- /// A config blob whose connectionStringRef resolves (for the life of the returned scope) to the /// fixture's real database, so the probe's SELECT 1 actually runs. private static string ConfigJson(SqlitePollFixture fixture) => ConfigJson(connectionString: fixture.ConnectionString); private static string ConfigWithProviderAsNumber(SqlitePollFixture fixture) { var name = SetRef(fixture.ConnectionString); return $$"""{"provider":0,"connectionStringRef":"{{name}}"}"""; } /// /// Builds a config JSON whose connectionStringRef is provisioned to /// via the process environment — the exact resolution path the /// factory uses. The env var is set for the test process; each call mints a unique ref so parallel /// tests cannot collide, and the value is never unset (a harmless per-run leak of a random name). /// private static string ConfigJson(string connectionString) { var name = SetRef(connectionString); return $$"""{"provider":"SqlServer","connectionStringRef":"{{name}}"}"""; } private static string SetRef(string connectionString) { var name = $"OtOpcUaSqlProbe{Guid.NewGuid():N}"; Environment.SetEnvironmentVariable( SqlConnectionStringResolver.EnvironmentVariableFor(name), connectionString); return name; } /// A whose connections throw on open — a database that cannot be /// reached, without needing a real unreachable endpoint. private sealed class FailingFactory(string message) : DbProviderFactory { public override DbConnection CreateConnection() => new FailingConnection(message); } /// Like , but its open failure embeds the connection string in the /// thrown exception's message — the realistic provider shape that a naive ex.Message would leak. private sealed class LeakyFailingFactory : DbProviderFactory { public override DbConnection CreateConnection() => new FailingConnection(message: null); } private sealed class FailingConnection(string? message) : DbConnection { [System.Diagnostics.CodeAnalysis.AllowNull] public override string ConnectionString { get; set; } = string.Empty; public override string Database => string.Empty; public override string DataSource => string.Empty; public override string ServerVersion => string.Empty; public override ConnectionState State => ConnectionState.Closed; // A null ctor message means "embed the connection string" — the leaky provider shape. private string FailureMessage => message ?? $"connect failed for {ConnectionString}"; public override void Open() => throw new InvalidOperationException(FailureMessage); public override Task OpenAsync(CancellationToken cancellationToken) => throw new InvalidOperationException(FailureMessage); public override void Close() { } public override void ChangeDatabase(string databaseName) { } protected override DbTransaction BeginDbTransaction(System.Data.IsolationLevel isolationLevel) => throw new NotSupportedException(); protected override DbCommand CreateDbCommand() => throw new NotSupportedException(); } }