using System.Data.Common; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; /// /// A real SQL Server database for the handful of AdminUI tests whose subject is the SQL Server /// provider's LINQ translation rather than the C# around it — EF InMemory evaluates those /// queries in-process, so it can neither fail on an untranslatable shape nor reproduce SQL Server's /// collation semantics. Creates a uniquely-named database, applies the EF migrations to it, and drops /// it at collection teardown. /// /// /// /// Shaped after ZB.MOM.WW.OtOpcUa.Configuration.Tests.SchemaComplianceFixture (same env /// vars, same unique-name + Migrate() + drop-via-master lifecycle) with one deliberate /// deviation: that fixture lets the connection failure surface as a constructor throw, so /// Configuration.Tests fails hard when SQL Server is unreachable. AdminUI.Tests is an ordinary /// unit suite that must stay green offline, so this fixture probes reachability FIRST and records /// a instead of throwing. Tests carry , /// which skips them when the probe failed. /// /// /// The skip is an attribute rather than the Assert.Skip(fixture.SkipReason) call the S7 /// integration suite uses, because AdminUI.Tests is one of the three projects pinned to /// xunit v2 by Akka.TestKit.Xunit2 (see Directory.Packages.props) and /// Assert.Skip is xunit v3 only. Overriding FactAttribute.Skip is the v2 equivalent /// and reports a genuine "skipped", not a vacuous pass. Both the attribute and this fixture read /// the same one-shot probe in , so a session probes once. /// /// /// The probe being one-shot bounds what "stays green offline" guarantees: it covers SQL /// being unreachable before the run. A connectivity blip during a run is never /// re-probed, so the affected tests surface as SqlException failures, not skips. /// /// /// Every test in the collection shares ONE database (migrating is the expensive part), so tests /// must isolate themselves by seeding uniquely-named rows, or by clearing the table they measure. /// xunit serialises a collection's tests, so a clear-then-seed is safe. /// /// public sealed class AdminUiSqlServerFixture : IDisposable { /// Gets the name of the per-run test database, or when SQL is unreachable. public string? DatabaseName { get; } /// Gets the connection string for the test database, or when SQL is unreachable. public string? ConnectionString { get; } /// /// Gets the reason the SQL-backed tests cannot run (server unreachable, migration failed), or /// when the database is ready. /// public string? SkipReason { get; } /// /// Gets a context factory pointed at the migrated test database — the seam the services under test /// (DeploymentDriftProbe, RawTreeService) take. Throws when /// is set; guard with . /// public IDbContextFactory Factory => _factory ?? throw new InvalidOperationException(SkipReason ?? "SQL Server fixture is not available."); private readonly SqlServerContextFactory? _factory; /// Initializes a new instance of the class. public AdminUiSqlServerFixture() { // Probe FIRST — see the remarks. A dead server must leave this constructor without throwing, // because xunit builds collection fixtures before it discovers that every test is skipped. SkipReason = AdminUiSqlServer.SkipReason; if (SkipReason is not null) return; DatabaseName = $"OtOpcUaAdminUi_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; ConnectionString = AdminUiSqlServer.ConnectionStringFor(DatabaseName); try { var options = new DbContextOptionsBuilder() .UseSqlServer(ConnectionString) .Options; using var ctx = new OtOpcUaConfigDbContext(options); ctx.Database.Migrate(); } catch (Exception ex) { // A half-created database would leak, so tear it down before standing the suite down. TryDropDatabase(); SkipReason = $"Could not migrate the AdminUI SQL test database: {ex.Message}"; DatabaseName = null; ConnectionString = null; return; } _factory = new SqlServerContextFactory(ConnectionString); } /// Disposes the fixture, dropping the test database. public void Dispose() => TryDropDatabase(); private void TryDropDatabase() { if (ConnectionString is null || DatabaseName is null) return; try { var master = new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString; using var conn = new SqlConnection(master); conn.Open(); using var cmd = conn.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 (DbException) { // Teardown is best-effort: a dropped connection here must not fail an otherwise-green run. // The database name carries a timestamp, so a leaked one is identifiable. } } private sealed class SqlServerContextFactory(string connectionString) : IDbContextFactory { public OtOpcUaConfigDbContext CreateDbContext() => new(new DbContextOptionsBuilder() .UseSqlServer(connectionString) .Options); public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(CreateDbContext()); } } /// /// One-shot reachability probe + connection-string builder shared by /// and . The attribute is /// constructed at discovery time and the fixture at collection start, so the probe must be cached or /// a run pays the connect timeout twice. /// internal static class AdminUiSqlServer { private const string DefaultServer = "10.100.0.35,14330"; private const string DefaultSaPassword = "OtOpcUaDev_2026!"; private const int ProbeTimeoutSeconds = 3; private static readonly Lazy Probe = new(RunProbe, LazyThreadSafetyMode.ExecutionAndPublication); /// Gets the reason SQL Server is unusable, or when it answered. public static string? SkipReason => Probe.Value; /// Builds a connection string for the named database on the configured test server. /// The database to connect to. /// The connection string. public static string ConnectionStringFor(string database) { var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword; return $"Server={server};Database={database};User Id=sa;Password={password};" + "TrustServerCertificate=True;Encrypt=False;"; } private static string? RunProbe() { var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; try { var probe = new SqlConnectionStringBuilder(ConnectionStringFor("master")) { ConnectTimeout = ProbeTimeoutSeconds, }.ConnectionString; using var conn = new SqlConnection(probe); conn.Open(); return null; } catch (Exception ex) { return $"SQL Server at '{server}' is unreachable ({ex.GetType().Name}: {ex.Message}); " + "set OTOPCUA_CONFIG_TEST_SERVER / OTOPCUA_CONFIG_TEST_SA_PASSWORD to point at a reachable instance."; } } } /// /// A that skips itself when the shared test SQL Server is unreachable. /// The xunit v2 equivalent of xunit v3's Assert.Skip — see /// 's remarks for why this project cannot use the latter. /// [AttributeUsage(AttributeTargets.Method)] public sealed class SqlServerFactAttribute : FactAttribute { /// Initializes a new instance of the class. public SqlServerFactAttribute() { if (AdminUiSqlServer.SkipReason is { } reason) Skip = reason; } } /// Collection definition binding the SQL-backed AdminUI tests to one shared database. [CollectionDefinition(nameof(AdminUiSqlServerCollection))] public sealed class AdminUiSqlServerCollection : ICollectionFixture { }