refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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 ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardStorage(
|
||||
connStr, NullLogger<ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardStorage>.Instance);
|
||||
await storage.InitializeAsync();
|
||||
var sfOptions = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardOptions
|
||||
{
|
||||
DefaultMaxRetries = 99,
|
||||
DefaultRetryInterval = TimeSpan.FromMinutes(10),
|
||||
RetryTimerInterval = TimeSpan.FromMinutes(10),
|
||||
};
|
||||
var sf = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService(
|
||||
storage, sfOptions, NullLogger<ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService>.Instance);
|
||||
|
||||
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance, storeAndForward: sf);
|
||||
|
||||
// Audit Log #23 (ExecutionId Task 4): a known execution id / source
|
||||
// script so the gateway -> EnqueueAsync hop can be asserted below.
|
||||
var executionId = Guid.NewGuid();
|
||||
const string sourceScript = "ScriptActor:WriteAudit";
|
||||
|
||||
await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (@v)",
|
||||
new Dictionary<string, object?> { ["v"] = 1 },
|
||||
executionId: executionId, sourceScript: sourceScript);
|
||||
|
||||
var depth = await storage.GetBufferDepthByCategoryAsync();
|
||||
Assert.Equal(1, depth[ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite]);
|
||||
|
||||
var buffered = ReadBufferedRetrySettings(connStr);
|
||||
Assert.Equal(5, buffered.MaxRetries);
|
||||
Assert.Equal((long)TimeSpan.FromSeconds(12).TotalMilliseconds, buffered.RetryIntervalMs);
|
||||
|
||||
// ExecutionId Task 4: the gateway must forward executionId / sourceScript
|
||||
// into EnqueueAsync, and the S&F layer must persist them on the
|
||||
// sf_messages row so the retry loop can stamp the right provenance.
|
||||
Assert.Equal(executionId, buffered.ExecutionId);
|
||||
Assert.Equal(sourceScript, buffered.SourceScript);
|
||||
}
|
||||
|
||||
[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 ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardStorage(
|
||||
connStr, NullLogger<ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardStorage>.Instance);
|
||||
await storage.InitializeAsync();
|
||||
var sfOptions = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardOptions
|
||||
{
|
||||
DefaultMaxRetries = 99,
|
||||
DefaultRetryInterval = TimeSpan.FromMinutes(10),
|
||||
RetryTimerInterval = TimeSpan.FromMinutes(10),
|
||||
};
|
||||
var sf = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService(
|
||||
storage, sfOptions, NullLogger<ZB.MOM.WW.ScadaBridge.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, Guid? ExecutionId, string? SourceScript)
|
||||
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, execution_id, source_script 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),
|
||||
reader.IsDBNull(2) ? (Guid?)null : Guid.Parse(reader.GetString(2)),
|
||||
reader.IsDBNull(3) ? null : reader.GetString(3));
|
||||
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 ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardMessage
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Category = ZB.MOM.WW.ScadaBridge.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-018: malformed JSON payload must park, not retry-forever ──
|
||||
|
||||
[Fact]
|
||||
public async Task DeliverBuffered_MalformedJsonPayload_ReturnsFalseSoMessageParks()
|
||||
{
|
||||
// No connection stub needed — deserialization fails before any
|
||||
// resolution or SQL execution. If the JsonException were to escape (the
|
||||
// pre-018 behaviour) the S&F engine would treat it as transient and
|
||||
// retry the same poison row forever.
|
||||
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance);
|
||||
|
||||
var poisonMessage = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardMessage
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Category = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite,
|
||||
Target = "someDb",
|
||||
// Truncated mid-write — `{` opens an object that never closes.
|
||||
PayloadJson = "{\"ConnectionName\":\"someDb\",\"Sql\":\"INSERT",
|
||||
};
|
||||
|
||||
var delivered = await gateway.DeliverBufferedAsync(poisonMessage);
|
||||
|
||||
Assert.False(delivered); // permanent — the S&F engine parks the message
|
||||
}
|
||||
|
||||
// ── ExternalSystemGateway-020: decimal SQL parameter precision survives JsonElement round-trip ──
|
||||
|
||||
[Fact]
|
||||
public void JsonElementToParameterValue_DecimalShapedNumber_PreservesPrecisionViaDecimal()
|
||||
{
|
||||
// A script's decimal SQL parameter is serialised as a bare JSON number
|
||||
// (System.Text.Json has no decimal type tag), then on the cached-write
|
||||
// retry path the buffered payload is re-deserialised into a
|
||||
// JsonElement and the gateway must materialise a CLR value for the
|
||||
// parameter. Pre-020 it called GetDouble() for any non-Int64 number,
|
||||
// which silently downcast every decimal to a binary float and lost
|
||||
// precision (1234567890.1234567890 -> 1234567890.1234567 as double).
|
||||
// The 020 fix prefers decimal — round-tripping must preserve every
|
||||
// digit of an authoring-time decimal value.
|
||||
const string authoredJson = "1234567890.1234567890";
|
||||
|
||||
// Round-trip through JsonElement, mirroring the buffered-payload path.
|
||||
using var document = JsonDocument.Parse(authoredJson);
|
||||
var element = document.RootElement;
|
||||
|
||||
var materialised = DatabaseGateway.JsonElementToParameterValue(element);
|
||||
|
||||
// The materialised value must be the original decimal, not a double.
|
||||
// Asserting on the type alone is enough to fail pre-020 (which
|
||||
// produced a System.Double); the value assertion locks in the
|
||||
// precision invariant.
|
||||
var asDecimal = Assert.IsType<decimal>(materialised);
|
||||
Assert.Equal(1234567890.1234567890m, asDecimal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonElementToParameterValue_WholeNumber_FastPathReturnsLong()
|
||||
{
|
||||
// Whole numbers must keep the existing Int64 fast path — the 020 fix
|
||||
// is "long first, then decimal, then double", not "decimal first".
|
||||
using var document = JsonDocument.Parse("42");
|
||||
var element = document.RootElement;
|
||||
|
||||
var materialised = DatabaseGateway.JsonElementToParameterValue(element);
|
||||
|
||||
Assert.IsType<long>(materialised);
|
||||
Assert.Equal(42L, materialised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonElementToParameterValue_OutOfDecimalRangeNumber_FallsThroughToDouble()
|
||||
{
|
||||
// A genuinely out-of-decimal-range value (e.g. very large scientific
|
||||
// notation) must still fall through to double rather than throw — the
|
||||
// decimal probe is a precision-preserving preference, not a hard
|
||||
// requirement.
|
||||
using var document = JsonDocument.Parse("1e40");
|
||||
var element = document.RootElement;
|
||||
|
||||
var materialised = DatabaseGateway.JsonElementToParameterValue(element);
|
||||
|
||||
Assert.IsType<double>(materialised);
|
||||
Assert.Equal(1e40, (double)materialised);
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-8: Tests for HTTP error classification.
|
||||
/// Transient: connection refused, timeout, HTTP 408/429/5xx.
|
||||
/// Permanent: HTTP 4xx (except 408/429).
|
||||
/// </summary>
|
||||
public class ErrorClassifierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.InternalServerError, true)]
|
||||
[InlineData(HttpStatusCode.BadGateway, true)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable, true)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout, true)]
|
||||
[InlineData(HttpStatusCode.RequestTimeout, true)]
|
||||
[InlineData((HttpStatusCode)429, true)] // TooManyRequests
|
||||
public void TransientStatusCodes_ClassifiedCorrectly(HttpStatusCode statusCode, bool expectedTransient)
|
||||
{
|
||||
Assert.Equal(expectedTransient, ErrorClassifier.IsTransient(statusCode));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.BadRequest, false)]
|
||||
[InlineData(HttpStatusCode.Unauthorized, false)]
|
||||
[InlineData(HttpStatusCode.Forbidden, false)]
|
||||
[InlineData(HttpStatusCode.NotFound, false)]
|
||||
[InlineData(HttpStatusCode.MethodNotAllowed, false)]
|
||||
[InlineData(HttpStatusCode.Conflict, false)]
|
||||
public void PermanentStatusCodes_ClassifiedCorrectly(HttpStatusCode statusCode, bool expectedTransient)
|
||||
{
|
||||
Assert.Equal(expectedTransient, ErrorClassifier.IsTransient(statusCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpRequestException_IsTransient()
|
||||
{
|
||||
Assert.True(ErrorClassifier.IsTransient(new HttpRequestException("Connection refused")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskCanceledException_IsTransient()
|
||||
{
|
||||
Assert.True(ErrorClassifier.IsTransient(new TaskCanceledException("Timeout")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeoutException_IsTransient()
|
||||
{
|
||||
Assert.True(ErrorClassifier.IsTransient(new TimeoutException()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericException_IsNotTransient()
|
||||
{
|
||||
Assert.False(ErrorClassifier.IsTransient(new InvalidOperationException("bad input")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsTransient_CreatesCorrectException()
|
||||
{
|
||||
var ex = ErrorClassifier.AsTransient("test message");
|
||||
Assert.IsType<TransientExternalSystemException>(ex);
|
||||
Assert.Equal("test message", ex.Message);
|
||||
}
|
||||
}
|
||||
+1090
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ExternalSystemGateway-013: configuration options must actually influence the
|
||||
/// registered HTTP client — an operator setting them must not be silently ignored.
|
||||
/// </summary>
|
||||
public class ServiceWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public void MaxConcurrentConnectionsPerSystem_IsAppliedToTheNamedHttpClientPrimaryHandler()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:ExternalSystemGateway:MaxConcurrentConnectionsPerSystem"] = "4",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
services.AddSingleton(Substitute.For<IExternalSystemRepository>());
|
||||
services.AddExternalSystemGateway();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
// Resolve the per-system named client's message-handler chain and walk to the
|
||||
// primary handler — the option must be reflected in MaxConnectionsPerServer.
|
||||
var handlerFactory = provider.GetRequiredService<IHttpMessageHandlerFactory>();
|
||||
var handler = handlerFactory.CreateHandler("ExternalSystem_AnySystem");
|
||||
|
||||
var primary = FindPrimaryHandler(handler);
|
||||
var sockets = Assert.IsType<SocketsHttpHandler>(primary);
|
||||
Assert.Equal(4, sockets.MaxConnectionsPerServer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxConcurrentConnectionsPerSystem_IsNotAppliedToNonGatewayHttpClients()
|
||||
{
|
||||
// ExternalSystemGateway-016: the gateway's connection cap must be scoped to
|
||||
// its own per-system clients ("ExternalSystem_{name}"). It must NOT leak onto
|
||||
// unrelated HttpClient consumers in the same host process (e.g. the
|
||||
// Notification Service's OAuth2 token client) — that would silently throttle
|
||||
// and override the primary-handler configuration of another component.
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:ExternalSystemGateway:MaxConcurrentConnectionsPerSystem"] = "4",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
services.AddSingleton(Substitute.For<IExternalSystemRepository>());
|
||||
services.AddExternalSystemGateway();
|
||||
|
||||
// A client owned by a different component, registered the way the
|
||||
// Notification Service registers its OAuth2 token client — a plain
|
||||
// AddHttpClient with no custom primary handler. Its primary handler must
|
||||
// remain the framework default (uncapped), not the gateway's SocketsHttpHandler.
|
||||
services.AddHttpClient("NotificationService_OAuth2");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var handlerFactory = provider.GetRequiredService<IHttpMessageHandlerFactory>();
|
||||
|
||||
// The gateway's own client must still get the gateway cap.
|
||||
var gatewayPrimary = FindPrimaryHandler(handlerFactory.CreateHandler("ExternalSystem_AnySystem"));
|
||||
Assert.Equal(4, Assert.IsType<SocketsHttpHandler>(gatewayPrimary).MaxConnectionsPerServer);
|
||||
|
||||
// The unrelated component's client must NOT inherit the gateway's connection
|
||||
// cap. With ConfigureHttpClientDefaults the primary handler is a
|
||||
// SocketsHttpHandler capped at the gateway's value (the leak); with a scoped
|
||||
// registration it is the framework default whose MaxConnectionsPerServer is
|
||||
// int.MaxValue.
|
||||
var otherPrimary = FindPrimaryHandler(handlerFactory.CreateHandler("NotificationService_OAuth2"));
|
||||
if (otherPrimary is SocketsHttpHandler otherSockets)
|
||||
{
|
||||
Assert.NotEqual(4, otherSockets.MaxConnectionsPerServer);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpMessageHandler FindPrimaryHandler(HttpMessageHandler handler)
|
||||
{
|
||||
var current = handler;
|
||||
while (current is DelegatingHandler delegating && delegating.InnerHandler != null)
|
||||
{
|
||||
current = delegating.InnerHandler;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ZB.MOM.WW.ScadaBridge.StoreAndForward.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user