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();
}
}