fix(drivers): complete the Sql + FOCAS in-place config re-parse (#516)
The first #516 pass deliberately left these two out, because each derives more than options from config: Sql its ISqlDialect and resolved connection string, FOCAS the IFocasClientFactory its Backend key selects — all injected at construction. Adopting new options alone would poll a NEW tag set through the OLD database/backend, and a half-applied config change is worse than a discarded one because it looks like it worked. Rather than accept that exclusion, this fixes the blocker. Each factory now exposes a ParseBinding returning EVERY config-derived dependency as one value, and the driver adopts them atomically: - Sql: ApplyBinding takes (options, dialect, connectionString) and rebuilds everything downstream — provider factory, Endpoint, and the SqlPollReader that captures all three. It runs BEFORE BuildTagTable, so the tag table and the connection it is polled over always come from the same revision. A test-injected DbProviderFactory survives a rebind (_explicitFactory), so a re-derived dialect cannot displace what a test passed in. - FOCAS: options and backend move as a pair in InitializeAsync. Re-resolving Sql's connection string on reinit is a side benefit: a rotated credential is picked up without a process restart. Drivers constructed directly get no rebinder and keep their constructor binding, so every existing "{}"-passing lifecycle test is unaffected by construction rather than by luck. The tests pin ATOMICITY, not merely that a re-parse happened — a test checking only options would have passed against the broken version. Confirmed by simulating the half-fix (adopt options, skip the backend): 2 of 3 FOCAS tests go red. Reverting Sql's rebind turns its connection-string test red. Sql also asserts that a reinit which cannot resolve its new connection leaves the previous binding whole rather than half-adopting. All 12 drivers now honour a changed config in place. Sql.Tests 226, FOCAS.Tests 275 pass.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #516 — FOCAS was excluded from the first pass because it derives MORE than options from config:
|
||||
/// the <c>Backend</c> key selects its <see cref="IFocasClientFactory"/>, injected at construction. Adopting
|
||||
/// a new <c>FocasDriverOptions</c> alone would poll a NEW device/tag set through the OLD backend, which
|
||||
/// looks like it worked and does not.
|
||||
/// <para><c>ParseBinding</c> now returns options + backend together and <c>InitializeAsync</c> adopts both
|
||||
/// or neither. These tests pin the BACKEND moving — the half that was missing — because a test that only
|
||||
/// checked options would have passed against the broken version.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasReinitConfigAdoptionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Reinitializing onto <c>Backend: "unimplemented"</c> must make the driver adopt that backend, whose
|
||||
/// <c>EnsureUsable()</c> throws by design. A driver still holding the constructor's <c>wire</c>
|
||||
/// backend would not throw — so the throw is a direct read of which backend is live.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_adopts_a_changed_backend_not_just_the_options()
|
||||
{
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance(
|
||||
"focas-1", """{"backend":"wire","devices":[]}""");
|
||||
|
||||
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync(
|
||||
"""{"backend":"unimplemented","devices":[]}""", CancellationToken.None));
|
||||
|
||||
ex.ShouldBeOfType<NotSupportedException>(
|
||||
"FocasDriver adopted a reinitialized config but kept its ORIGINAL backend — the half-applied "
|
||||
+ "adoption (#516) that would poll a new device/tag set through the old backend");
|
||||
}
|
||||
|
||||
/// <summary>The other direction: moving OFF the unimplemented backend must also take effect, so the
|
||||
/// adoption is not a one-way latch.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_away_from_the_unimplemented_backend_also_takes_effect()
|
||||
{
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance(
|
||||
"focas-1", """{"backend":"unimplemented","devices":[]}""");
|
||||
|
||||
// Sanity: the constructor-supplied backend is the throwing one.
|
||||
(await Record.ExceptionAsync(() => driver.InitializeAsync("{}", CancellationToken.None)))
|
||||
.ShouldBeOfType<NotSupportedException>();
|
||||
|
||||
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync(
|
||||
"""{"backend":"wire","devices":[]}""", CancellationToken.None));
|
||||
|
||||
ex.ShouldNotBeOfType<NotSupportedException>(
|
||||
"FocasDriver stayed on the unimplemented backend after a reinitialize that selected 'wire'");
|
||||
}
|
||||
|
||||
/// <summary>An empty/placeholder document keeps the constructor-supplied pair, so the lifecycle tests
|
||||
/// that pass <c>"{}"</c> keep meaning what they meant.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_backend()
|
||||
{
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance(
|
||||
"focas-1", """{"backend":"unimplemented","devices":[]}""");
|
||||
|
||||
(await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None)))
|
||||
.ShouldBeOfType<NotSupportedException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #516 — Sql was excluded from the first pass because it derives MORE than options from config
|
||||
/// (its <see cref="ISqlDialect"/> and its resolved connection string are both config-derived), so
|
||||
/// adopting a new <c>SqlDriverOptions</c> 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.
|
||||
/// <para>The fix is <c>ParseBinding</c> + <c>ApplyBinding</c>: 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.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SqlReinitConfigAdoptionTests
|
||||
{
|
||||
private const string RefA = "SqlReinitTestA";
|
||||
private const string RefB = "SqlReinitTestB";
|
||||
|
||||
/// <summary>
|
||||
/// A reinitialize that changes <c>connectionStringRef</c> must move the driver onto the NEW database.
|
||||
/// <c>Endpoint</c> 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.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A config whose <c>connectionStringRef</c> 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.
|
||||
/// </summary>
|
||||
[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<Exception>(() => 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");
|
||||
}
|
||||
|
||||
/// <summary>An empty/placeholder document keeps the constructor-supplied binding, so the many lifecycle
|
||||
/// tests that pass <c>"{}"</c> keep meaning what they meant.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user