using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// /// Gitea #516 — Sql was excluded from the first pass because it derives MORE than options from config /// (its and its resolved connection string are both config-derived), so /// adopting a new SqlDriverOptions alone would poll a NEW tag set through the OLD database. /// A half-applied config change is worse than a discarded one, because it looks like it worked. /// The fix is ParseBinding + ApplyBinding: one parse produces all three, and they are /// adopted together. These tests pin the ATOMICITY, not just that a re-parse happens — that is the /// property that made this driver special. /// [Trait("Category", "Unit")] public sealed class SqlReinitConfigAdoptionTests { private const string RefA = "SqlReinitTestA"; private const string RefB = "SqlReinitTestB"; /// /// A reinitialize that changes connectionStringRef must move the driver onto the NEW database. /// Endpoint is the credential-free rendering of the live connection string, so it is a direct /// read of which connection the driver actually adopted — no log-string matching. /// [Fact] public async Task Reinitialize_adopts_a_changed_connection_string_not_just_the_options() { using var _ = new ScopedEnvironmentVariable( SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA, "Server=old-server;Database=OldDb;Integrated Security=true"); using var __ = new ScopedEnvironmentVariable( SqlConnectionStringResolver.EnvironmentVariablePrefix + RefB, "Server=new-server;Database=NewDb;Integrated Security=true"); var driver = SqlDriverFactoryExtensions.CreateInstance( "sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null); driver.Endpoint.ShouldContain("old-server"); // The database is unreachable, so init faults — irrelevant here. The assertion is which database it // tried, i.e. whether the connection string moved with the options. try { await driver.ReinitializeAsync( $$"""{"provider":"SqlServer","connectionStringRef":"{{RefB}}"}""", CancellationToken.None); } catch { // Liveness failure against a non-existent server is expected. } driver.Endpoint.ShouldContain( "new-server", customMessage: "SqlDriver adopted a reinitialized config but kept its ORIGINAL connection string — the exact " + "half-applied adoption (#516) that would poll a new tag set against the old database"); driver.Endpoint.ShouldNotContain("old-server"); } /// /// A config whose connectionStringRef names an unprovisioned environment variable must make /// the whole reinitialize FAIL, leaving the previous binding intact — never a partial adoption where /// new options are live against the old connection. /// [Fact] public async Task A_reinitialize_that_cannot_resolve_its_connection_leaves_the_previous_binding_intact() { using var _ = new ScopedEnvironmentVariable( SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA, "Server=old-server;Database=OldDb;Integrated Security=true"); var driver = SqlDriverFactoryExtensions.CreateInstance( "sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null); await Should.ThrowAsync(() => driver.ReinitializeAsync( """{"provider":"SqlServer","connectionStringRef":"SqlReinitTestNeverProvisioned"}""", CancellationToken.None)); driver.Endpoint.ShouldContain( "old-server", customMessage: "a reinitialize that could not resolve its new connection must leave the previous binding whole"); } /// An empty/placeholder document keeps the constructor-supplied binding, so the many lifecycle /// tests that pass "{}" keep meaning what they meant. [Fact] public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_binding() { using var _ = new ScopedEnvironmentVariable( SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA, "Server=old-server;Database=OldDb;Integrated Security=true"); var driver = SqlDriverFactoryExtensions.CreateInstance( "sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null); try { await driver.ReinitializeAsync("{}", CancellationToken.None); } catch { /* liveness failure expected */ } driver.Endpoint.ShouldContain("old-server"); } /// Sets an environment variable for the duration of a test and restores it after. The resolver /// reads process-global state, so a leaked variable would race every sibling test. private sealed class ScopedEnvironmentVariable : IDisposable { private readonly string _name; private readonly string? _previous; public ScopedEnvironmentVariable(string name, string value) { _name = name; _previous = Environment.GetEnvironmentVariable(name); Environment.SetEnvironmentVariable(name, value); } public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); } }