2331f7ed14
v2-ci / unit-tests (push) Waiting to run
allowWrites is no longer inert: SqlDriver implements IWritable, default off. - Gates (outermost first): node Writable + WriteOperate (server), driver allowWrites, the tag's own TagConfig "writable": true (strict JSON boolean; anything else refuses the tag, and the deploy gate says why), and a model that names exactly one row — KeyValue, or WideRow by where-pair. topByTimestamp and Query are BadNotWritable. - SqlWritePlanner: the only statement a write can become is UPDATE <table> SET <value col> = @v WHERE <key col> = @k, built from the catalog-gate-canonical identifiers (dialect-quoted) with value and key bound; value-before-key marker order so positional ODBC binds correctly. No authored write SQL, INSERT, UPSERT or procedure. - Executed in a transaction that commits only when exactly one row was touched: 0 rows -> BadNoData (never inserts), >1 -> rolled back, BadConfigurationError, unknown count -> rolled back, BadNotSupported. Bounded by operationTimeout; provider errors -> BadCommunicationError; health untouched. - Discovery advertises Operate only where a write could succeed. - AdminUI: driver form's Allow writes switch is live; tag editor gains a per-tag write switch; model validates writable strictly (parity corpus). - Factory no longer warns "allowWrites ignored"; it logs whether writes are on. - Tests: SqlWriteTests (planner SQL/bind order/injection/coercion, every gate, readback, absent key, two-row rollback, gate-rejected tag, discovery); MySqlReadTests write legs against InnoDB (keyed row only, two-row rollback, server-side grant refusal, default-off). Seed adds a setpoints table and the one UPDATE grant. (lmxopcua#753) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
557 lines
24 KiB
C#
557 lines
24 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
|
|
|
/// <summary>
|
|
/// Proves the composition layer: <see cref="SqlDriverFactoryExtensions"/> (config blob → live driver) and
|
|
/// <see cref="SqlConnectionStringResolver"/> (a <c>connectionStringRef</c> name → the secret it stands for).
|
|
/// <para>Three things here are behaviour rather than plumbing, and each has its own test below.
|
|
/// <b>(1) The string-enum guard</b> — the driver-page enum-serialization defect that bit the AdminUI shipped
|
|
/// configs whose enum fields were written as ORDINALS while the consuming DTO was string-typed, faulting the
|
|
/// driver at deploy time. The factory's options therefore both accept a numeric <c>provider</c> and write the
|
|
/// NAME. <b>(2) Config validation</b> — <c>operationTimeout > commandTimeout</c> is deliberately
|
|
/// unenforced in the reader and the shell, so the factory is the only place it can be caught before a
|
|
/// deployment silently masks its own server-side backstop. <b>(3) Credential hygiene</b> — the resolved
|
|
/// connection string is a secret; it must not reach a log line or an exception message, and the tests below
|
|
/// assert that rather than trusting the reviewer.</para>
|
|
/// </summary>
|
|
public sealed class SqlDriverFactoryTests
|
|
{
|
|
// ---- the plan's headline legs ----
|
|
|
|
/// <summary>
|
|
/// <b>R06:S-8.</b> A malformed config used to quote System.Text.Json's message — which echoes the
|
|
/// offending token — into the exception that becomes <c>InitializeFailed</c> → health
|
|
/// <c>LastError</c> → <c>/hosts</c>. A pasted connection string is exactly the malformed text that
|
|
/// reaches here, so the parser text (and the inner exception carrying it) is withheld, as the probe and
|
|
/// browser already do. The message still names the instance and says what is wrong.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CreateInstance_malformedJson_withholdsTheParserText()
|
|
{
|
|
const string secret = "hunter2-do-not-log";
|
|
var ex = Should.Throw<InvalidOperationException>(() =>
|
|
SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"provider":"SqlServer","connectionStringRef": Password={{secret}} }""", null));
|
|
|
|
ex.Message.ShouldContain("d1");
|
|
ex.Message.ShouldContain("not valid JSON");
|
|
ex.Message.ShouldNotContain(secret);
|
|
ex.Message.ShouldNotContain("Password");
|
|
ex.InnerException.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_missingConnectionStringRef_throwsActionable()
|
|
=> Should.Throw<InvalidOperationException>(() =>
|
|
SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
|
|
.Message.ShouldContain("connectionStringRef");
|
|
|
|
[Fact]
|
|
public void ConnectionStringRef_resolvesFromEnv()
|
|
{
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=x;Database=y;");
|
|
|
|
SqlConnectionStringResolver.Resolve(name).ShouldBe("Server=x;Database=y;");
|
|
}
|
|
|
|
[Fact]
|
|
public void Provider_serializesAsNameNotNumber()
|
|
{
|
|
var json = JsonSerializer.Serialize(
|
|
new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
|
|
SqlDriverFactoryExtensions.JsonOptionsForTest);
|
|
|
|
json.ShouldContain("\"SqlServer\"");
|
|
json.ShouldNotContain("\"provider\":0");
|
|
}
|
|
|
|
// ---- the other half of the enum guard: a NUMERIC provider must still parse ----
|
|
|
|
[Fact]
|
|
public void CreateInstance_providerWrittenAsANumber_stillParses()
|
|
{
|
|
// The defect's real shape: an authoring surface serialises the enum as an ordinal. Rejecting that
|
|
// would fault the driver at deploy time, which is exactly what the guard exists to prevent — so the
|
|
// options accept both spellings on the way IN, and only ever emit the name on the way OUT.
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
|
|
|
var driver = SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"provider":0,"connectionStringRef":"{{name}}"}""", null);
|
|
|
|
driver.DriverType.ShouldBe(SqlDriver.DriverTypeName);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_unknownJsonMembers_areIgnoredRatherThanFatal()
|
|
{
|
|
// A config blob authored against a newer (or older) schema must not brick the driver.
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
|
|
|
var driver = SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"connectionStringRef":"{{name}}","somethingNobodyHasShippedYet":true}""", null);
|
|
|
|
driver.DriverInstanceId.ShouldBe("d1");
|
|
}
|
|
|
|
// ---- connection-string resolution ----
|
|
|
|
[Fact]
|
|
public void Resolve_whenTheEnvironmentVariableIsAbsent_throwsNamingTheVariable()
|
|
{
|
|
var name = NewRef();
|
|
|
|
var ex = Should.Throw<InvalidOperationException>(() => SqlConnectionStringResolver.Resolve(name));
|
|
|
|
// The operator's next action is "set this variable", so the message must name it exactly.
|
|
ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_whenTheEnvironmentVariableIsBlank_isTreatedAsAbsent()
|
|
{
|
|
// An empty value is a half-provisioned deployment, not a connection string — failing here beats
|
|
// handing "" to the provider and reporting an unintelligible ADO.NET error.
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), " ");
|
|
|
|
Should.Throw<InvalidOperationException>(() => SqlConnectionStringResolver.Resolve(name));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_whenTheRefResolvesToNothing_namesTheEnvironmentVariable_notTheRef()
|
|
{
|
|
var name = NewRef();
|
|
|
|
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"connectionStringRef":"{{name}}"}""", null));
|
|
|
|
ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name));
|
|
}
|
|
|
|
// ---- provider gate ----
|
|
|
|
/// <summary>
|
|
/// The falsifiability control for the widened gate: every declared member now constructs, so an
|
|
/// out-of-range ordinal (a config authored against a future enum) stands in for "a provider this build
|
|
/// does not carry" — it must fail loudly at construction, naming itself. Without this leg, a gate that
|
|
/// had quietly become "construct anything" would pass every test below.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo()
|
|
{
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
|
|
|
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"provider":99,"connectionStringRef":"{{name}}"}""", null));
|
|
|
|
ex.Message.ShouldContain("99");
|
|
ex.Message.ShouldContain("not available in this build");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The reserved names are named as reserved — an operator reading the rejection must be able to tell
|
|
/// "misspelled" from "this build does not carry it", so the message enumerates both halves.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CreateInstance_aReservedProvider_namesTheSupportedAndTheReservedSets()
|
|
{
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
|
|
|
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"provider":99,"connectionStringRef":"{{name}}"}""", null));
|
|
|
|
ex.Message.ShouldContain(nameof(SqlProvider.SqlServer));
|
|
ex.Message.ShouldContain(nameof(SqlProvider.Odbc));
|
|
ex.Message.ShouldContain(nameof(SqlProvider.Postgres));
|
|
ex.Message.ShouldContain(nameof(SqlProvider.Oracle));
|
|
ex.Message.ShouldContain(nameof(SqlProvider.MySql));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The widened gate, asserted on the thing that actually varies: which dialect the config binds to.
|
|
/// Binding — not merely "no throw" — is what proves the provider reaches the poll path, since a gate
|
|
/// that fell through to <c>SqlServerDialect</c> would also construct without complaint and then speak
|
|
/// T-SQL at a Postgres server.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("SqlServer", typeof(SqlServerDialect))]
|
|
[InlineData("Postgres", typeof(PostgresDialect))]
|
|
[InlineData("Oracle", typeof(OracleDialect))]
|
|
[InlineData("MySql", typeof(MySqlDialect))]
|
|
[InlineData("Odbc", typeof(OdbcDialect))]
|
|
public void ParseBinding_aSupportedProvider_bindsThatProvidersDialect(string provider, Type expected)
|
|
{
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
|
|
|
var binding = SqlDriverFactoryExtensions.ParseBinding(
|
|
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""");
|
|
|
|
binding.Dialect.ShouldBeOfType(expected);
|
|
binding.Dialect.Provider.ShouldBe(Enum.Parse<SqlProvider>(provider));
|
|
}
|
|
|
|
/// <summary>
|
|
/// End-to-end through the registered factory entry point, so the widening is proven on the path the
|
|
/// bootstrapper actually calls — not only on the internal parse.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("Postgres")]
|
|
[InlineData("Oracle")]
|
|
[InlineData("MySql")]
|
|
[InlineData("Odbc")]
|
|
public async Task CreateInstance_aNewlySupportedProvider_constructsPastTheProviderGate(string provider)
|
|
{
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
|
|
|
await using var driver = SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""", null);
|
|
|
|
driver.ShouldNotBeNull();
|
|
}
|
|
|
|
// ---- config validation (the reader and the shell deliberately do NOT do this) ----
|
|
|
|
[Fact]
|
|
public void CreateInstance_operationTimeoutNotGreaterThanCommandTimeout_isRejected()
|
|
{
|
|
// Inverted, the client-side wall-clock abort always fires first and the server-side CommandTimeout
|
|
// backstop can never be reached — the deployment silently loses one of its two independent bounds.
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
|
|
|
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1",
|
|
$$"""
|
|
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"}
|
|
""",
|
|
null));
|
|
|
|
ex.Message.ShouldContain("operationTimeout");
|
|
ex.Message.ShouldContain("commandTimeout");
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_equalTimeouts_areAlsoRejected()
|
|
{
|
|
// "Strictly greater" (design §8.3): equal leaves the two bounds racing, which is the same defect
|
|
// with a coin toss on top.
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
|
|
|
Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1",
|
|
$$"""
|
|
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:10","operationTimeout":"00:00:10"}
|
|
""",
|
|
null));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("\"commandTimeout\":\"00:00:00\"")]
|
|
[InlineData("\"operationTimeout\":\"-00:00:05\"")]
|
|
[InlineData("\"defaultPollInterval\":\"00:00:00\"")]
|
|
[InlineData("\"maxConcurrentGroups\":0")]
|
|
public void CreateInstance_nonPositiveKnobs_areRejected(string knobJson)
|
|
{
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
|
|
|
Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"connectionStringRef":"{{name}}",{{knobJson}}}""", null));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_theDocumentedDefaults_passValidation()
|
|
{
|
|
// The falsifiability control for the four rejection legs above: an all-defaults config must build.
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
|
|
|
var options = SqlDriverFactoryExtensions.BuildOptions(
|
|
new SqlDriverConfigDto { ConnectionStringRef = name }, "d1");
|
|
|
|
options.CommandTimeout.ShouldBe(TimeSpan.FromSeconds(10));
|
|
options.OperationTimeout.ShouldBe(TimeSpan.FromSeconds(15));
|
|
options.DefaultPollInterval.ShouldBe(TimeSpan.FromSeconds(5));
|
|
options.MaxConcurrentGroups.ShouldBe(4);
|
|
options.NullIsBad.ShouldBeFalse();
|
|
options.OperationTimeout.ShouldBeGreaterThan(options.CommandTimeout);
|
|
}
|
|
|
|
// ---- rawTags: how the deploy artifact actually delivers a driver's tags ----
|
|
|
|
[Fact]
|
|
public void BuildOptions_carriesTheArtifactsRawTagsOntoTheOptions()
|
|
{
|
|
var dto = JsonSerializer.Deserialize<SqlDriverConfigDto>(
|
|
"""
|
|
{
|
|
"connectionStringRef":"anything",
|
|
"rawTags":[
|
|
{"rawPath":"Plant/Sql/db1/Speed","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false},
|
|
{"rawPath":"Plant/Sql/db1/Temp","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false}
|
|
]
|
|
}
|
|
""",
|
|
SqlDriverFactoryExtensions.JsonOptionsForTest)!;
|
|
|
|
var options = SqlDriverFactoryExtensions.BuildOptions(dto, "d1");
|
|
|
|
options.RawTags.Count.ShouldBe(2);
|
|
options.RawTags[0].RawPath.ShouldBe("Plant/Sql/db1/Speed");
|
|
options.RawTags[1].RawPath.ShouldBe("Plant/Sql/db1/Temp");
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildOptions_withNoRawTags_yieldsAnEmptyList_notNull()
|
|
{
|
|
var options = SqlDriverFactoryExtensions.BuildOptions(
|
|
new SqlDriverConfigDto { ConnectionStringRef = "anything" }, "d1");
|
|
|
|
options.RawTags.ShouldBeEmpty();
|
|
}
|
|
|
|
// ---- allowWrites (lmxopcua#753): honoured now, default off ----
|
|
|
|
[Theory]
|
|
[InlineData(",\"allowWrites\":true", true)]
|
|
[InlineData(",\"allowWrites\":false", false)]
|
|
[InlineData("", false)] // absent ⇒ read-only
|
|
public void ParseBinding_carriesAllowWritesOntoTheOptions_defaultOff(string fragment, bool expected)
|
|
{
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
|
|
|
SqlDriverFactoryExtensions.ParseBinding("d1", $$"""{"connectionStringRef":"{{name}}"{{fragment}}}""")
|
|
.Options.AllowWrites.ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_withAllowWrites_logsNoWarning_andSaysWritesAreOn()
|
|
{
|
|
// allowWrites used to be inert and drew a "this build ignores it" warning; it is honoured now, so the
|
|
// flag is an ordinary setting — reported in the configured line, never as a warning.
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
|
var loggerFactory = new RecordingLoggerFactory();
|
|
|
|
SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory)
|
|
.ShouldBeAssignableTo<IWritable>();
|
|
|
|
loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty();
|
|
loggerFactory.Entries.ShouldContain(e => e.Message.Contains("allowed for opted-in tags"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_withoutAllowWrites_logsNoWarning()
|
|
{
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
|
var loggerFactory = new RecordingLoggerFactory();
|
|
|
|
SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":false}""", loggerFactory);
|
|
|
|
loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty();
|
|
// ...and the factory DID log — so "no warning" is a statement about severity, not about silence.
|
|
loggerFactory.Entries.ShouldNotBeEmpty();
|
|
}
|
|
|
|
// ---- credential hygiene ----
|
|
|
|
[Fact]
|
|
public void CreateInstance_neverPutsTheResolvedConnectionStringIntoALogOrTheEndpoint()
|
|
{
|
|
const string secret = "SuperSecret-PleaseDoNotLogMe";
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name),
|
|
$"Server=srv;Database=db;User ID=sa;Password={secret}");
|
|
var loggerFactory = new RecordingLoggerFactory();
|
|
|
|
var driver = (SqlDriver)SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory);
|
|
|
|
// The credential-free rendering IS emitted — the factory logs where the driver points, which is what
|
|
// makes "no connection string" a real constraint rather than "log nothing and call it safe".
|
|
driver.Endpoint.ShouldBe("srv/db");
|
|
foreach (var entry in loggerFactory.Entries) entry.Message.ShouldNotContain(secret);
|
|
loggerFactory.Entries.ShouldNotBeEmpty(); // ...and there WAS something to leak into.
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_whenValidationFails_theExceptionCarriesNoConnectionString()
|
|
{
|
|
const string secret = "SuperSecret-PleaseDoNotThrowMe";
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name),
|
|
$"Server=srv;Database=db;Password={secret}");
|
|
|
|
// Validation deliberately runs AFTER resolution here, so the throwing path is one that HAS the
|
|
// secret in hand — the case where a careless $"...{connectionString}" would actually leak.
|
|
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
|
|
"d1",
|
|
$$"""
|
|
{"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"}
|
|
""",
|
|
null));
|
|
|
|
ex.ToString().ShouldNotContain(secret);
|
|
}
|
|
|
|
// ---- registry wiring ----
|
|
|
|
[Fact]
|
|
public void Register_registersTheFactoryUnderTheDriversTypeName()
|
|
{
|
|
var name = NewRef();
|
|
using var _ = new EnvironmentVariableScope(
|
|
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db");
|
|
var registry = new DriverFactoryRegistry();
|
|
|
|
SqlDriverFactoryExtensions.Register(registry);
|
|
|
|
var factory = registry.TryGet(SqlDriverFactoryExtensions.DriverTypeName).ShouldNotBeNull();
|
|
var driver = factory("d1", $$"""{"connectionStringRef":"{{name}}"}""");
|
|
driver.ShouldBeOfType<SqlDriver>();
|
|
driver.DriverType.ShouldBe("Sql");
|
|
}
|
|
|
|
[Fact]
|
|
public void DriverTypeName_matchesTheDriversOwnConstant()
|
|
// Task 11 unifies both onto DriverTypeNames.Sql; until then they must not drift apart.
|
|
=> SqlDriverFactoryExtensions.DriverTypeName.ShouldBe(SqlDriver.DriverTypeName);
|
|
|
|
// ---- argument guards ----
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void CreateInstance_blankInputs_areRejected(string blank)
|
|
{
|
|
Should.Throw<ArgumentException>(() =>
|
|
SqlDriverFactoryExtensions.CreateInstance(blank, """{"connectionStringRef":"x"}""", null));
|
|
Should.Throw<ArgumentException>(() => SqlDriverFactoryExtensions.CreateInstance("d1", blank, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_configThatIsNotJson_throwsNamingTheDriverInstance()
|
|
{
|
|
var ex = Should.Throw<InvalidOperationException>(
|
|
() => SqlDriverFactoryExtensions.CreateInstance("d1", "not json at all", null));
|
|
|
|
ex.Message.ShouldContain("d1");
|
|
}
|
|
|
|
/// <summary>A per-test connection-string ref, so no two tests can collide over one process-wide env var.</summary>
|
|
private static string NewRef() => $"OtOpcUaSqlTest{Guid.NewGuid():N}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets one environment variable for the life of the scope and restores its previous value — including
|
|
/// restoring it to <see langword="null"/>. Environment variables are process-global, so a test that sets
|
|
/// one without restoring it leaks into every test that runs after it in the same process.
|
|
/// </summary>
|
|
internal sealed class EnvironmentVariableScope : IDisposable
|
|
{
|
|
private readonly string _name;
|
|
private readonly string? _previous;
|
|
|
|
/// <summary>Sets <paramref name="name"/> to <paramref name="value"/>, remembering what was there.</summary>
|
|
/// <param name="name">The environment variable to set.</param>
|
|
/// <param name="value">The value to set it to.</param>
|
|
public EnvironmentVariableScope(string name, string? value)
|
|
{
|
|
_name = name;
|
|
_previous = Environment.GetEnvironmentVariable(name);
|
|
Environment.SetEnvironmentVariable(name, value);
|
|
}
|
|
|
|
/// <summary>Restores the previous value.</summary>
|
|
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures what the factory logged, so "warned" and "did not leak the connection string" are asserted
|
|
/// rather than assumed.
|
|
/// </summary>
|
|
internal sealed class RecordingLoggerFactory : ILoggerFactory
|
|
{
|
|
private readonly List<LogEntry> _entries = [];
|
|
|
|
/// <summary>The log entries recorded so far, in order.</summary>
|
|
public IReadOnlyList<LogEntry> Entries
|
|
{
|
|
get { lock (_entries) return [.. _entries]; }
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public ILogger CreateLogger(string categoryName) => new Recorder(this);
|
|
|
|
/// <inheritdoc/>
|
|
public void AddProvider(ILoggerProvider provider) { }
|
|
|
|
/// <inheritdoc/>
|
|
public void Dispose() { }
|
|
|
|
private void Record(LogLevel level, string message)
|
|
{
|
|
lock (_entries) _entries.Add(new LogEntry(level, message));
|
|
}
|
|
|
|
/// <summary>One captured log entry.</summary>
|
|
/// <param name="Level">The level it was logged at.</param>
|
|
/// <param name="Message">The formatted message.</param>
|
|
internal sealed record LogEntry(LogLevel Level, string Message);
|
|
|
|
private sealed class Recorder(RecordingLoggerFactory owner) : ILogger
|
|
{
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
=> owner.Record(logLevel, formatter(state, exception));
|
|
}
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
public static readonly NullScope Instance = new();
|
|
|
|
public void Dispose() { }
|
|
}
|
|
}
|