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