using System.Globalization; using Microsoft.Data.SqlClient; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; /// /// A real, seeded SQL Server database standing in for the source the Sql driver polls — /// the live counterpart of the offline SqlitePollFixture, and the only place the shipped /// Microsoft.Data.SqlClient path, the real INFORMATION_SCHEMA catalog SQL, and genuine /// T-SQL column types are exercised at all. /// Env-gated, and the gate is checked before any socket is touched. This fixture opens /// nothing unless — or plus a /// password — is set; absent, it records and every test calls /// Assert.Skip. That is what keeps dotnet test genuinely green (and instant) on the /// macOS dev box this is usually run from, which is the same contract every other /// *.IntegrationTests fixture in this tree honours. /// Safety against the shared central server (design §9). The always-on SQL Server on the /// docker host hosts ConfigDb for the whole dev rig, so this fixture is deliberately /// conservative: /// /// It uses its own database, — the supplied connection /// string's catalog is used only to reach master and is otherwise ignored, so a /// mis-set env var cannot aim the seed at somebody else's schema. /// A supplied catalog literally named ConfigDb is rejected loudly, at /// construction, 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. /// It creates the database only if missing and never drops a database, so /// re-running the suite is safe and an operator-created SqlPollFixture is never destroyed. /// Only the objects inside its own schema are dropped and recreated, which /// is what makes the seed deterministic across runs. /// /// The seed is a contract, not scaffolding. The constants below mirror /// SqlitePollFixture's shape — a present value, a present row with a NULL cell, an /// absent 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. has no offline counterpart: it exists to pin /// SqlServerDialect.MapColumnType against the type names SQL Server actually reports, /// which a hand-written mapping table cannot do. /// public sealed class SqlPollServerFixture : IAsyncLifetime { /// A full ADO.NET connection string for the fixture server. Takes precedence when set. public const string ConnectionStringEnvVar = "Sql__ConnectionStrings__Fixture"; /// /// Server endpoint in host,port form (e.g. 10.100.0.35,14330) — the lighter-weight /// gate. Requires ; defaults to sa. /// public const string EndpointEnvVar = "SQL_TEST_ENDPOINT"; /// SQL login for the form. Defaults to sa. public const string UserEnvVar = "SQL_TEST_USER"; /// SQL password for the form. Required by that form. public const string PasswordEnvVar = "SQL_TEST_PASSWORD"; /// The database this fixture creates and seeds. Never ConfigDb, never dropped. public const string FixtureDatabase = "SqlPollFixture"; /// /// The schema every seeded object lives in. Deliberately not dbo: it scopes the /// drop-and-recreate to objects this fixture owns, and it makes every authored table a /// two-part name, so the planner's QuoteQualifiedName ([sqlpoll].[TagValues]) is /// exercised against a real server rather than only in a unit test. /// public const string Schema = "sqlpoll"; /// /// The Application Name 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. /// public const string DriverApplicationName = "OtOpcUa.Sql.ITs.driver"; /// The Application Name the fixture's own inspection connections carry. private const string FixtureApplicationName = "OtOpcUa.Sql.ITs.fixture"; /// Seconds a connect attempt may take before the fixture calls the server unreachable. private const int ProbeConnectTimeoutSeconds = 5; // ---- key-value (EAV) source ---- /// The key-value table: tag_name nvarchar(128), num_value float NULL, sample_ts datetime2. public const string KeyValueTable = "TagValues"; /// The key-value table's key column. public const string KeyColumn = "tag_name"; /// The key-value table's value column. public const string ValueColumn = "num_value"; /// The source-timestamp column carried by every seeded table. public const string TimestampColumn = "sample_ts"; /// A key whose row exists and whose value cell holds . public const string PresentKey = "Line1.Speed"; /// The value seeded for . public const double PresentValue = 42.0; /// A second present key, so a group can legitimately fold more than one member. public const string SecondPresentKey = "Line1.Pressure"; /// The value seeded for . public const double SecondPresentValue = 3.5; /// A key whose row exists but whose value cell is NULL. public const string NullValueKey = "Line1.Temp"; /// A key with no row at all — a different outcome from . public const string AbsentKey = "Line1.Missing"; /// The UTC source timestamp seeded for . public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc); // ---- wide-row source ---- /// The wide-row table: station_id int, oven_temp float NULL, pressure float NULL, sample_ts datetime2. public const string WideRowTable = "LatestStatus"; /// The wide-row table's row-selector column. public const string WideRowSelectorColumn = "station_id"; /// A station whose row exists, with both value columns populated. public const string PresentStation = "7"; /// 's oven_temp. public const double PresentStationOvenTemp = 180.5; /// 's pressure. public const double PresentStationPressure = 1.2; /// /// The station holding the newest sample_ts — what a topByTimestamp selector /// must resolve to. Deliberately not the first-inserted row, so a plan that loses its /// ORDER BY … DESC or its SELECT TOP 1 picks the wrong one and the test says so. /// public const string NewestStation = "8"; /// 's oven_temp — what a topByTimestamp plan yields. public const double NewestStationOvenTemp = 210.25; /// A station whose row exists but whose oven_temp cell is NULL. public const string NullOvenTempStation = "9"; /// A station with no row at all. public const string AbsentStation = "404"; /// A view over — the TABLE_TYPE = 'VIEW' catalog row. public const string WideRowView = "LatestStatusView"; // ---- T-SQL type-coverage source ---- /// /// One wide row carrying one column per T-SQL family the dialect claims to map. Read with no /// authored type, each column's driver type is inferred from what /// SqlDataReader.GetDataTypeName reports — which is the only way to find out whether /// SqlServerDialect.MapColumnType's table matches reality rather than matching itself. /// public const string TypeProbeTable = "TypeProbe"; /// The single-row selector column of . public const string TypeProbeSelectorColumn = "probe_id"; /// The seeded row's value. public const string TypeProbeRow = "1"; /// Seeded bit value. public const bool ProbeBit = true; /// Seeded int value — int.MaxValue, so a narrowing coercion overflows loudly. public const int ProbeInt = int.MaxValue; /// Seeded bigint value — beyond exact double range, so an Int64→Float64 slip shows. public const long ProbeBigInt = 9007199254740993L; /// Seeded real value; exactly representable, so equality is a fair assertion. public const float ProbeReal = 1.5f; /// Seeded float value; exactly representable. public const double ProbeFloat = 3.25d; /// Seeded decimal(18,4) value — the documented Float64 precision collapse. public const decimal ProbeDecimal = 123.4567m; /// Seeded nvarchar(64) value. public const string ProbeNVarChar = "hello"; /// Seeded datetime2(3) value, read back as UTC. public static readonly DateTime ProbeDateTimeUtc = new(2026, 7, 24, 10, 0, 3, DateTimeKind.Utc); /// /// Why the suite is skipping, or when the fixture is seeded and usable. /// Tests read this and call Assert.Skip — the house idiom /// (Snap7ServerFixture / ModbusSimulatorFixture). /// public string? SkipReason { get; private set; } /// /// The connection string to hand the code under test: the seeded , /// tagged with . Empty while is set. /// public string ConnectionString { get; private set; } = string.Empty; /// The server the fixture resolved, for skip/diagnostic messages. Never carries credentials. public string Server { get; private set; } = string.Empty; /// Connection string the fixture's own inspection connections use. Empty while skipping. private string _inspectionConnectionString = string.Empty; /// /// Resolves the gate, creates if missing, and (re)seeds this /// fixture's schema. /// The two failure modes are handled deliberately differently: failing to connect is /// the offline case and becomes a , while failing after the connection /// opened (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. /// /// A task that represents the asynchronous operation. 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); } /// Holds no long-lived connection, so there is nothing to tear down. /// A completed task. public ValueTask DisposeAsync() => ValueTask.CompletedTask; /// /// 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. /// /// An open connection the caller owns and must dispose. public async Task OpenInspectionConnectionAsync() { var connection = new SqlConnection(_inspectionConnectionString); await connection.OpenAsync().ConfigureAwait(false); return connection; } /// Two-part name of a seeded object, in the form an authored tag's table carries. /// The bare table or view name. /// The schema.table name. public static string Qualified(string table) => $"{Schema}.{table}"; /// /// Reads the connection-string gate. Returns — touching no socket — when /// neither form is configured. /// 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; } /// /// Refuses, loudly and before any I/O, a connection string aimed at the rig's shared /// ConfigDb. The catalog is ignored either way (everything lands in /// ), so this exists purely to tell an operator their env var is /// wrong rather than to prevent damage that could otherwise occur. /// /// The supplied catalog is named ConfigDb. 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)."); } /// /// Drops and recreates this fixture's schema and every object in it, then inserts the seed rows. /// 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 schema, which nothing else creates. /// 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); } } /// /// The seed, one batch per statement. CREATE SCHEMA and CREATE VIEW must each be the /// first statement of their batch in T-SQL, which is why this is a list rather than one script. /// Every numeric literal is composed under : plain /// interpolation would emit 180,5 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. /// private static IEnumerable 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'); """); } /// Runs one non-query batch. 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); } } } /// /// Collection definition so the seed runs once per test session rather than once per test class — /// the same shape as Snap7ServerCollection. /// [CollectionDefinition(Name)] public sealed class SqlPollServerCollection : ICollectionFixture { /// The collection name test classes reference. public const string Name = "SqlPollServer"; }