fix(external-system-gateway): resolve ExternalSystemGateway-004..010 — honour retry settings, dispose HTTP messages, fix URL building, truncate error bodies, fix connection leak

This commit is contained in:
Joseph Doherty
2026-05-16 21:11:24 -04:00
parent 8c67ffad2a
commit 2502e4d10a
5 changed files with 615 additions and 52 deletions

View File

@@ -1,3 +1,5 @@
using System.Data;
using System.Data.Common;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.ExternalSystems;
@@ -75,4 +77,66 @@ public class DatabaseGatewayTests
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<DatabaseConnectionDefinition> { 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();
}
}