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