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); } } // ---- 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); /// 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() { } } } }