using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; /// /// Per-test-class MSSQL fixture for the Bundle C integration tests (#23 M1). /// /// Creates a fresh, uniquely-named test database on the running infra/mssql /// container, applies the EF migrations against it, and drops it on dispose. /// When MSSQL is not reachable (CI without the container), /// is set to false and describes why — tests pair /// [SkippableFact] with Skip.IfNot(_fixture.Available, _fixture.SkipReason) /// so the runner reports them as Skipped (not silently Passed). /// /// /// xunit 2.9.x has no native Assert.Skip/Assert.SkipUnless (those /// are v3); the project uses the Xunit.SkippableFact package as the canonical /// equivalent. The fixture attempts connect + create-db + migrate once at /// construct time. The Connect Timeout=3 in /// makes the fixture fail fast in a no-container environment (under ~5s total) /// instead of hanging 30s on SqlClient's default. Only connect-failure exceptions /// (SqlException, plus the InvalidOperationException SqlClient raises from /// OpenAsync) flip Available to false — every other exception bubbles up so a /// real bug is not silently swallowed. /// public sealed class MsSqlMigrationFixture : IDisposable { // Same credentials infra/mssql/setup.sql + docker-compose use. Not a committed // production secret — this is a local dev container connection string. // Connect Timeout=3 makes the fixture fail fast (~3s) in a no-container // environment rather than hanging on SqlClient's default 30s connect timeout. private const string DefaultAdminConnectionString = "Server=localhost,1433;User Id=sa;Password=ScadaLink_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=3"; private const string AdminEnvVar = "SCADALINK_MSSQL_TEST_CONN"; public string DatabaseName { get; } public string ConnectionString { get; } /// /// True when the MSSQL container was reachable at fixture construction /// time AND the per-fixture test database was successfully created. When /// false, the integration tests using this fixture must early-return. /// public bool Available { get; } /// /// Populated when is false; describes why the /// fixture chose to skip (env var unset, connect failed, etc.). /// public string SkipReason { get; } private readonly string _adminConnectionString; public MsSqlMigrationFixture() { // Short, lowercase guid suffix keeps the database identifier under SQL Server's // 128-char limit and safe for raw concatenation (no quoting required). DatabaseName = $"ScadaLinkAuditMigTest_{Guid.NewGuid():N}".Substring(0, 38); // Env var lets CI / power users override the admin endpoint; absent // defaults to the local docker dev container's sa connection. var fromEnv = Environment.GetEnvironmentVariable(AdminEnvVar); _adminConnectionString = string.IsNullOrWhiteSpace(fromEnv) ? DefaultAdminConnectionString : fromEnv; // Step 1: open the admin connection. This is the only step that may // legitimately fail when MSSQL is absent; SqlException + the rare // InvalidOperationException from OpenAsync are the connect-failure // surfaces we tolerate. Everything else (CREATE DATABASE, MigrateAsync) // is treated as a hard fixture failure once we *have* a connection. try { using var connection = new SqlConnection(_adminConnectionString); try { connection.Open(); } catch (SqlException ex) { ConnectionString = string.Empty; Available = false; SkipReason = $"MSSQL unavailable (connect failed: SqlException {ex.Number}: {ex.Message})"; return; } catch (InvalidOperationException ex) { ConnectionString = string.Empty; Available = false; SkipReason = $"MSSQL unavailable (OpenAsync threw: {ex.Message})"; return; } using (var createCmd = connection.CreateCommand()) { createCmd.CommandText = $"CREATE DATABASE [{DatabaseName}];"; createCmd.ExecuteNonQuery(); } ConnectionString = BuildPerDbConnectionString(_adminConnectionString, DatabaseName); // Apply the EF migrations once at fixture construction so each test // can read from a fully-migrated database without per-test setup. // Failures here are real bugs — let them bubble. ApplyMigrationsCore(ConnectionString, CancellationToken.None).GetAwaiter().GetResult(); Available = true; SkipReason = string.Empty; } catch { // Best-effort cleanup if we created the database but failed before // setting Available — otherwise Dispose() would skip the drop. TryDropOrphanDatabase(); throw; } } private void TryDropOrphanDatabase() { if (string.IsNullOrEmpty(ConnectionString)) { return; } try { SqlConnection.ClearAllPools(); using var connection = new SqlConnection(_adminConnectionString); connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = $"IF DB_ID(N'{DatabaseName}') IS NOT NULL " + $"BEGIN " + $" ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " + $" DROP DATABASE [{DatabaseName}]; " + $"END"; cmd.ExecuteNonQuery(); } catch { // Best-effort — orphan databases carry a random guid suffix. } } /// /// Applies the EF migrations to the per-fixture test database via a freshly /// constructed pointed at it. Uses the /// schema-only single-argument constructor — the AuditLog migration does /// not write secret-bearing columns at apply time. Called once from the /// constructor; tests do not invoke this directly. /// private static async Task ApplyMigrationsCore(string connectionString, CancellationToken cancellationToken) { var options = new DbContextOptionsBuilder() .UseSqlServer(connectionString) .Options; await using var context = new ScadaLinkDbContext(options); await context.Database.MigrateAsync(cancellationToken); } /// /// Convenience for opening a fresh to the test /// database. Caller is responsible for disposal. /// public SqlConnection OpenConnection() { ThrowIfUnavailable(); var connection = new SqlConnection(ConnectionString); connection.Open(); return connection; } public void Dispose() { if (!Available) { return; } // Best-effort drop — never let a teardown failure pollute later runs. // SINGLE_USER WITH ROLLBACK IMMEDIATE detaches lingering pooled connections // so the DROP DATABASE doesn't fail with "database is in use". try { // Connection-pool cleanup is necessary because EF's MigrateAsync leaves // pooled connections behind; SqlConnection.ClearAllPools() forces them // closed so the SINGLE_USER + DROP sequence below can complete. SqlConnection.ClearAllPools(); using var connection = new SqlConnection(_adminConnectionString); connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = $"IF DB_ID(N'{DatabaseName}') IS NOT NULL " + $"BEGIN " + $" ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " + $" DROP DATABASE [{DatabaseName}]; " + $"END"; cmd.ExecuteNonQuery(); } catch { // Swallow — the database name carries a random guid suffix so a // stranded test database does not collide with future runs. } } /// /// Throws an when invoked on an /// unavailable fixture; tests should branch on /// before reaching this code path. /// private void ThrowIfUnavailable() { if (!Available) { throw new InvalidOperationException( $"MsSqlMigrationFixture is not Available: {SkipReason}"); } } private static string BuildPerDbConnectionString(string adminConnectionString, string databaseName) { var builder = new SqlConnectionStringBuilder(adminConnectionString) { InitialCatalog = databaseName, }; return builder.ConnectionString; } }