fix(sql): Test Connect must construct Postgres and Oracle dialects too

SqlDriverProbe.DefaultDialectFor was left SqlServer-only when the provider
gates were widened, so AdminUI Test Connect failed for both new providers
with InvalidOperationException while Deploy and browse worked. Found by the
live chrome/AdminUI gate the plan deferred, not by any test: every case in
SqlDriverProbeTests builds its probe through ForTest(), which injects the
dialect and never reaches the production switch.

Widen the switch arm-for-arm with SqlDriverFactoryExtensions.CreateDialect,
and cover the gate directly via an internal seam — plus a reserved-provider
falsification control and an end-to-end production-constructor case.
This commit is contained in:
Joseph Doherty
2026-08-05 07:43:31 -04:00
parent c89da8eb73
commit 75a057f268
2 changed files with 77 additions and 2 deletions
@@ -164,11 +164,37 @@ public sealed class SqlDriverProbe : IDriverProbe
}
}
/// <summary>Constructs the production dialect for a provider; v1 supports SQL Server only.</summary>
/// <summary>
/// Test seam over <see cref="DefaultDialectFor"/>. The parameterless constructor is the only
/// production path into that switch, and <see cref="ForTest"/> deliberately bypasses it — so without
/// this seam the gate has no direct coverage at all, which is how it kept a SqlServer-only switch
/// after the factory and browser were widened.
/// </summary>
/// <param name="provider">The provider whose dialect to construct.</param>
/// <returns>The dialect this build constructs for <paramref name="provider"/>.</returns>
/// <exception cref="InvalidOperationException">The provider is reserved and not constructed.</exception>
internal static ISqlDialect DefaultDialectForTest(SqlProvider provider) => DefaultDialectFor(provider);
/// <summary>
/// Constructs the production dialect for a provider — <b>the same set
/// <see cref="SqlDriverFactoryExtensions"/> constructs</b>, because Test Connect must exercise the
/// backend a Deploy would actually use. This build constructs <see cref="SqlProvider.SqlServer"/>,
/// <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/>;
/// <see cref="SqlProvider.MySql"/> and <see cref="SqlProvider.Odbc"/> stay reserved.
/// <para><b>Keep this arm-for-arm with the factory's <c>CreateDialect</c>.</b> When the factory and the
/// browser were widened to Postgres + Oracle this switch was left SqlServer-only, so a config that
/// deployed and browsed fine failed Test Connect with an <see cref="InvalidOperationException"/> — and
/// no test noticed, because every probe test injects its dialect through <see cref="ForTest"/>.</para>
/// </summary>
private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
SqlProvider.Postgres => new PostgresDialect(),
SqlProvider.Oracle => new OracleDialect(),
_ => throw new InvalidOperationException(
$"provider '{provider}' is not available in this build (only SqlServer is supported)."),
$"provider '{provider}' is not available in this build. Supported: " +
$"'{nameof(SqlProvider.SqlServer)}', '{nameof(SqlProvider.Postgres)}', " +
$"'{nameof(SqlProvider.Oracle)}'. Reserved (declared on the enum but not implemented): " +
$"'{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
};
}
@@ -3,6 +3,7 @@ using System.Data.Common;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
@@ -125,6 +126,54 @@ public sealed class SqlDriverProbeTests
result.Ok.ShouldBeFalse();
}
/// <summary>
/// The <b>production</b> dialect gate — the one seam every other test in this file bypasses.
/// <para>Each case above builds its probe with <see cref="SqlDriverProbe.ForTest"/>, which injects
/// <c>_ => dialect</c> and therefore never reaches <c>DefaultDialectFor</c>. That is exactly how the
/// probe kept a SqlServer-only gate while the factory and the browser were widened to Postgres +
/// Oracle: the whole suite stayed green, and AdminUI "Test Connect" failed for two of the three
/// shipped providers with <c>InvalidOperationException</c>. Caught by the live chrome/AdminUI gate,
/// not by any test — so this asserts the gate directly.</para>
/// </summary>
[Theory]
[InlineData(SqlProvider.SqlServer)]
[InlineData(SqlProvider.Postgres)]
[InlineData(SqlProvider.Oracle)]
public void ProductionDialectGate_constructsEveryShippedProvider(SqlProvider provider)
=> SqlDriverProbe.DefaultDialectForTest(provider).Provider.ShouldBe(provider);
/// <summary>
/// The falsification control for the Theory above: the two <b>reserved</b> members must still be
/// rejected. Without this, widening the gate to "return a dialect for anything" would pass.
/// </summary>
[Theory]
[InlineData(SqlProvider.MySql)]
[InlineData(SqlProvider.Odbc)]
public void ProductionDialectGate_stillRejectsReservedProviders(SqlProvider provider)
=> Should.Throw<InvalidOperationException>(() => SqlDriverProbe.DefaultDialectForTest(provider))
.Message.ShouldContain(provider.ToString());
/// <summary>
/// End-to-end through the <b>production constructor</b>: a Postgres config must get PAST dialect
/// selection and fail at the connection attempt instead. Both outcomes are a red result whose message
/// names only an exception type, so the type is the discriminator — with the SqlServer-only gate this
/// is <c>InvalidOperationException</c> thrown before any socket is opened.
/// </summary>
[Fact]
public async Task ProductionProbe_postgresConfig_reachesTheConnectionAttempt_notTheProviderGate()
{
// Port 1 on the loopback: nothing listens, so the connect fails fast and locally — no fixture, no
// network egress, and a failure that is the PROVIDER's, which is the point of the assertion.
var name = SetRef("Host=127.0.0.1;Port=1;Database=x;Username=u;Password=p;Timeout=2");
var result = await new SqlDriverProbe().ProbeAsync(
$$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""",
TimeSpan.FromSeconds(10), CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldNotContain(nameof(InvalidOperationException));
}
// ---- helpers ----
/// <summary>A config blob whose connectionStringRef resolves (for the life of the returned scope) to the