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);
}
}