254 lines
10 KiB
C#
254 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// WP-9: Tests for Database access — connection resolution, cached writes.
|
|
/// </summary>
|
|
public class DatabaseGatewayTests
|
|
{
|
|
private readonly IExternalSystemRepository _repository = Substitute.For<IExternalSystemRepository>();
|
|
|
|
/// <summary>
|
|
/// Configures the repository substitute for the name-keyed connection-resolution
|
|
/// path used by <c>DatabaseGateway</c> (ExternalSystemGateway-011). A <c>null</c>
|
|
/// connection models a "not found" — the substitute returns <c>null</c> by default,
|
|
/// so no stub is needed for the absent entity.
|
|
/// </summary>
|
|
private void StubConnection(DatabaseConnectionDefinition? connection)
|
|
{
|
|
if (connection != null)
|
|
{
|
|
_repository.GetDatabaseConnectionByNameAsync(connection.Name, Arg.Any<CancellationToken>())
|
|
.Returns(connection);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetConnection_NotFound_Throws()
|
|
{
|
|
StubConnection(connection: null);
|
|
|
|
var gateway = new DatabaseGateway(
|
|
_repository,
|
|
NullLogger<DatabaseGateway>.Instance);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => gateway.GetConnectionAsync("nonexistent"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedWrite_NoStoreAndForward_Throws()
|
|
{
|
|
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 };
|
|
StubConnection(conn);
|
|
|
|
var gateway = new DatabaseGateway(
|
|
_repository,
|
|
NullLogger<DatabaseGateway>.Instance,
|
|
storeAndForward: null);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedWrite_ConnectionNotFound_Throws()
|
|
{
|
|
StubConnection(connection: null);
|
|
|
|
var gateway = new DatabaseGateway(
|
|
_repository,
|
|
NullLogger<DatabaseGateway>.Instance);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => 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),
|
|
};
|
|
StubConnection(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<ScadaLink.StoreAndForward.StoreAndForwardStorage>.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<ScadaLink.StoreAndForward.StoreAndForwardService>.Instance);
|
|
|
|
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance, storeAndForward: sf);
|
|
|
|
await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (@v)",
|
|
new Dictionary<string, object?> { ["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_ZeroMaxRetriesIsTreatedAsUnsetNotRetryForever()
|
|
{
|
|
// ExternalSystemGateway-015: a stored MaxRetries of 0 is interpreted by the
|
|
// Store-and-Forward retry sweep as "no limit" (retry forever). The entity's
|
|
// non-nullable int default is also 0, so the gateway must treat the
|
|
// connection's MaxRetries == 0 as "unset" and pass null — the bounded S&F
|
|
// default must apply, never 0.
|
|
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test")
|
|
{
|
|
Id = 1,
|
|
MaxRetries = 0,
|
|
RetryDelay = TimeSpan.FromSeconds(3),
|
|
};
|
|
StubConnection(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<ScadaLink.StoreAndForward.StoreAndForwardStorage>.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<ScadaLink.StoreAndForward.StoreAndForwardService>.Instance);
|
|
|
|
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance, storeAndForward: sf);
|
|
|
|
await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)");
|
|
|
|
var (maxRetries, _) = ReadBufferedRetrySettings(connStr);
|
|
// Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever.
|
|
Assert.Equal(99, maxRetries);
|
|
Assert.NotEqual(0, maxRetries);
|
|
}
|
|
|
|
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()
|
|
{
|
|
StubConnection(connection: null);
|
|
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.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 };
|
|
StubConnection(conn);
|
|
|
|
var fake = new ThrowingDbConnection();
|
|
var gateway = new ConnectionFactoryStubGateway(_repository, fake);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => gateway.GetConnectionAsync("testDb"));
|
|
|
|
Assert.True(fake.WasDisposed, "The SqlConnection was leaked — it must be disposed when OpenAsync fails");
|
|
}
|
|
|
|
/// <summary>Test gateway that substitutes the connection factory with a stub.</summary>
|
|
private sealed class ConnectionFactoryStubGateway : DatabaseGateway
|
|
{
|
|
private readonly DbConnection _connection;
|
|
|
|
public ConnectionFactoryStubGateway(IExternalSystemRepository repository, DbConnection connection)
|
|
: base(repository, NullLogger<DatabaseGateway>.Instance) => _connection = connection;
|
|
|
|
internal override DbConnection CreateConnection(string connectionString) => _connection;
|
|
}
|
|
|
|
/// <summary>A DbConnection whose OpenAsync always fails, tracking whether it was disposed.</summary>
|
|
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();
|
|
}
|
|
}
|