using System.Data; using System.Data.Common; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ScadaLink.Commons.Entities.ExternalSystems; using ScadaLink.Commons.Interfaces.Repositories; namespace ScadaLink.ExternalSystemGateway.Tests; /// /// WP-9: Tests for Database access — connection resolution, cached writes. /// public class DatabaseGatewayTests { private readonly IExternalSystemRepository _repository = Substitute.For(); [Fact] public async Task GetConnection_NotFound_Throws() { _repository.GetAllDatabaseConnectionsAsync().Returns(new List()); var gateway = new DatabaseGateway( _repository, NullLogger.Instance); await Assert.ThrowsAsync( () => gateway.GetConnectionAsync("nonexistent")); } [Fact] public async Task CachedWrite_NoStoreAndForward_Throws() { var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 }; _repository.GetAllDatabaseConnectionsAsync() .Returns(new List { conn }); var gateway = new DatabaseGateway( _repository, NullLogger.Instance, storeAndForward: null); await Assert.ThrowsAsync( () => gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)")); } [Fact] public async Task CachedWrite_ConnectionNotFound_Throws() { _repository.GetAllDatabaseConnectionsAsync().Returns(new List()); var gateway = new DatabaseGateway( _repository, NullLogger.Instance); await Assert.ThrowsAsync( () => gateway.CachedWriteAsync("nonexistent", "INSERT INTO t VALUES (1)")); } // ── ExternalSystemGateway-014: CachedWrite happy-path buffering ── [Fact] public async Task CachedWrite_BuffersTheWriteWithConnectionRetrySettings() { var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1, MaxRetries = 5, RetryDelay = TimeSpan.FromSeconds(12), }; _repository.GetAllDatabaseConnectionsAsync(Arg.Any()) .Returns(new List { conn }); var dbName = $"EsgCachedWrite_{Guid.NewGuid():N}"; var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; using var keepAlive = new Microsoft.Data.Sqlite.SqliteConnection(connStr); keepAlive.Open(); var storage = new ScadaLink.StoreAndForward.StoreAndForwardStorage( connStr, NullLogger.Instance); await storage.InitializeAsync(); var sfOptions = new ScadaLink.StoreAndForward.StoreAndForwardOptions { DefaultMaxRetries = 99, DefaultRetryInterval = TimeSpan.FromMinutes(10), RetryTimerInterval = TimeSpan.FromMinutes(10), }; var sf = new ScadaLink.StoreAndForward.StoreAndForwardService( storage, sfOptions, NullLogger.Instance); var gateway = new DatabaseGateway(_repository, NullLogger.Instance, storeAndForward: sf); await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (@v)", new Dictionary { ["v"] = 1 }); var depth = await storage.GetBufferDepthByCategoryAsync(); Assert.Equal(1, depth[ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite]); var (maxRetries, retryIntervalMs) = ReadBufferedRetrySettings(connStr); Assert.Equal(5, maxRetries); Assert.Equal((long)TimeSpan.FromSeconds(12).TotalMilliseconds, retryIntervalMs); } [Fact] public async Task CachedWrite_ZeroMaxRetriesIsHonouredNotTreatedAsUnset() { var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1, MaxRetries = 0, RetryDelay = TimeSpan.FromSeconds(3), }; _repository.GetAllDatabaseConnectionsAsync(Arg.Any()) .Returns(new List { conn }); var dbName = $"EsgCachedWriteZero_{Guid.NewGuid():N}"; var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; using var keepAlive = new Microsoft.Data.Sqlite.SqliteConnection(connStr); keepAlive.Open(); var storage = new ScadaLink.StoreAndForward.StoreAndForwardStorage( connStr, NullLogger.Instance); await storage.InitializeAsync(); var sfOptions = new ScadaLink.StoreAndForward.StoreAndForwardOptions { DefaultMaxRetries = 99, DefaultRetryInterval = TimeSpan.FromMinutes(10), RetryTimerInterval = TimeSpan.FromMinutes(10), }; var sf = new ScadaLink.StoreAndForward.StoreAndForwardService( storage, sfOptions, NullLogger.Instance); var gateway = new DatabaseGateway(_repository, NullLogger.Instance, storeAndForward: sf); await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)"); var (maxRetries, _) = ReadBufferedRetrySettings(connStr); Assert.Equal(0, maxRetries); // honoured — not the S&F default of 99 } private static (int MaxRetries, long RetryIntervalMs) ReadBufferedRetrySettings(string connStr) { using var conn = new Microsoft.Data.Sqlite.SqliteConnection(connStr); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT max_retries, retry_interval_ms FROM sf_messages"; using var reader = cmd.ExecuteReader(); Assert.True(reader.Read(), "expected exactly one buffered message"); var result = (reader.GetInt32(0), reader.GetInt64(1)); Assert.False(reader.Read(), "expected exactly one buffered message"); return result; } // ── ExternalSystemGateway-001: buffered CachedDbWrite delivery handler ── [Fact] public async Task DeliverBuffered_ConnectionNoLongerExists_ReturnsFalseSoMessageParks() { _repository.GetAllDatabaseConnectionsAsync().Returns(new List()); var gateway = new DatabaseGateway(_repository, NullLogger.Instance); var message = new ScadaLink.StoreAndForward.StoreAndForwardMessage { Id = Guid.NewGuid().ToString("N"), Category = ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite, Target = "gone-db", PayloadJson = """{"ConnectionName":"gone-db","Sql":"INSERT INTO t VALUES (1)","Parameters":null}""", }; var delivered = await gateway.DeliverBufferedAsync(message); Assert.False(delivered); // permanent — the S&F engine parks the message } // ── ExternalSystemGateway-010: SqlConnection must not leak when OpenAsync fails ── [Fact] public async Task GetConnection_OpenFails_DisposesConnectionBeforeRethrowing() { var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 }; _repository.GetAllDatabaseConnectionsAsync().Returns(new List { conn }); var fake = new ThrowingDbConnection(); var gateway = new ConnectionFactoryStubGateway(_repository, fake); await Assert.ThrowsAsync( () => gateway.GetConnectionAsync("testDb")); Assert.True(fake.WasDisposed, "The SqlConnection was leaked — it must be disposed when OpenAsync fails"); } /// Test gateway that substitutes the connection factory with a stub. private sealed class ConnectionFactoryStubGateway : DatabaseGateway { private readonly DbConnection _connection; public ConnectionFactoryStubGateway(IExternalSystemRepository repository, DbConnection connection) : base(repository, NullLogger.Instance) => _connection = connection; internal override DbConnection CreateConnection(string connectionString) => _connection; } /// A DbConnection whose OpenAsync always fails, tracking whether it was disposed. private sealed class ThrowingDbConnection : DbConnection { public bool WasDisposed { get; private set; } public override Task OpenAsync(CancellationToken cancellationToken) => throw new InvalidOperationException("simulated open failure"); public override void Open() => throw new InvalidOperationException("simulated open failure"); protected override void Dispose(bool disposing) { if (disposing) WasDisposed = true; base.Dispose(disposing); } public override ValueTask DisposeAsync() { WasDisposed = true; return base.DisposeAsync(); } // Unused abstract members. [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 ChangeDatabase(string databaseName) => throw new NotSupportedException(); public override void Close() { } protected override DbTransaction BeginDbTransaction(IsolationLevel il) => throw new NotSupportedException(); protected override DbCommand CreateDbCommand() => throw new NotSupportedException(); } }