test(sql): env-gated central-SQL integration fixture + read round-trip

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 15:06:55 -04:00
parent 9e7fa5d11f
commit 48cbc26c34
4 changed files with 1172 additions and 0 deletions
@@ -0,0 +1,469 @@
using System.Globalization;
using Microsoft.Data.SqlClient;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// A real, seeded <b>SQL Server</b> database standing in for the source the <c>Sql</c> driver polls —
/// the live counterpart of the offline <c>SqlitePollFixture</c>, and the only place the shipped
/// <c>Microsoft.Data.SqlClient</c> path, the real <c>INFORMATION_SCHEMA</c> catalog SQL, and genuine
/// T-SQL column types are exercised at all.
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
/// nothing unless <see cref="ConnectionStringEnvVar"/> — or <see cref="EndpointEnvVar"/> plus a
/// password — is set; absent, it records <see cref="SkipReason"/> and every test calls
/// <c>Assert.Skip</c>. That is what keeps <c>dotnet test</c> genuinely green (and instant) on the
/// macOS dev box this is usually run from, which is the same contract every other
/// <c>*.IntegrationTests</c> fixture in this tree honours.</para>
/// <para><b>Safety against the shared central server (design §9).</b> The always-on SQL Server on the
/// docker host hosts <c>ConfigDb</c> for the whole dev rig, so this fixture is deliberately
/// conservative:</para>
/// <list type="bullet">
/// <item>It uses <b>its own database</b>, <see cref="FixtureDatabase"/> — the supplied connection
/// string's catalog is used only to reach <c>master</c> and is otherwise <b>ignored</b>, so a
/// mis-set env var cannot aim the seed at somebody else's schema.</item>
/// <item>A supplied catalog literally named <c>ConfigDb</c> is rejected <b>loudly, at
/// construction</b>, rather than quietly retargeted — it is the signature of a copied-and-pasted
/// connection string, and the operator should know their env var is wrong.</item>
/// <item>It <b>creates the database only if missing</b> and <b>never drops a database</b>, so
/// re-running the suite is safe and an operator-created <c>SqlPollFixture</c> is never destroyed.
/// Only the objects inside its own <see cref="Schema"/> schema are dropped and recreated, which
/// is what makes the seed deterministic across runs.</item>
/// </list>
/// <para><b>The seed is a contract, not scaffolding.</b> The constants below mirror
/// <c>SqlitePollFixture</c>'s shape — a present value, a present row with a <b>NULL</b> cell, an
/// <b>absent</b> key, and a wide-row table whose newest row is deliberately not its first — so the
/// same assertions can be made offline and live, and a divergence between the SQLite and SQL Server
/// paths shows up as a failing assertion rather than as two suites that quietly test different
/// things. <see cref="TypeProbeTable"/> has no offline counterpart: it exists to pin
/// <c>SqlServerDialect.MapColumnType</c> against the type names SQL Server <em>actually</em> reports,
/// which a hand-written mapping table cannot do.</para>
/// </summary>
public sealed class SqlPollServerFixture : IAsyncLifetime
{
/// <summary>A full ADO.NET connection string for the fixture server. Takes precedence when set.</summary>
public const string ConnectionStringEnvVar = "Sql__ConnectionStrings__Fixture";
/// <summary>
/// Server endpoint in <c>host,port</c> form (e.g. <c>10.100.0.35,14330</c>) — the lighter-weight
/// gate. Requires <see cref="PasswordEnvVar"/>; <see cref="UserEnvVar"/> defaults to <c>sa</c>.
/// </summary>
public const string EndpointEnvVar = "SQL_TEST_ENDPOINT";
/// <summary>SQL login for the <see cref="EndpointEnvVar"/> form. Defaults to <c>sa</c>.</summary>
public const string UserEnvVar = "SQL_TEST_USER";
/// <summary>SQL password for the <see cref="EndpointEnvVar"/> form. Required by that form.</summary>
public const string PasswordEnvVar = "SQL_TEST_PASSWORD";
/// <summary>The database this fixture creates and seeds. Never <c>ConfigDb</c>, never dropped.</summary>
public const string FixtureDatabase = "SqlPollFixture";
/// <summary>
/// The schema every seeded object lives in. Deliberately <b>not</b> <c>dbo</c>: it scopes the
/// drop-and-recreate to objects this fixture owns, and it makes every authored <c>table</c> a
/// two-part name, so the planner's <c>QuoteQualifiedName</c> (<c>[sqlpoll].[TagValues]</c>) is
/// exercised against a real server rather than only in a unit test.
/// </summary>
public const string Schema = "sqlpoll";
/// <summary>
/// The <c>Application Name</c> the driver's connections carry, so the pooling assertion can count
/// exactly this suite's sessions on a server shared with the whole dev rig.
/// </summary>
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.driver";
/// <summary>The <c>Application Name</c> the fixture's own inspection connections carry.</summary>
private const string FixtureApplicationName = "OtOpcUa.Sql.ITs.fixture";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 5;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>tag_name nvarchar(128), num_value float NULL, sample_ts datetime2</c>.</summary>
public const string KeyValueTable = "TagValues";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "tag_name";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "num_value";
/// <summary>The source-timestamp column carried by every seeded table.</summary>
public const string TimestampColumn = "sample_ts";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
public const string SecondPresentKey = "Line1.Pressure";
/// <summary>The value seeded for <see cref="SecondPresentKey"/>.</summary>
public const double SecondPresentValue = 3.5;
/// <summary>A key whose row <b>exists</b> but whose value cell is <c>NULL</c>.</summary>
public const string NullValueKey = "Line1.Temp";
/// <summary>A key with <b>no row at all</b> — a different outcome from <see cref="NullValueKey"/>.</summary>
public const string AbsentKey = "Line1.Missing";
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
public static readonly DateTime PresentKeyTimestampUtc =
new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// <summary>The wide-row table: <c>station_id int, oven_temp float NULL, pressure float NULL, sample_ts datetime2</c>.</summary>
public const string WideRowTable = "LatestStatus";
/// <summary>The wide-row table's row-selector column.</summary>
public const string WideRowSelectorColumn = "station_id";
/// <summary>A station whose row exists, with both value columns populated.</summary>
public const string PresentStation = "7";
/// <summary><see cref="PresentStation"/>'s <c>oven_temp</c>.</summary>
public const double PresentStationOvenTemp = 180.5;
/// <summary><see cref="PresentStation"/>'s <c>pressure</c>.</summary>
public const double PresentStationPressure = 1.2;
/// <summary>
/// The station holding the <b>newest</b> <c>sample_ts</c> — what a <c>topByTimestamp</c> selector
/// must resolve to. Deliberately not the first-inserted row, so a plan that loses its
/// <c>ORDER BY … DESC</c> or its <c>SELECT TOP 1</c> picks the wrong one and the test says so.
/// </summary>
public const string NewestStation = "8";
/// <summary><see cref="NewestStation"/>'s <c>oven_temp</c> — what a <c>topByTimestamp</c> plan yields.</summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>A station whose row exists but whose <c>oven_temp</c> cell is <c>NULL</c>.</summary>
public const string NullOvenTempStation = "9";
/// <summary>A station with no row at all.</summary>
public const string AbsentStation = "404";
/// <summary>A view over <see cref="WideRowTable"/> — the <c>TABLE_TYPE = 'VIEW'</c> catalog row.</summary>
public const string WideRowView = "LatestStatusView";
// ---- T-SQL type-coverage source ----
/// <summary>
/// One wide row carrying one column per T-SQL family the dialect claims to map. Read with no
/// authored <c>type</c>, each column's driver type is inferred from what
/// <c>SqlDataReader.GetDataTypeName</c> reports — which is the only way to find out whether
/// <c>SqlServerDialect.MapColumnType</c>'s table matches reality rather than matching itself.
/// </summary>
public const string TypeProbeTable = "TypeProbe";
/// <summary>The single-row selector column of <see cref="TypeProbeTable"/>.</summary>
public const string TypeProbeSelectorColumn = "probe_id";
/// <summary>The seeded row's <see cref="TypeProbeSelectorColumn"/> value.</summary>
public const string TypeProbeRow = "1";
/// <summary>Seeded <c>bit</c> value.</summary>
public const bool ProbeBit = true;
/// <summary>Seeded <c>int</c> value — <c>int.MaxValue</c>, so a narrowing coercion overflows loudly.</summary>
public const int ProbeInt = int.MaxValue;
/// <summary>Seeded <c>bigint</c> value — beyond exact <c>double</c> range, so an Int64→Float64 slip shows.</summary>
public const long ProbeBigInt = 9007199254740993L;
/// <summary>Seeded <c>real</c> value; exactly representable, so equality is a fair assertion.</summary>
public const float ProbeReal = 1.5f;
/// <summary>Seeded <c>float</c> value; exactly representable.</summary>
public const double ProbeFloat = 3.25d;
/// <summary>Seeded <c>decimal(18,4)</c> value — the documented Float64 precision collapse.</summary>
public const decimal ProbeDecimal = 123.4567m;
/// <summary>Seeded <c>nvarchar(64)</c> value.</summary>
public const string ProbeNVarChar = "hello";
/// <summary>Seeded <c>datetime2(3)</c> value, read back as UTC.</summary>
public static readonly DateTime ProbeDateTimeUtc =
new(2026, 7, 24, 10, 0, 3, DateTimeKind.Utc);
/// <summary>
/// Why the suite is skipping, or <see langword="null"/> when the fixture is seeded and usable.
/// Tests read this and call <c>Assert.Skip</c> — the house idiom
/// (<c>Snap7ServerFixture</c> / <c>ModbusSimulatorFixture</c>).
/// </summary>
public string? SkipReason { get; private set; }
/// <summary>
/// The connection string to hand the code under test: the seeded <see cref="FixtureDatabase"/>,
/// tagged with <see cref="DriverApplicationName"/>. Empty while <see cref="SkipReason"/> is set.
/// </summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>The server the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Connection string the fixture's own inspection connections use. Empty while skipping.</summary>
private string _inspectionConnectionString = string.Empty;
/// <summary>
/// Resolves the gate, creates <see cref="FixtureDatabase"/> if missing, and (re)seeds this
/// fixture's schema.
/// <para>The two failure modes are handled deliberately differently: <b>failing to connect</b> is
/// the offline case and becomes a <see cref="SkipReason"/>, while <b>failing after the connection
/// opened</b> (DDL, seeding) is a real fault against a reachable server and is allowed to throw —
/// swallowing that would leave the suite silently testing an unseeded database.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
if (ResolveConnectionString() is not { } supplied)
{
SkipReason =
$"Set {ConnectionStringEnvVar} (a full connection string) — or {EndpointEnvVar} " +
$"(host,port) plus {PasswordEnvVar} (and optionally {UserEnvVar}, default 'sa') — to run " +
"the Sql driver's live SQL Server suite. No connection is attempted without it.";
return;
}
var builder = new SqlConnectionStringBuilder(supplied);
GuardAgainstConfigDb(builder);
if (!builder.ContainsKey("Connect Timeout")) builder.ConnectTimeout = ProbeConnectTimeoutSeconds;
Server = builder.DataSource;
// The supplied catalog is used ONLY to reach master; the seed always lands in FixtureDatabase.
var master = new SqlConnectionStringBuilder(builder.ConnectionString)
{
InitialCatalog = "master",
ApplicationName = FixtureApplicationName,
}.ConnectionString;
SqlConnection connection;
try
{
connection = new SqlConnection(master);
await connection.OpenAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
SkipReason =
$"SQL Server at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " +
$"({ex.GetType().Name}: {ex.Message}). Check the endpoint and credentials in " +
$"{ConnectionStringEnvVar}/{EndpointEnvVar}, or leave them unset to skip this suite.";
return;
}
await using (connection.ConfigureAwait(false))
{
// Create-if-missing. This fixture never drops a database — an existing SqlPollFixture, whoever
// made it, keeps everything outside this fixture's own schema.
await ExecuteAsync(
connection,
$"IF DB_ID(N'{FixtureDatabase}') IS NULL CREATE DATABASE [{FixtureDatabase}];")
.ConfigureAwait(false);
}
ConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString)
{
InitialCatalog = FixtureDatabase,
ApplicationName = DriverApplicationName,
}.ConnectionString;
_inspectionConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString)
{
InitialCatalog = FixtureDatabase,
ApplicationName = FixtureApplicationName,
}.ConnectionString;
await SeedAsync().ConfigureAwait(false);
}
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// Opens a fresh connection to the seeded database for direct arrangement or inspection, tagged
/// with the fixture's own application name so it is never counted by the pooling assertion.
/// </summary>
/// <returns>An open connection the caller owns and must dispose.</returns>
public async Task<SqlConnection> OpenInspectionConnectionAsync()
{
var connection = new SqlConnection(_inspectionConnectionString);
await connection.OpenAsync().ConfigureAwait(false);
return connection;
}
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
/// <param name="table">The bare table or view name.</param>
/// <returns>The <c>schema.table</c> name.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <summary>
/// Reads the connection-string gate. Returns <see langword="null"/> — touching no socket — when
/// neither form is configured.
/// </summary>
private static string? ResolveConnectionString()
{
if (Environment.GetEnvironmentVariable(ConnectionStringEnvVar) is { Length: > 0 } full)
return full;
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint)
return null;
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
// failed login against a shared server, which is how accounts get locked out.
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password)
return null;
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa";
return new SqlConnectionStringBuilder
{
DataSource = endpoint,
InitialCatalog = FixtureDatabase,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = false,
}.ConnectionString;
}
/// <summary>
/// Refuses, loudly and before any I/O, a connection string aimed at the rig's shared
/// <c>ConfigDb</c>. The catalog is ignored either way (everything lands in
/// <see cref="FixtureDatabase"/>), so this exists purely to tell an operator their env var is
/// wrong rather than to prevent damage that could otherwise occur.
/// </summary>
/// <exception cref="InvalidOperationException">The supplied catalog is named <c>ConfigDb</c>.</exception>
private static void GuardAgainstConfigDb(SqlConnectionStringBuilder builder)
{
if (!string.Equals(builder.InitialCatalog, "ConfigDb", StringComparison.OrdinalIgnoreCase)) return;
throw new InvalidOperationException(
$"{ConnectionStringEnvVar} names the database 'ConfigDb'. That is the dev rig's shared " +
$"configuration database; this fixture seeds its own '{FixtureDatabase}' database and must " +
"never be pointed at ConfigDb. Point the connection string at 'master' (or omit the catalog).");
}
/// <summary>
/// Drops and recreates this fixture's schema and every object in it, then inserts the seed rows.
/// <para>Drop-and-recreate rather than merge-or-insert because the seed is an assertion contract:
/// a leftover row from an older revision of this file would make a passing test meaningless. The
/// blast radius is bounded to the <see cref="Schema"/> schema, which nothing else creates.</para>
/// </summary>
private async Task SeedAsync()
{
var connection = await OpenInspectionConnectionAsync().ConfigureAwait(false);
await using (connection.ConfigureAwait(false))
{
// Views first: a view over a dropped table blocks nothing, but dropping the table under a live
// view leaves the catalog reporting a column set that no longer resolves.
foreach (var statement in SeedStatements())
await ExecuteAsync(connection, statement).ConfigureAwait(false);
}
}
/// <summary>
/// The seed, one batch per statement. <c>CREATE SCHEMA</c> and <c>CREATE VIEW</c> must each be the
/// first statement of their batch in T-SQL, which is why this is a list rather than one script.
/// <para>Every numeric literal is composed under <see cref="CultureInfo.InvariantCulture"/>: plain
/// interpolation would emit <c>180,5</c> on a comma-decimal machine, and T-SQL would read that as
/// two column values rather than as one wrong number — a seed failure with a baffling message.</para>
/// </summary>
private static IEnumerable<string> SeedStatements()
{
yield return $"DROP VIEW IF EXISTS [{Schema}].[{WideRowView}];";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{WideRowTable}];";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{TypeProbeTable}];";
yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');";
yield return $"""
CREATE TABLE [{Schema}].[{KeyValueTable}] (
[{KeyColumn}] nvarchar(128) NOT NULL,
[{ValueColumn}] float NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}])
VALUES (N'{PresentKey}', {PresentValue}, '2026-07-24T10:00:00'),
(N'{NullValueKey}', NULL, '2026-07-24T10:00:01'),
(N'{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02');
""");
yield return $"""
CREATE TABLE [{Schema}].[{WideRowTable}] (
[{WideRowSelectorColumn}] int NOT NULL,
[oven_temp] float NULL,
[pressure] float NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
// NewestStation is inserted LAST but is also the newest timestamp; PresentStation is first. A
// topByTimestamp plan that lost its ORDER BY would still pick a plausible-looking row, so the two
// orderings are kept deliberately different.
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{WideRowTable}]
([{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}])
VALUES ({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00'),
({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01'),
({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02');
""");
yield return $"""
CREATE VIEW [{Schema}].[{WideRowView}] AS
SELECT [{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}]
FROM [{Schema}].[{WideRowTable}];
""";
yield return $"""
CREATE TABLE [{Schema}].[{TypeProbeTable}] (
[{TypeProbeSelectorColumn}] int NOT NULL,
[bit_col] bit NULL,
[int_col] int NULL,
[bigint_col] bigint NULL,
[real_col] real NULL,
[float_col] float NULL,
[decimal_col] decimal(18,4) NULL,
[nvarchar_col] nvarchar(64) NULL,
[datetime2_col] datetime2(3) NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{TypeProbeTable}]
([{TypeProbeSelectorColumn}], [bit_col], [int_col], [bigint_col], [real_col], [float_col],
[decimal_col], [nvarchar_col], [datetime2_col], [{TimestampColumn}])
VALUES ({TypeProbeRow}, 1, {ProbeInt}, {ProbeBigInt}, {ProbeReal}, {ProbeFloat},
{ProbeDecimal}, N'{ProbeNVarChar}', '2026-07-24T10:00:03', '2026-07-24T10:00:03');
""");
}
/// <summary>Runs one non-query batch.</summary>
private static async Task ExecuteAsync(SqlConnection connection, string sql)
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = sql;
await command.ExecuteNonQueryAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Collection definition so the seed runs once per test session rather than once per test class —
/// the same shape as <c>Snap7ServerCollection</c>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class SqlPollServerCollection : ICollectionFixture<SqlPollServerFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlPollServer";
}