using System.Data; using System.Data.Common; using System.Globalization; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// /// Proves the shell — the part deliberately does not /// own: lifecycle (initialize / reinitialize / dispose), the authored RawPath table, read-only discovery, /// the poll-engine subscription overlay, and the health + host-connectivity surface. /// The health assertions are the point of this class. The reader returns Bad-coded snapshots /// rather than throwing for everything short of an unreachable server, so nothing below the shell degrades /// health on its own: a frozen database yields all-BadTimeout snapshots and a perfectly successful /// tick. If the shell does not classify what came back, the driver reports /// while every value is Bad — the failure mode these tests exist to /// prevent, together with its inverse (a tag typo must NOT report the database down). /// public sealed class SqlDriverTests { private const string DriverInstanceId = "sql-1"; // ---- discovery + initialize ---- [Fact] public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly() { using var fixture = new SqlitePollFixture(); await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); await driver.InitializeAsync(ConfigJson, CancellationToken.None); driver.GetHealth().State.ShouldBe(DriverState.Healthy); driver.DriverType.ShouldBe("Sql"); driver.DriverInstanceId.ShouldBe(DriverInstanceId); ((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse(); ((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once); var capture = new CapturingBuilder(); await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None); capture.Variables.ShouldContain(v => v.Info.FullName == "Speed" && v.Info.SecurityClass == SecurityClassification.ViewOnly); // v1 is read-only by construction, not by configuration. driver.ShouldNotBeAssignableTo(); } [Fact] public async Task Initialize_anUnparseableTagConfig_isSkippedAndLogged_andTheRestStillServe() { using var fixture = new SqlitePollFixture(); var logger = new CapturingLogger(); await using var driver = NewDriver( fixture, logger, KvEntry("Speed", SqlitePollFixture.PresentKey), new RawTagEntry("Broken", "not json at all", WriteIdempotent: false)); // A single malformed blob must never fail the whole driver. await driver.InitializeAsync(ConfigJson, CancellationToken.None); driver.GetHealth().State.ShouldBe(DriverState.Healthy); var capture = new CapturingBuilder(); await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None); capture.Variables.Count.ShouldBe(1); capture.Variables[0].Info.FullName.ShouldBe("Speed"); logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("Broken")); // …and the skipped tag resolves to nothing rather than to someone else's row. var snapshots = await driver.ReadAsync(["Broken"], CancellationToken.None); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); } [Fact] public async Task Initialize_whenTheDatabaseIsUnreachable_faultsWithAnActionableErrorAndNoCredentials() { using var fixture = new SqlitePollFixture(); await using var driver = NewDriver( fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey)); // Mirrors ModbusDriver: the driver records Faulted AND rethrows, so DriverInstanceActor lands in // Reconnecting and its retry-connect timer keeps trying — a database that is merely down must not // seal as a silently-connected driver. var thrown = await Should.ThrowAsync( async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Faulted); health.LastError.ShouldNotBeNullOrWhiteSpace(); // Actionable: it names the endpoint the operator has to go look at… health.LastError!.ShouldContain(NoSuchDatabaseName); thrown.Message.ShouldContain(NoSuchDatabaseName); // …and never the credential-bearing connection string. health.LastError.ShouldNotContain(SecretToken); thrown.Message.ShouldNotContain(SecretToken); driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped); driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken); } [Fact] public async Task ReinitializeAsync_recoversFromFaulted_onceTheDatabaseIsReachableAgain() { using var fixture = new SqlitePollFixture(); // SQLite creates a missing FILE on open but cannot create a missing DIRECTORY — so this connection // string is unreachable until the directory exists, and reachable the moment it does. var directory = Path.Combine(Path.GetTempPath(), $"otopcua-sql-driver-{Guid.NewGuid():N}"); var connectionString = new SqliteConnectionStringBuilder { DataSource = Path.Combine(directory, "late.db"), }.ToString(); await using var driver = NewDriver( fixture, connectionString, CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey)); await Should.ThrowAsync( async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); driver.GetHealth().State.ShouldBe(DriverState.Faulted); try { Directory.CreateDirectory(directory); await driver.ReinitializeAsync(ConfigJson, CancellationToken.None); driver.GetHealth().State.ShouldBe(DriverState.Healthy); driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running); // The authored table survived the round trip — a recovered driver still serves its tags. var capture = new CapturingBuilder(); await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None); capture.Variables.Count.ShouldBe(1); } finally { SqliteConnection.ClearAllPools(); try { Directory.Delete(directory, recursive: true); } catch (IOException) { } } } // ---- IReadable delegation ---- [Fact] public async Task ReadAsync_delegatesToTheReader_valueForValue() { using var fixture = new SqlitePollFixture(); await using var driver = NewDriver( fixture, KvEntry("Speed", SqlitePollFixture.PresentKey), KvEntry("Temp", SqlitePollFixture.NullValueKey), KvEntry("Missing", SqlitePollFixture.AbsentKey)); await driver.InitializeAsync(ConfigJson, CancellationToken.None); var reference = new SqlPollReader( fixture.Factory, fixture.ConnectionString, new SqliteDialect(), commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15), maxConcurrentGroups: 4, nullIsBad: false, resolve: rawPath => rawPath switch { "Speed" => KvDefinition("Speed", SqlitePollFixture.PresentKey), "Temp" => KvDefinition("Temp", SqlitePollFixture.NullValueKey), "Missing" => KvDefinition("Missing", SqlitePollFixture.AbsentKey), _ => null, }); string[] refs = ["Speed", "Temp", "Missing", "NotAuthored"]; var throughDriver = await driver.ReadAsync(refs, CancellationToken.None); var throughReader = await reference.ReadAsync(refs, CancellationToken.None); throughDriver.Count.ShouldBe(throughReader.Count); for (var i = 0; i < throughDriver.Count; i++) { throughDriver[i].Value.ShouldBe(throughReader[i].Value); throughDriver[i].StatusCode.ShouldBe(throughReader[i].StatusCode); throughDriver[i].SourceTimestampUtc.ShouldBe(throughReader[i].SourceTimestampUtc); } } // ---- the sustained-timeout decision ---- [Fact] public async Task ReadAsync_whenEveryTagTimesOut_degradesHealthAndReportsTheHostStopped() { using var fixture = new SqlitePollFixture(); await using var driver = NewDriver( fixture, fixture.ConnectionString, CapturingLogger.Null, // Deliberately inverted (operationTimeout < commandTimeout) so the CLIENT-side bound fires while // the server-side backstop would still be waiting — the reader's frozen-peer shape. operationTimeout: TimeSpan.FromMilliseconds(500), commandTimeout: TimeSpan.FromSeconds(30), rawTags: [KvEntry("Speed", SqlitePollFixture.PresentKey)]); await driver.InitializeAsync(ConfigJson, CancellationToken.None); driver.GetHealth().State.ShouldBe(DriverState.Healthy); // A held EXCLUSIVE transaction is this rig's frozen database (see SqlPollReaderTests). using var locker = fixture.OpenNewConnection(); using (var begin = locker.CreateCommand()) { begin.CommandText = "BEGIN EXCLUSIVE"; begin.ExecuteNonQuery(); } try { var snapshots = await driver.ReadAsync(["Speed"], CancellationToken.None); // The reader does exactly what it promises — Bad-coded snapshots, no exception… snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout); // …so the poll engine sees a clean tick, and ONLY the shell's own classification degrades health. var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Degraded); health.LastError.ShouldNotBeNullOrWhiteSpace(); driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped); } finally { using var rollback = locker.CreateCommand(); rollback.CommandText = "ROLLBACK"; rollback.ExecuteNonQuery(); } } [Fact] public async Task ReadAsync_anUnresolvableRef_isNotAConnectivityFact_soHealthAndHostStand() { // The falsifiability control for the test above: a Bad-coded batch must degrade the driver only when // the Bad codes are connection-class. An authoring typo reporting "the database is down" would send // an operator to the wrong system entirely. using var fixture = new SqlitePollFixture(); await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); await driver.InitializeAsync(ConfigJson, CancellationToken.None); (await driver.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good); var snapshots = await driver.ReadAsync(["TypoedTagName"], CancellationToken.None); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown); driver.GetHealth().State.ShouldBe(DriverState.Healthy); driver.GetHostStatuses()[0].State.ShouldBe(HostState.Running); } // ---- ISubscribable (poll-engine overlay) ---- [Fact] public async Task SubscribeAsync_deliversAnInitialChange_andUnsubscribeStopsThePolling() { using var fixture = new SqlitePollFixture(); await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); await driver.InitializeAsync(ConfigJson, CancellationToken.None); var first = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var changes = 0; driver.OnDataChange += (_, args) => { Interlocked.Increment(ref changes); first.TrySetResult(args); }; var handle = await driver.SubscribeAsync( ["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None); var initial = await first.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); initial.FullReference.ShouldBe("Speed"); initial.Snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue); initial.SubscriptionHandle.ShouldBe(handle); await driver.UnsubscribeAsync(handle, CancellationToken.None); driver.ActiveSubscriptionCount.ShouldBe(0); // Unsubscribe awaits the loop task, so nothing may arrive after it returns. var afterUnsubscribe = Volatile.Read(ref changes); await Task.Delay(400, TestContext.Current.CancellationToken); Volatile.Read(ref changes).ShouldBe(afterUnsubscribe); } // ---- disposal ---- [Fact] public async Task DisposeAsync_stopsThePollEngine_andIsIdempotent() { using var fixture = new SqlitePollFixture(); var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); await driver.InitializeAsync(ConfigJson, CancellationToken.None); var changes = 0; driver.OnDataChange += (_, _) => Interlocked.Increment(ref changes); _ = await driver.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None); await Task.Delay(300, TestContext.Current.CancellationToken); await driver.DisposeAsync(); driver.ActiveSubscriptionCount.ShouldBe(0); var afterDispose = Volatile.Read(ref changes); await Task.Delay(400, TestContext.Current.CancellationToken); Volatile.Read(ref changes).ShouldBe(afterDispose); // Idempotent: an `await using` after an explicit ShutdownAsync/DisposeAsync must not throw. await driver.ShutdownAsync(CancellationToken.None); await Should.NotThrowAsync(async () => await driver.DisposeAsync()); } // ---- host identity ---- [Fact] public async Task GetHostStatuses_namesTheServerAndDatabase_butNeverTheCredentials() { using var fixture = new SqlitePollFixture(); const string connectionString = "Server=sqlsrv01;Initial Catalog=MesStaging;User ID=svc_ot;Password=" + SecretToken + ";"; await using var driver = NewDriver(fixture, connectionString, CapturingLogger.Null); var hosts = driver.GetHostStatuses(); hosts.Count.ShouldBe(1); hosts[0].HostName.ShouldContain("sqlsrv01"); hosts[0].HostName.ShouldContain("MesStaging"); hosts[0].HostName.ShouldNotContain(SecretToken); hosts[0].HostName.ShouldNotContain("svc_ot"); hosts[0].State.ShouldBe(HostState.Unknown); // nothing has been attempted yet } [Fact] public async Task OnHostStatusChanged_firesOnceOnTheRunningTransition() { using var fixture = new SqlitePollFixture(); await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); var transitions = new List(); driver.OnHostStatusChanged += (_, args) => { lock (transitions) { transitions.Add(args); } }; await driver.InitializeAsync(ConfigJson, CancellationToken.None); // A second successful poll must not re-raise — the event is for transitions, not for ticks. await driver.ReadAsync(["Speed"], CancellationToken.None); lock (transitions) { transitions.Count.ShouldBe(1); transitions[0].OldState.ShouldBe(HostState.Unknown); transitions[0].NewState.ShouldBe(HostState.Running); } } // ---- C1a: the credential guarantee, proven load-bearing ---- [Fact] public async Task Initialize_whenTheProviderExceptionCarriesCredentials_leaksThemIntoNoOperatorSurface() { // The vacuous sibling test drives SQLite's "unable to open database file" path, whose message // structurally never contains the connection string — so it passes with or without any defensive // code. This one fabricates a DbException whose OWN .Message carries a credential-like token and // drives it through Initialize's liveness-failure path via the injectable factory seam. It is RED // against the pre-fix code that interpolated ex.Message into LastError and the thrown message. const string leakyMessage = "login failed for user; Password=" + SecretToken; var driver = new SqlDriver( new SqlDriverOptions { RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)], OperationTimeout = TimeSpan.FromSeconds(15), CommandTimeout = TimeSpan.FromSeconds(10), }, DriverInstanceId, new SqliteDialect(), // A credential-free connection string: the ONLY place the token can come from is the exception. "Server=sqlsrv01;Initial Catalog=Mes", factory: new ThrowingConnectionFactory(leakyMessage), logger: CapturingLogger.Null); await using var _ = driver; var thrown = await Should.ThrowAsync( async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Faulted); health.LastError.ShouldNotBeNullOrWhiteSpace(); // The token appears in NEITHER the operator-facing LastError NOR the thrown message NOR host status. health.LastError!.ShouldNotContain(SecretToken); thrown.Message.ShouldNotContain(SecretToken); driver.GetHostStatuses()[0].HostName.ShouldNotContain(SecretToken); driver.GetHostStatuses()[0].State.ShouldBe(HostState.Stopped); // …and it is still actionable: it names the endpoint the operator has to go look at. health.LastError.ShouldContain("sqlsrv01"); } // ---- I1: no double health-classification on a subscribed-read outage ---- [Fact] public async Task HandlePollError_forADbException_defersToReadAsync_soHealthIsClassifiedOnce() { // On a subscribed-read outage the reader throws a DbException; ReadAsync classifies health with a // good, endpoint-bearing message + host Stopped and rethrows the SAME exception, which the poll // engine then routes to HandlePollError. HandlePollError must ignore the DbException class ReadAsync // owns — pre-fix it re-degraded with the bare ex.Message (the WORSE of the two LastErrors, and a // credential-leak path) and logged the outage a second time. using var fixture = new SqlitePollFixture(); var logger = new CapturingLogger(); await using var driver = NewDriver(fixture, logger, KvEntry("Speed", SqlitePollFixture.PresentKey)); await driver.InitializeAsync(ConfigJson, CancellationToken.None); driver.GetHealth().State.ShouldBe(DriverState.Healthy); driver.HandlePollError(new FakeDbException("connection failed; Password=" + SecretToken)); var health = driver.GetHealth(); // Untouched: HandlePollError left the DbException to ReadAsync, so it neither degraded nor leaked… health.State.ShouldBe(DriverState.Healthy); health.LastError.ShouldBeNull(); // …and it logged nothing (ReadAsync owns the single outage warning). logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(0); } [Fact] public void HandlePollError_forAnEngineContractViolation_stillDegrades() { // The falsifiability control for the guard above: a non-DbException (the poll engine's own // reader-contract-violation throw) is NOT owned by ReadAsync, so it must still reach the health // surface. The guard skips only the DbException connection class. using var fixture = new SqlitePollFixture(); var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); driver.HandlePollError(new InvalidOperationException("Reader contract violation: expected 1 snapshots")); driver.GetHealth().State.ShouldBe(DriverState.Degraded); } [Fact] public void HandlePollError_forANonDbExceptionCarryingCredentials_degradesWithoutLeakingThem() { // The residual C1 leg HandlePollError owns: a NON-DbException raised from provider interaction — // canonically the ArgumentException a malformed connection string's keyword parser throws, echoing // credential fragments (the exact unquoted-';'-in-password vector this whole fix exists for). It is // not the DbException class ReadAsync owns, so it reaches Degrade — and must arrive TYPE-only, never // ex.Message. Pre-fix this degraded with the raw message and leaked the token. using var fixture = new SqlitePollFixture(); var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey)); driver.HandlePollError(new ArgumentException( "Keyword not supported: 'secrettail;connect timeout'. Password=" + SecretToken)); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Degraded); health.LastError.ShouldNotBeNull(); health.LastError.ShouldNotContain(SecretToken); // never the message text health.LastError.ShouldContain(nameof(ArgumentException)); // but still actionable (the type) } // ---- I2: a late poll cannot un-fault a Faulted driver ---- [Fact] public async Task ObservePollOutcome_whenALateGoodPollLands_cannotUnFaultTheDriver() { // The engine's teardown waits ~5 s for loops to wind down, but a poll may still be in flight up to // the 15 s default operationTimeout — so a poll can complete AFTER a concurrent ReinitializeAsync has // recorded Faulted. If that stale poll reports all-Good it must NOT flip Faulted back to Healthy: only // a successful (re)initialize clears Faulted. RED against the pre-fix unconditional Healthy write. using var fixture = new SqlitePollFixture(); await using var driver = NewDriver( fixture, Unreachable(), CapturingLogger.Null, KvEntry("Speed", SqlitePollFixture.PresentKey)); await Should.ThrowAsync( async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None)); driver.GetHealth().State.ShouldBe(DriverState.Faulted); // The stale in-flight poll completes with an all-Good batch. driver.ObservePollOutcome( [new DataValueSnapshot(42, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)]); driver.GetHealth().State.ShouldBe(DriverState.Faulted); // stayed Faulted } // ---- I3: an omitted-type tag warns at discovery ---- [Fact] public async Task Discover_warnsForAnOmittedTypeTag_butNotForATypedOne() { using var fixture = new SqlitePollFixture(); var logger = new CapturingLogger(); await using var driver = NewDriver( fixture, logger, KvEntry("Speed", SqlitePollFixture.PresentKey), // no "type" → discovered String TypedKvEntry("Rpm", SqlitePollFixture.PresentKey, "Int32")); // explicit "type" await driver.InitializeAsync(ConfigJson, CancellationToken.None); var capture = new CapturingBuilder(); await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None); logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("Speed") && e.Message.Contains("no authored")); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning && e.Message.Contains("Rpm") && e.Message.Contains("no authored")); } // ---- helpers ---- /// A distinctive password token: any assertion that finds it has found a credential leak. private const string SecretToken = "hunter2-do-not-log"; /// The unreachable connection string's database file name, used as the actionable-error probe. private const string NoSuchDatabaseName = "otopcua-sql-no-such-dir"; /// /// The driver's DriverConfig JSON. The shell serves its typed options (the factory, Task 9, /// owns parsing) exactly as ModbusDriver does, so this is deliberately inert. /// private const string ConfigJson = """{"provider":"SqlServer"}"""; private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags) => NewDriver(fixture, CapturingLogger.Null, rawTags); private static SqlDriver NewDriver( SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags) => NewDriver(fixture, fixture.ConnectionString, logger, rawTags); private static SqlDriver NewDriver( SqlitePollFixture fixture, string connectionString, CapturingLogger logger, params RawTagEntry[] rawTags) => NewDriver( fixture, connectionString, logger, operationTimeout: TimeSpan.FromSeconds(15), commandTimeout: TimeSpan.FromSeconds(10), rawTags: rawTags); /// /// Builds the driver through its production constructor. That constructor is the injection /// seam — dialect, provider factory and already-resolved connection string are all parameters — so no /// test-only factory is needed on the product type (Task 9 resolves the same three from config). /// private static SqlDriver NewDriver( SqlitePollFixture fixture, string connectionString, CapturingLogger logger, TimeSpan operationTimeout, TimeSpan commandTimeout, IReadOnlyList rawTags) => new( new SqlDriverOptions { RawTags = rawTags, OperationTimeout = operationTimeout, CommandTimeout = commandTimeout, }, DriverInstanceId, new SqliteDialect(), connectionString, factory: fixture.Factory, logger: logger); /// A connection string whose database cannot be opened — the directory does not exist. private static string Unreachable() => new SqliteConnectionStringBuilder { DataSource = Path.Combine( Path.GetTempPath(), $"{NoSuchDatabaseName}-{Guid.NewGuid():N}", "db.sqlite"), Password = SecretToken, }.ToString(); /// One authored raw tag: a key-value TagConfig blob over the fixture's EAV table. private static RawTagEntry KvEntry(string rawPath, string keyValue) => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$""" { "driver": "Sql", "model": "KeyValue", "table": "{{SqlitePollFixture.KeyValueTable}}", "keyColumn": "{{SqlitePollFixture.KeyColumn}}", "keyValue": "{{keyValue}}", "valueColumn": "{{SqlitePollFixture.ValueColumn}}", "timestampColumn": "{{SqlitePollFixture.TimestampColumn}}" } """), WriteIdempotent: false); /// A key-value entry carrying an explicit "type" — so DeclaredType is non-null. private static RawTagEntry TypedKvEntry(string rawPath, string keyValue, string type) => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$""" { "driver": "Sql", "model": "KeyValue", "table": "{{SqlitePollFixture.KeyValueTable}}", "keyColumn": "{{SqlitePollFixture.KeyColumn}}", "keyValue": "{{keyValue}}", "valueColumn": "{{SqlitePollFixture.ValueColumn}}", "timestampColumn": "{{SqlitePollFixture.TimestampColumn}}", "type": "{{type}}" } """), WriteIdempotent: false); /// The same tag as , already typed — for the reference reader. private static SqlTagDefinition KvDefinition(string rawPath, string keyValue) => new(rawPath, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue, ValueColumn: SqlitePollFixture.ValueColumn, TimestampColumn: SqlitePollFixture.TimestampColumn); /// Records everything the driver streams into the address space. private sealed class CapturingBuilder : IAddressSpaceBuilder { /// The folders created, in order. public List<(string BrowseName, string DisplayName)> Folders { get; } = []; /// The variables registered, in order. public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) { Variables.Add((browseName, attributeInfo)); return new Handle(attributeInfo.FullName); } public void AddProperty(string browseName, DriverDataType dataType, object? value) { } private sealed class Handle(string fullReference) : IVariableHandle { public string FullReference => fullReference; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink(); private sealed class Sink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } } } /// Captures the driver's log so "skipped and logged" can be asserted rather than assumed. private sealed class CapturingLogger : ILogger { /// A logger that records nothing — for the tests that do not assert on logging. public static CapturingLogger Null { get; } = new(); /// Every record written, level + rendered message. public List<(LogLevel Level, string Message)> Entries { get; } = []; public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { lock (Entries) Entries.Add((logLevel, formatter(state, exception))); } private sealed class NullScope : IDisposable { public static NullScope Instance { get; } = new(); public void Dispose() { } } } /// /// A whose message is under the test's control — stands in for the /// credential-echoing exceptions a real ADO.NET provider can raise, so the credential-hygiene /// guarantee can be exercised without a live database. /// private sealed class FakeDbException(string message) : DbException(message); /// /// A whose connections always fail to open, with a /// caller-supplied (credential-bearing) message — the seam that drives the driver's /// unreachable-database path with a fabricated provider exception. /// private sealed class ThrowingConnectionFactory(string openFailureMessage) : DbProviderFactory { public override DbConnection CreateConnection() => new ThrowingConnection(openFailureMessage); } /// A connection that throws on open; every other member is inert. private sealed class ThrowingConnection(string openFailureMessage) : DbConnection { [System.Diagnostics.CodeAnalysis.AllowNull] public override string ConnectionString { get; set; } = string.Empty; public override string Database => string.Empty; public override string DataSource => string.Empty; public override string ServerVersion => string.Empty; public override ConnectionState State => ConnectionState.Closed; public override void Open() => throw new FakeDbException(openFailureMessage); public override void ChangeDatabase(string databaseName) => throw new NotSupportedException(); public override void Close() { } protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw new NotSupportedException(); protected override DbCommand CreateDbCommand() => throw new NotSupportedException(); } }