docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)

Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
/// <summary>
/// WP-9: Tests for Database access — connection resolution, cached writes.
/// Tests for Database access — connection resolution, cached writes.
/// </summary>
public class DatabaseGatewayTests : IDisposable
{
@@ -55,7 +55,7 @@ public class DatabaseGatewayTests : IDisposable
/// <summary>
/// Configures the repository substitute for the name-keyed connection-resolution
/// path used by <c>DatabaseGateway</c> (ExternalSystemGateway-011). A <c>null</c>
/// path used by <c>DatabaseGateway</c>. 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>
@@ -68,6 +68,8 @@ public class DatabaseGatewayTests : IDisposable
}
}
/// <summary>Verifies that GetConnection throws when the connection is not found.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetConnection_NotFound_Throws()
{
@@ -81,6 +83,8 @@ public class DatabaseGatewayTests : IDisposable
() => gateway.GetConnectionAsync("nonexistent"));
}
/// <summary>Verifies that CachedWrite throws when no store-and-forward service is configured.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedWrite_NoStoreAndForward_Throws()
{
@@ -96,6 +100,8 @@ public class DatabaseGatewayTests : IDisposable
() => gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)"));
}
/// <summary>Verifies that CachedWrite throws when the connection is not found.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedWrite_ConnectionNotFound_Throws()
{
@@ -109,8 +115,10 @@ public class DatabaseGatewayTests : IDisposable
() => gateway.CachedWriteAsync("nonexistent", "INSERT INTO t VALUES (1)"));
}
// ── ExternalSystemGateway-014: CachedWrite happy-path buffering ──
// ── CachedWrite happy-path buffering ──
/// <summary>Verifies that CachedWrite buffers the write using the connection's retry settings.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedWrite_BuffersTheWriteWithConnectionRetrySettings()
{
@@ -135,7 +143,7 @@ public class DatabaseGatewayTests : IDisposable
var sf = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService(
storage, sfOptions, NullLogger<ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService>.Instance);
// M2.3 (#7): CachedWriteAsync now attempts the write immediately and
// CachedWriteAsync now attempts the write immediately and
// only buffers on a TRANSIENT failure. The stub forces a transient
// outcome so this test exercises the buffering path deterministically
// without a real SQL Server.
@@ -144,7 +152,7 @@ public class DatabaseGatewayTests : IDisposable
sf,
onExecute: () => throw new TransientDatabaseException("deadlock", errorNumber: 1205));
// Audit Log #23 (ExecutionId Task 4): a known execution id / source
// ExecutionId provenance: a known execution id / source
// script so the gateway -> EnqueueAsync hop can be asserted below.
var executionId = Guid.NewGuid();
const string sourceScript = "ScriptActor:WriteAudit";
@@ -160,17 +168,19 @@ public class DatabaseGatewayTests : IDisposable
Assert.Equal(5, buffered.MaxRetries);
Assert.Equal((long)TimeSpan.FromSeconds(12).TotalMilliseconds, buffered.RetryIntervalMs);
// ExecutionId Task 4: the gateway must forward executionId / sourceScript
// ExecutionId provenance: 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);
}
/// <summary>Verifies that a zero MaxRetries is treated as unset rather than as retry-forever.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedWrite_ZeroMaxRetriesIsTreatedAsUnsetNotRetryForever()
{
// ExternalSystemGateway-015: a stored MaxRetries of 0 is interpreted by the
// 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
@@ -196,7 +206,7 @@ public class DatabaseGatewayTests : IDisposable
var sf = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService(
storage, sfOptions, NullLogger<ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService>.Instance);
// M2.3 (#7): force a transient outcome so the write reaches S&F.
// Force a transient outcome so the write reaches S&F.
var gateway = new ExecuteStubGateway(
_repository,
sf,
@@ -210,7 +220,7 @@ public class DatabaseGatewayTests : IDisposable
Assert.NotEqual(0, maxRetries);
}
// ── M2.3 (#7): transient-vs-permanent SQL classification on the immediate
// ── Transient-vs-permanent SQL classification on the immediate
// cached-write attempt + the buffered retry path ──
/// <summary>
@@ -235,6 +245,8 @@ public class DatabaseGatewayTests : IDisposable
return (sf, localDb.Db);
}
/// <summary>Verifies that a permanent SQL error returns a failure synchronously and is not buffered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedWrite_PermanentSqlError_ReturnsFailedSynchronously_NotBuffered()
{
@@ -263,6 +275,8 @@ public class DatabaseGatewayTests : IDisposable
Assert.Equal(0, ReadBufferDepth(sfDb));
}
/// <summary>Verifies that a transient SQL error is buffered to store-and-forward.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedWrite_TransientSqlError_BuffersToStoreAndForward()
{
@@ -294,6 +308,8 @@ public class DatabaseGatewayTests : IDisposable
Assert.Equal(1, ReadBufferDepth(sfDb));
}
/// <summary>Verifies that an immediate success is not buffered and returns Delivered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedWrite_ImmediateSuccess_NotBuffered_ReturnsDelivered()
{
@@ -316,6 +332,8 @@ public class DatabaseGatewayTests : IDisposable
Assert.Equal(0, ReadBufferDepth(sfDb));
}
/// <summary>Verifies that a transient SQL error during buffered delivery is rethrown so the engine retries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliverBuffered_TransientSqlError_RethrowsSoEngineRetries()
{
@@ -343,6 +361,8 @@ public class DatabaseGatewayTests : IDisposable
() => gateway.DeliverBufferedAsync(message));
}
/// <summary>Verifies that a permanent SQL error during buffered delivery returns false so the message parks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliverBuffered_PermanentSqlError_ReturnsFalseSoMessageParks()
{
@@ -372,7 +392,7 @@ public class DatabaseGatewayTests : IDisposable
Assert.False(delivered); // permanent — the S&F engine parks the message
}
// ── M2.3 (#7) code-review fix: ExecuteWriteAsync must classify NON-SqlException
// ── ExecuteWriteAsync must classify NON-SqlException
// DB outages as transient (buffer+retry) and propagate cancellation —
// mirroring the HTTP path's ordered catches in InvokeHttpAsync. The pre-fix
// code only caught SqlException, so a live outage surfacing as
@@ -381,6 +401,8 @@ public class DatabaseGatewayTests : IDisposable
// buffering. These tests drive the RAW execution seam (RunSqlAsync) so the
// PRODUCTION classification in ExecuteWriteAsync runs end-to-end. ──
/// <summary>Provides the non-SQL outage exceptions that must be classified as transient.</summary>
/// <returns>The outage exceptions, one per test case.</returns>
public static IEnumerable<object[]> TransientNonSqlOutages()
{
// A live DB outage that surfaces as a non-SqlException: connection-state,
@@ -391,6 +413,9 @@ public class DatabaseGatewayTests : IDisposable
yield return new object[] { new TimeoutException("The operation has timed out.") };
}
/// <summary>Verifies that a non-SQL outage is classified as transient and buffered rather than crashing.</summary>
/// <param name="outage">The outage exception raised by the write.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[MemberData(nameof(TransientNonSqlOutages))]
public async Task CachedWrite_NonSqlOutage_ClassifiedTransient_BuffersNotCrash(Exception outage)
@@ -422,6 +447,8 @@ public class DatabaseGatewayTests : IDisposable
Assert.Equal(1, ReadBufferDepth(sfDb));
}
/// <summary>Verifies that a requested cancellation propagates OperationCanceledException and is not reclassified.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedWrite_CancellationRequested_PropagatesOperationCanceled_NotReclassified()
{
@@ -447,6 +474,8 @@ public class DatabaseGatewayTests : IDisposable
Assert.Equal(0, ReadBufferDepth(sfDb));
}
/// <summary>Verifies that an unexpected exception propagates and is not classified as transient.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedWrite_UnexpectedException_Propagates_NotClassifiedTransient()
{
@@ -468,6 +497,8 @@ public class DatabaseGatewayTests : IDisposable
Assert.Equal(0, ReadBufferDepth(sfDb));
}
/// <summary>Verifies that a non-SQL outage during buffered delivery is rethrown as transient so the engine retries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliverBuffered_NonSqlOutage_RethrowsAsTransient_SoEngineRetries()
{
@@ -495,13 +526,15 @@ public class DatabaseGatewayTests : IDisposable
() => gateway.DeliverBufferedAsync(message));
}
// ── ExternalSystemGateway-025: a caller-token cancel that surfaces from the SQL
// ── A caller-token cancel that surfaces from the SQL
// driver as a SqlException (mid-flight cancel) must propagate as
// OperationCanceledException — never reclassified as a permanent DB error.
// The fix re-checks the caller's token at the TOP of `catch (SqlException)`
// via cancellationToken.ThrowIfCancellationRequested(), so the cancel wins
// regardless of the driver's exception shape (version-independent). ──
/// <summary>Verifies that a cancellation surfacing as a SqlException propagates as canceled and is not reclassified as permanent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedWrite_CancellationSurfacingAsSqlException_PropagatesCanceled_NotReclassifiedPermanent()
{
@@ -584,6 +617,10 @@ public class DatabaseGatewayTests : IDisposable
{
private readonly Action _onExecute;
/// <summary>Initializes the stub gateway.</summary>
/// <param name="repository">The external system repository.</param>
/// <param name="storeAndForward">The store-and-forward service, or null.</param>
/// <param name="onExecute">The callback invoked when a write executes.</param>
public ExecuteStubGateway(
IExternalSystemRepository repository,
ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService? storeAndForward,
@@ -591,6 +628,7 @@ public class DatabaseGatewayTests : IDisposable
: base(repository, NullLogger<DatabaseGateway>.Instance, storeAndForward)
=> _onExecute = onExecute;
/// <inheritdoc />
internal override Task ExecuteWriteAsync(
string connectionName,
string connectionString,
@@ -617,6 +655,10 @@ public class DatabaseGatewayTests : IDisposable
{
private readonly Action _onRunSql;
/// <summary>Initializes the stub gateway.</summary>
/// <param name="repository">The external system repository.</param>
/// <param name="storeAndForward">The store-and-forward service, or null.</param>
/// <param name="onRunSql">The callback invoked when SQL is run.</param>
public RawExecuteStubGateway(
IExternalSystemRepository repository,
ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService? storeAndForward,
@@ -624,6 +666,7 @@ public class DatabaseGatewayTests : IDisposable
: base(repository, NullLogger<DatabaseGateway>.Instance, storeAndForward)
=> _onRunSql = onRunSql;
/// <inheritdoc />
internal override Task RunSqlAsync(
string connectionString,
string sql,
@@ -654,8 +697,10 @@ public class DatabaseGatewayTests : IDisposable
return result;
}
// ── ExternalSystemGateway-001: buffered CachedDbWrite delivery handler ──
// ── Buffered CachedDbWrite delivery handler ──
/// <summary>Verifies that buffered delivery returns false so the message parks when the connection no longer exists.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliverBuffered_ConnectionNoLongerExists_ReturnsFalseSoMessageParks()
{
@@ -676,14 +721,16 @@ public class DatabaseGatewayTests : IDisposable
Assert.False(delivered); // permanent — the S&F engine parks the message
}
// ── ExternalSystemGateway-018: malformed JSON payload must park, not retry-forever ──
// ── Malformed JSON payload must park, not retry-forever ──
/// <summary>Verifies that a malformed JSON payload returns false so the message parks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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
// previous 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);
@@ -701,8 +748,9 @@ public class DatabaseGatewayTests : IDisposable
Assert.False(delivered); // permanent — the S&F engine parks the message
}
// ── ExternalSystemGateway-020: decimal SQL parameter precision survives JsonElement round-trip ──
// ── Decimal SQL parameter precision survives JsonElement round-trip ──
/// <summary>Verifies that a decimal-shaped JSON number preserves its precision by being converted to a decimal.</summary>
[Fact]
public void JsonElementToParameterValue_DecimalShapedNumber_PreservesPrecisionViaDecimal()
{
@@ -710,7 +758,7 @@ public class DatabaseGatewayTests : IDisposable
// (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,
// parameter. Previously 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
@@ -724,13 +772,14 @@ public class DatabaseGatewayTests : IDisposable
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
// Asserting on the type alone is enough to fail the previous implementation (which
// produced a System.Double); the value assertion locks in the
// precision invariant.
var asDecimal = Assert.IsType<decimal>(materialised);
Assert.Equal(1234567890.1234567890m, asDecimal);
}
/// <summary>Verifies that a whole JSON number takes the fast path and returns a long.</summary>
[Fact]
public void JsonElementToParameterValue_WholeNumber_FastPathReturnsLong()
{
@@ -745,6 +794,7 @@ public class DatabaseGatewayTests : IDisposable
Assert.Equal(42L, materialised);
}
/// <summary>Verifies that a number outside the decimal range falls through to a double.</summary>
[Fact]
public void JsonElementToParameterValue_OutOfDecimalRangeNumber_FallsThroughToDouble()
{
@@ -761,8 +811,10 @@ public class DatabaseGatewayTests : IDisposable
Assert.Equal(1e40, (double)materialised);
}
// ── ExternalSystemGateway-010: SqlConnection must not leak when OpenAsync fails ──
// ── SqlConnection must not leak when OpenAsync fails ──
/// <summary>Verifies that the connection is disposed before the exception is rethrown when opening fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetConnection_OpenFails_DisposesConnectionBeforeRethrowing()
{
@@ -783,27 +835,36 @@ public class DatabaseGatewayTests : IDisposable
{
private readonly DbConnection _connection;
/// <summary>Initializes the stub gateway with the connection it will hand out.</summary>
/// <param name="repository">The external system repository.</param>
/// <param name="connection">The connection returned by the factory method.</param>
public ConnectionFactoryStubGateway(IExternalSystemRepository repository, DbConnection connection)
: base(repository, NullLogger<DatabaseGateway>.Instance) => _connection = connection;
/// <inheritdoc />
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
{
/// <summary>Gets a value indicating whether the connection was disposed.</summary>
public bool WasDisposed { get; private set; }
/// <inheritdoc />
public override Task OpenAsync(CancellationToken cancellationToken) =>
throw new InvalidOperationException("simulated open failure");
/// <inheritdoc />
public override void Open() => throw new InvalidOperationException("simulated open failure");
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing) WasDisposed = true;
base.Dispose(disposing);
}
/// <inheritdoc />
public override ValueTask DisposeAsync()
{
WasDisposed = true;
@@ -811,15 +872,24 @@ public class DatabaseGatewayTests : IDisposable
}
// Unused abstract members.
/// <inheritdoc />
[System.Diagnostics.CodeAnalysis.AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
/// <inheritdoc />
public override string Database => string.Empty;
/// <inheritdoc />
public override string DataSource => string.Empty;
/// <inheritdoc />
public override string ServerVersion => string.Empty;
/// <inheritdoc />
public override ConnectionState State => ConnectionState.Closed;
/// <inheritdoc />
public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();
/// <inheritdoc />
public override void Close() { }
/// <inheritdoc />
protected override DbTransaction BeginDbTransaction(IsolationLevel il) => throw new NotSupportedException();
/// <inheritdoc />
protected override DbCommand CreateDbCommand() => throw new NotSupportedException();
}
}
@@ -3,12 +3,15 @@ using System.Net;
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
/// <summary>
/// WP-8: Tests for HTTP error classification.
/// Tests for HTTP error classification.
/// Transient: connection refused, timeout, HTTP 408/429/5xx.
/// Permanent: HTTP 4xx (except 408/429).
/// </summary>
public class ErrorClassifierTests
{
/// <summary>Verifies that transient HTTP status codes are classified as transient.</summary>
/// <param name="statusCode">The test input.</param>
/// <param name="expectedTransient">The test input.</param>
[Theory]
[InlineData(HttpStatusCode.InternalServerError, true)]
[InlineData(HttpStatusCode.BadGateway, true)]
@@ -21,6 +24,9 @@ public class ErrorClassifierTests
Assert.Equal(expectedTransient, ErrorClassifier.IsTransient(statusCode));
}
/// <summary>Verifies that permanent HTTP status codes are classified as permanent.</summary>
/// <param name="statusCode">The test input.</param>
/// <param name="expectedTransient">The test input.</param>
[Theory]
[InlineData(HttpStatusCode.BadRequest, false)]
[InlineData(HttpStatusCode.Unauthorized, false)]
@@ -33,30 +39,35 @@ public class ErrorClassifierTests
Assert.Equal(expectedTransient, ErrorClassifier.IsTransient(statusCode));
}
/// <summary>Verifies that an HttpRequestException is classified as transient.</summary>
[Fact]
public void HttpRequestException_IsTransient()
{
Assert.True(ErrorClassifier.IsTransient(new HttpRequestException("Connection refused")));
}
/// <summary>Verifies that a TaskCanceledException is classified as transient.</summary>
[Fact]
public void TaskCanceledException_IsTransient()
{
Assert.True(ErrorClassifier.IsTransient(new TaskCanceledException("Timeout")));
}
/// <summary>Verifies that a TimeoutException is classified as transient.</summary>
[Fact]
public void TimeoutException_IsTransient()
{
Assert.True(ErrorClassifier.IsTransient(new TimeoutException()));
}
/// <summary>Verifies that a generic exception is not classified as transient.</summary>
[Fact]
public void GenericException_IsNotTransient()
{
Assert.False(ErrorClassifier.IsTransient(new InvalidOperationException("bad input")));
}
/// <summary>Verifies that AsTransient creates the expected exception.</summary>
[Fact]
public void AsTransient_CreatesCorrectException()
{
@@ -65,6 +76,7 @@ public class ErrorClassifierTests
Assert.Equal("test message", ex.Message);
}
/// <summary>Verifies that an OperationCanceledException caused by the caller's cancellation is not classified as transient.</summary>
[Fact]
public void IsTransient_CallerCancelledOce_IsNotTransient()
{
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
/// <summary>
/// WP-6/7: Tests for ExternalSystemClient — HTTP client, call modes, error handling.
/// Tests for ExternalSystemClient — HTTP client, call modes, error handling.
/// </summary>
public class ExternalSystemClientTests : IDisposable
{
@@ -57,7 +57,7 @@ public class ExternalSystemClientTests : IDisposable
/// <summary>
/// Configures the repository substitute for the name-keyed resolution path used by
/// <c>ExternalSystemClient</c> (ExternalSystemGateway-011). A <c>null</c> system or
/// <c>ExternalSystemClient</c>. A <c>null</c> system or
/// method models a "not found" — the substitute returns <c>null</c> by default, so
/// no stub is needed for the absent entity.
/// </summary>
@@ -76,6 +76,8 @@ public class ExternalSystemClientTests : IDisposable
}
}
/// <summary>Verifies that calling an unknown system returns an error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_SystemNotFound_ReturnsError()
{
@@ -91,6 +93,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("not found", result.ErrorMessage);
}
/// <summary>Verifies that calling an unknown method returns an error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_MethodNotFound_ReturnsError()
{
@@ -107,6 +111,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("not found", result.ErrorMessage);
}
/// <summary>Verifies that a successful HTTP call returns the response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_SuccessfulHttp_ReturnsResponse()
{
@@ -129,6 +135,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("42", result.ResponseJson!);
}
/// <summary>Verifies that an HTTP 500 response is returned as a transient error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_Transient500_ReturnsTransientError()
{
@@ -151,6 +159,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("Transient error", result.ErrorMessage);
}
/// <summary>Verifies that an HTTP 400 response is returned as a permanent error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_Permanent400_ReturnsPermanentError()
{
@@ -173,6 +183,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("Permanent error", result.ErrorMessage);
}
/// <summary>Verifies that a cached call to an unknown system returns an error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedCall_SystemNotFound_ReturnsError()
{
@@ -188,6 +200,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("not found", result.ErrorMessage);
}
/// <summary>Verifies that a successful cached call returns its response directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedCall_Success_ReturnsDirectly()
{
@@ -210,7 +224,7 @@ public class ExternalSystemClientTests : IDisposable
Assert.False(result.WasBuffered);
}
// ── ExternalSystemGateway-001: buffered-call delivery handler ──
// ── buffered-call delivery handler ──
private static ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardMessage BufferedCall(
string systemName, string methodName) =>
@@ -223,6 +237,8 @@ public class ExternalSystemClientTests : IDisposable
$$"""{"SystemName":"{{systemName}}","MethodName":"{{methodName}}","Parameters":null}""",
};
/// <summary>Verifies that delivering a buffered message over a successful HTTP call returns true.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliverBuffered_SuccessfulHttp_ReturnsTrue()
{
@@ -241,6 +257,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.True(delivered);
}
/// <summary>Verifies that delivering a buffered message returns false when its system no longer exists, so the message parks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliverBuffered_SystemNoLongerExists_ReturnsFalseSoMessageParks()
{
@@ -254,6 +272,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.False(delivered); // permanent — the S&F engine parks the message
}
/// <summary>Verifies that delivering a buffered message that gets an HTTP 500 throws so the engine retries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliverBuffered_Transient500_ThrowsSoEngineRetries()
{
@@ -271,14 +291,16 @@ public class ExternalSystemClientTests : IDisposable
() => client.DeliverBufferedAsync(BufferedCall("TestAPI", "failMethod")));
}
// ── ExternalSystemGateway-018: malformed JSON payload must park, not retry-forever ──
// ── malformed JSON payload must park, not retry-forever ──
/// <summary>Verifies that delivering a buffered message with a malformed JSON payload returns false, so the message parks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliverBuffered_MalformedJsonPayload_ReturnsFalseSoMessageParks()
{
// No repository / HTTP stubs needed — deserialization fails before any
// resolution or HTTP call. If the JsonException were to escape (the
// pre-018 behaviour) the S&F engine would treat it as transient and
// previous behaviour) the S&F engine would treat it as transient and
// retry the same poison row forever.
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
@@ -297,8 +319,10 @@ public class ExternalSystemClientTests : IDisposable
Assert.False(delivered); // permanent — the S&F engine parks the message
}
// ── ExternalSystemGateway-003: CachedCall must not double-dispatch ──
// ── CachedCall must not double-dispatch ──
/// <summary>Verifies that a cached call transient failure is not immediately redispatched through the registered handler.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedCall_TransientFailure_DoesNotImmediatelyRedispatchViaRegisteredHandler()
{
@@ -339,8 +363,10 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal(0, handlerInvocations);
}
// ── ExternalSystemGateway-002: per-system call timeout ──
// ── per-system call timeout ──
/// <summary>Verifies that a call to a slow system times out as a transient error within the configured window.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_SlowSystem_TimesOutAsTransientErrorWithinConfiguredWindow()
{
@@ -372,7 +398,7 @@ public class ExternalSystemClientTests : IDisposable
}
/// <summary>
/// ExternalSystemGateway-019 regression: <see cref="HttpClient.Timeout"/> defaults
/// Regression: <see cref="HttpClient.Timeout"/> defaults
/// to 100 seconds and is enforced internally by <c>SendAsync</c> via its own
/// private CTS — a <see cref="TaskCanceledException"/> raised by that internal
/// CTS does not trip either the caller's token or the gateway's timeout CTS,
@@ -383,6 +409,7 @@ public class ExternalSystemClientTests : IDisposable
/// <c>InvokeHttpAsync</c> is the sole timeout source. This test verifies the
/// property is in fact set before any request is dispatched.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_DisablesHttpClientFrameworkTimeoutSoLongTimeoutsArentClipped()
{
@@ -414,6 +441,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal(Timeout.InfiniteTimeSpan, httpClient.Timeout);
}
/// <summary>Verifies that caller cancellation of a call is not misreported as a timeout.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_CallerCancellation_IsNotMisreportedAsTimeout()
{
@@ -438,8 +467,10 @@ public class ExternalSystemClientTests : IDisposable
() => client.CallAsync("TestAPI", "getData", cancellationToken: cts.Token));
}
// ── ExternalSystemGateway-004: per-system retry settings honoured for cached calls ──
// ── per-system retry settings honoured for cached calls ──
/// <summary>Verifies that a cached call transient failure is buffered with the system's retry settings.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedCall_TransientFailure_BuffersWithSystemRetrySettings()
{
@@ -471,7 +502,7 @@ public class ExternalSystemClientTests : IDisposable
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance,
storeAndForward: sf);
// Audit Log #23 (ExecutionId Task 4): a known execution id / source
// ExecutionId: a known execution id / source
// script so the gateway -> EnqueueAsync hop can be asserted below.
var executionId = Guid.NewGuid();
const string sourceScript = "ScriptActor:CheckPressure";
@@ -488,7 +519,7 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal(7, buffered.MaxRetries);
Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, buffered.RetryIntervalMs);
// ExecutionId Task 4: the gateway must forward executionId / sourceScript
// ExecutionId: 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);
@@ -514,10 +545,12 @@ public class ExternalSystemClientTests : IDisposable
return result;
}
/// <summary>Verifies that a zero max-retries value on a cached call transient failure is treated as unset rather than as retry forever.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedCall_TransientFailure_ZeroMaxRetriesIsTreatedAsUnsetNotRetryForever()
{
// ExternalSystemGateway-015: the Store-and-Forward engine interprets a stored
// the Store-and-Forward engine interprets a stored
// MaxRetries of 0 as "no limit" (retry forever) — see StoreAndForwardMessage.cs
// and the retry-sweep guard `MaxRetries > 0 && ...`. The entity's non-nullable
// int default is also 0, so passing 0 verbatim would buffer every cached call
@@ -558,8 +591,10 @@ public class ExternalSystemClientTests : IDisposable
Assert.NotEqual(0, maxRetries);
}
// ── ExternalSystemGateway-005: HttpRequestMessage / HttpResponseMessage disposal ──
// ── HttpRequestMessage / HttpResponseMessage disposal ──
/// <summary>Verifies that a successful HTTP call disposes the request and the response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_SuccessfulHttp_DisposesRequestAndResponse()
{
@@ -580,6 +615,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.True(handler.ResponseContentDisposed, "HttpResponseMessage content was not disposed");
}
/// <summary>Verifies that a permanent failure still disposes the request and the response.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_PermanentFailure_StillDisposesRequestAndResponse()
{
@@ -600,8 +637,10 @@ public class ExternalSystemClientTests : IDisposable
Assert.True(handler.ResponseContentDisposed, "HttpResponseMessage content was not disposed on the error path");
}
// ── ExternalSystemGateway-006: BuildUrl — empty path must not append a trailing slash ──
// ── BuildUrl — empty path must not append a trailing slash ──
/// <summary>Verifies that a method with an empty path does not append a trailing slash to the URL.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_MethodWithEmptyPath_DoesNotAppendTrailingSlash()
{
@@ -621,6 +660,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal("https://api.example.com/api", handler.LastUri!.ToString());
}
/// <summary>Verifies that a method with a path builds the expected URL.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_MethodWithPath_BuildsExpectedUrl()
{
@@ -640,8 +681,10 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal("https://api.example.com/api/data", handler.LastUri!.ToString());
}
// ── ExternalSystemGateway-007: external error body must be truncated, not echoed verbatim ──
// ── external error body must be truncated, not echoed verbatim ──
/// <summary>Verifies that a permanent failure with a huge error body truncates the error message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_PermanentFailureWithHugeErrorBody_TruncatesErrorMessage()
{
@@ -666,8 +709,10 @@ public class ExternalSystemClientTests : IDisposable
$"Error message was {result.ErrorMessage.Length} chars — expected it to be truncated");
}
// ── ExternalSystemGateway-008: cancellation of a CachedCall must not be buffered ──
// ── cancellation of a CachedCall must not be buffered ──
/// <summary>Verifies that caller cancellation of a cached call is not buffered as a transient failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CachedCall_CallerCancellation_IsNotBufferedAsTransient()
{
@@ -706,8 +751,10 @@ public class ExternalSystemClientTests : IDisposable
"A caller-cancelled CachedCall must not be buffered for retry");
}
// ── ExternalSystemGateway-014: BuildUrl query-string, ApplyAuth, connection errors ──
// ── BuildUrl query-string, ApplyAuth, connection errors ──
/// <summary>Verifies that a GET call with parameters appends an escaped query string.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_GetWithParameters_AppendsEscapedQueryString()
{
@@ -735,10 +782,12 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("page=2", uri);
}
/// <summary>Verifies that a GET call whose parameters are all null does not append a trailing question mark.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_GetWithAllNullParameters_DoesNotAppendTrailingQuestionMark()
{
// ExternalSystemGateway-017: a GET method invoked with a non-empty parameter
// a GET method invoked with a non-empty parameter
// dictionary whose values are all null has an effectively empty query string.
// The URL must be identical to the no-parameters case — no bare trailing '?'.
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
@@ -762,6 +811,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.DoesNotContain("?", uri);
}
/// <summary>Verifies that a POST call with parameters sends a JSON body.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_PostWithParameters_SendsJsonBody()
{
@@ -781,6 +832,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("\"name\":\"widget\"", handler.LastBody);
}
/// <summary>Verifies that API key auth with the default header sends the X-Api-Key header.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_ApiKeyAuthWithDefaultHeader_SendsXApiKeyHeader()
{
@@ -804,6 +857,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal("secret-key-123", values!.Single());
}
/// <summary>Verifies that API key auth with a custom header name sends that named header.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_ApiKeyAuthWithCustomHeader_SendsNamedHeader()
{
@@ -827,6 +882,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal("abc", values!.Single());
}
/// <summary>Verifies that basic auth sends a Base64-encoded Authorization header.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_BasicAuth_SendsBase64AuthorizationHeader()
{
@@ -853,6 +910,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal("alice:s3cret", decoded);
}
/// <summary>Verifies that a JSON API key auth config sets the configured header and preserves colons in the key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeyAuth_JsonConfig_SetsConfiguredHeaderAndKeepsColons()
{
@@ -876,11 +935,13 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal("ab:cd:ef", values!.Single());
}
/// <summary>Verifies that a JSON API key auth config without a header name uses the default header.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeyAuth_JsonConfig_DefaultHeader()
{
// The UI-placeholder shape ({"key":"xyz"}) — silently misparsed by the
// legacy colon-split before Task 15 (it produced header name '{"key').
// legacy colon-split (it produced header name '{"key').
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
{
Id = 1,
@@ -901,6 +962,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal("xyz", values!.Single());
}
/// <summary>Verifies that a JSON basic auth config encodes the username and password.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BasicAuth_JsonConfig_EncodesUsernamePassword()
{
@@ -927,6 +990,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal("u:p:w", decoded);
}
/// <summary>Verifies that the legacy colon-delimited API key auth config still works.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeyAuth_LegacyColonConfig_StillWorks()
{
@@ -952,6 +1017,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Equal("secret", values!.Single());
}
/// <summary>Verifies that a connection error is classified as transient.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_ConnectionError_IsClassifiedAsTransient()
{
@@ -972,8 +1039,10 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("Transient error", result.ErrorMessage);
}
// ── ExternalSystemGateway-012: permanent failures must be logged ──
// ── permanent failures must be logged ──
/// <summary>Verifies that a permanent failure logs a warning.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_PermanentFailure_LogsAWarning()
{
@@ -996,6 +1065,8 @@ public class ExternalSystemClientTests : IDisposable
e.Level >= LogLevel.Warning && e.Message.Contains("TestAPI"));
}
/// <summary>Verifies that a per-system timeout overrides the gateway default.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PerSystemTimeout_OverridesGatewayDefault()
{
@@ -1023,6 +1094,8 @@ public class ExternalSystemClientTests : IDisposable
"per-system 1s timeout must apply, not the 30s default");
}
/// <summary>Verifies that a transient failure does not log at warning level or above.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_TransientFailure_DoesNotLogAtWarningOrAbove()
{
@@ -1047,11 +1120,26 @@ public class ExternalSystemClientTests : IDisposable
/// <summary>Test helper: an ILogger that records every entry for assertions.</summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
/// <summary>Gets the entries value.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = new();
/// <summary>Starts no scope; the fake does not track scopes.</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state, ignored.</param>
/// <returns>No scope token; the fake does not track scopes.</returns>
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
/// <summary>Reports every log level as enabled.</summary>
/// <param name="logLevel">The log level being queried.</param>
/// <returns>Always <see langword="true"/>.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The severity of the entry.</param>
/// <param name="eventId">The event id of the entry.</param>
/// <param name="state">The state object being logged.</param>
/// <param name="exception">The exception associated with the entry, if any.</param>
/// <param name="formatter">Formats the state and exception into the message text.</param>
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
@@ -1062,12 +1150,15 @@ public class ExternalSystemClientTests : IDisposable
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
/// <summary>Does nothing; the scope holds no resources.</summary>
public void Dispose() { }
}
}
// ── ESG: bound outbound response buffering (MaxResponseBodyBytes) ──
/// <summary>Verifies that an oversized response body fails permanently without buffering the whole body.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OversizedResponseBody_FailsPermanently_WithoutBufferingWholeBody()
{
@@ -1124,12 +1215,16 @@ public class ExternalSystemClientTests : IDisposable
private readonly HttpStatusCode _statusCode;
private readonly string _body;
/// <summary>Initializes a new instance of the <c>MockHttpMessageHandler</c> class.</summary>
/// <param name="statusCode">The status code.</param>
/// <param name="body">The body.</param>
public MockHttpMessageHandler(HttpStatusCode statusCode, string body)
{
_statusCode = statusCode;
_body = body;
}
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(_statusCode)
@@ -1147,15 +1242,21 @@ public class ExternalSystemClientTests : IDisposable
private readonly HttpStatusCode _statusCode;
private readonly string _body;
/// <summary>Initializes a new instance of the <c>DisposalTrackingHandler</c> class.</summary>
/// <param name="statusCode">The status code.</param>
/// <param name="body">The body.</param>
public DisposalTrackingHandler(HttpStatusCode statusCode, string body)
{
_statusCode = statusCode;
_body = body;
}
/// <summary>Gets the request disposed value.</summary>
public bool RequestDisposed { get; private set; }
/// <summary>Gets the response content disposed value.</summary>
public bool ResponseContentDisposed { get; private set; }
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Content = new TrackingContent(string.Empty, () => RequestDisposed = true);
@@ -1169,8 +1270,12 @@ public class ExternalSystemClientTests : IDisposable
private sealed class TrackingContent : StringContent
{
private readonly Action _onDispose;
/// <summary>Initializes a new instance of the <c>TrackingContent</c> class.</summary>
/// <param name="content">The content.</param>
/// <param name="onDispose">The on dispose.</param>
public TrackingContent(string content, Action onDispose) : base(content) => _onDispose = onDispose;
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing) _onDispose();
@@ -1187,16 +1292,23 @@ public class ExternalSystemClientTests : IDisposable
private readonly HttpStatusCode _statusCode;
private readonly string _body;
/// <summary>Initializes a new instance of the <c>RequestCapturingHandler</c> class.</summary>
/// <param name="statusCode">The status code.</param>
/// <param name="body">The body.</param>
public RequestCapturingHandler(HttpStatusCode statusCode, string body)
{
_statusCode = statusCode;
_body = body;
}
/// <summary>Gets the last uri value.</summary>
public Uri? LastUri { get; private set; }
/// <summary>Gets the last headers value.</summary>
public HttpRequestHeaders? LastHeaders { get; private set; }
/// <summary>Gets the last body value.</summary>
public string? LastBody { get; private set; }
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastUri = request.RequestUri;
@@ -1213,8 +1325,11 @@ public class ExternalSystemClientTests : IDisposable
private class ThrowingHttpMessageHandler : HttpMessageHandler
{
private readonly Exception _exception;
/// <summary>Initializes a new instance of the <c>ThrowingHttpMessageHandler</c> class.</summary>
/// <param name="exception">The exception.</param>
public ThrowingHttpMessageHandler(Exception exception) => _exception = exception;
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> throw _exception;
}
@@ -1226,8 +1341,11 @@ public class ExternalSystemClientTests : IDisposable
{
private readonly TimeSpan _delay;
/// <summary>Initializes a new instance of the <c>HangingHttpMessageHandler</c> class.</summary>
/// <param name="delay">The delay.</param>
public HangingHttpMessageHandler(TimeSpan delay) => _delay = delay;
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(_delay, cancellationToken);
@@ -1236,11 +1354,13 @@ public class ExternalSystemClientTests : IDisposable
}
/// <summary>
/// ExternalSystemGateway-022: an HTTP method outside the documented allowlist
/// an HTTP method outside the documented allowlist
/// (GET/POST/PUT/PATCH/DELETE) must be rejected at the gateway entry with a
/// clear <see cref="ArgumentException"/> — not silently dispatched as a
/// non-standard verb whose parameters land in neither body nor query string.
/// </summary>
/// <param name="httpMethod">The unsupported HTTP method name.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("FOO")]
[InlineData("DLETE")] // common typo for DELETE
@@ -1265,6 +1385,9 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("GET", ex.Message);
}
/// <summary>Verifies that a call using a documented HTTP method is accepted.</summary>
/// <param name="httpMethod">The http method.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("GET")]
[InlineData("get")] // case-insensitive — operator-authored strings
@@ -1291,6 +1414,8 @@ public class ExternalSystemClientTests : IDisposable
// ── ExternalSystemGateway: {param} path-template substitution ──
/// <summary>Verifies that a path template substitutes the parameter and consumes it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PathTemplate_SubstitutesAndConsumesParameter()
{
@@ -1319,6 +1444,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.DoesNotContain("id=", uri);
}
/// <summary>Verifies that in a POST with a path template, the consumed parameter is excluded from the body.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PathTemplate_Post_ConsumedParamExcludedFromBody()
{
@@ -1345,6 +1472,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.DoesNotContain("orderId", handler.LastBody);
}
/// <summary>Verifies that a path template with a missing parameter throws a clear error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PathTemplate_MissingParameter_ThrowsClearError()
{
@@ -1364,6 +1493,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("id", ex.Message);
}
/// <summary>Verifies that delivering a buffered message whose path template is now unresolvable returns false, so the message parks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliverBuffered_PathTemplateNowUnresolvable_ReturnsFalseSoMessageParks()
{
@@ -1385,6 +1516,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.False(delivered);
}
/// <summary>Verifies that delivering a buffered message whose stored method has an unsupported verb returns false, so the message parks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliverBuffered_UnsupportedVerbOnStoredMethod_ReturnsFalseSoMessageParks()
{
@@ -1400,6 +1533,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.False(delivered);
}
/// <summary>Verifies that a response declaring the Latin1 charset is decoded using that charset.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_Latin1Charset_DecodesUsingDeclaredCharset()
{
@@ -1422,6 +1557,8 @@ public class ExternalSystemClientTests : IDisposable
Assert.Contains("température 25°C", result.ResponseJson);
}
/// <summary>Verifies that a response with an unknown charset falls back to UTF-8 decoding.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Call_UnknownCharset_FallsBackToUtf8()
{
@@ -1444,6 +1581,7 @@ public class ExternalSystemClientTests : IDisposable
private sealed class ByteBodyHandler(
HttpStatusCode statusCode, byte[] body, string contentType) : HttpMessageHandler
{
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
@@ -13,6 +13,7 @@ public class ExternalSystemGatewayOptionsValidatorTests
private static ValidateOptionsResult Validate(ExternalSystemGatewayOptions options) =>
new ExternalSystemGatewayOptionsValidator().Validate(Options.DefaultName, options);
/// <summary>Verifies that the default options are valid.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -20,6 +21,7 @@ public class ExternalSystemGatewayOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a zero default HTTP timeout is rejected.</summary>
[Fact]
public void ZeroDefaultHttpTimeout_IsRejected()
{
@@ -29,6 +31,7 @@ public class ExternalSystemGatewayOptionsValidatorTests
Assert.Contains("DefaultHttpTimeout", result.FailureMessage);
}
/// <summary>Verifies that a negative default HTTP timeout is rejected.</summary>
[Fact]
public void NegativeDefaultHttpTimeout_IsRejected()
{
@@ -38,6 +41,7 @@ public class ExternalSystemGatewayOptionsValidatorTests
Assert.Contains("DefaultHttpTimeout", result.FailureMessage);
}
/// <summary>Verifies that a zero maximum of concurrent connections per system is rejected.</summary>
[Fact]
public void ZeroMaxConcurrentConnectionsPerSystem_IsRejected()
{
@@ -47,6 +51,7 @@ public class ExternalSystemGatewayOptionsValidatorTests
Assert.Contains("MaxConcurrentConnectionsPerSystem", result.FailureMessage);
}
/// <summary>Verifies that a negative maximum of concurrent connections per system is rejected.</summary>
[Fact]
public void NegativeMaxConcurrentConnectionsPerSystem_IsRejected()
{
@@ -56,6 +61,7 @@ public class ExternalSystemGatewayOptionsValidatorTests
Assert.Contains("MaxConcurrentConnectionsPerSystem", result.FailureMessage);
}
/// <summary>Verifies that a zero maximum response body size is rejected.</summary>
[Fact]
public void ZeroMaxResponseBodyBytes_IsRejected()
{
@@ -7,11 +7,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
/// <summary>
/// ExternalSystemGateway-013: configuration options must actually influence the
/// Configuration options must actually influence the
/// registered HTTP client — an operator setting them must not be silently ignored.
/// </summary>
public class ServiceWiringTests
{
/// <summary>Verifies that the per-system connection cap is applied to the primary handler of the named HTTP client.</summary>
[Fact]
public void MaxConcurrentConnectionsPerSystem_IsAppliedToTheNamedHttpClientPrimaryHandler()
{
@@ -40,10 +41,11 @@ public class ServiceWiringTests
Assert.Equal(4, sockets.MaxConnectionsPerServer);
}
/// <summary>Verifies that the per-system connection cap is not applied to HTTP clients that do not belong to the gateway.</summary>
[Fact]
public void MaxConcurrentConnectionsPerSystem_IsNotAppliedToNonGatewayHttpClients()
{
// ExternalSystemGateway-016: the gateway's connection cap must be scoped to
// 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
@@ -3,7 +3,7 @@ using System.Data.Common;
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
/// <summary>
/// M2.3 (#7): unit tests for the transient-vs-permanent SQL error-number
/// Unit tests for the transient-vs-permanent SQL error-number
/// classifier that <c>DatabaseGateway</c> uses to decide whether a failed
/// cached write should be buffered (transient) or returned to the script
/// synchronously / parked (permanent).
@@ -13,6 +13,8 @@ public class SqlErrorClassifierTests
// The full transient set documented on SqlErrorClassifier — connection,
// timeout, deadlock, and Azure throttle error numbers. A retry can plausibly
// succeed for any of these, so they are buffered to store-and-forward.
/// <summary>Verifies that a known transient error number is classified as transient.</summary>
/// <param name="errorNumber">The SQL error number.</param>
[Theory]
[InlineData(-2)] // timeout expired
[InlineData(-1)] // connection error
@@ -37,6 +39,8 @@ public class SqlErrorClassifierTests
// Constraint, syntax, and permission errors are permanent — retrying the
// identical statement cannot succeed and may cause duplicate side effects.
/// <summary>Verifies that a known permanent error number is not classified as transient.</summary>
/// <param name="errorNumber">The SQL error number.</param>
[Theory]
[InlineData(547)] // constraint violation (FK/CHECK)
[InlineData(2627)] // primary-key / unique constraint violation
@@ -53,6 +57,8 @@ public class SqlErrorClassifierTests
Assert.False(SqlErrorClassifier.IsTransient(errorNumber));
}
/// <summary>Verifies that an unknown error number defaults to permanent.</summary>
/// <param name="errorNumber">The SQL error number.</param>
[Theory]
[InlineData(0)] // no error number captured
[InlineData(99999)] // unknown / undocumented number
@@ -65,11 +71,13 @@ public class SqlErrorClassifierTests
Assert.False(SqlErrorClassifier.IsTransient(errorNumber));
}
// ── M2.3 (#7) code-review fix: IsTransient(Exception) — a live DB outage does
// ── IsTransient(Exception) — a live DB outage does
// not always surface as a SqlException. Transport/connection/timeout/driver
// exception types are transient (buffer+retry), mirroring the HTTP path's
// ErrorClassifier.IsTransient(Exception). ──
/// <summary>Provides exception instances of the transport types that count as transient.</summary>
/// <returns>Theory data with one exception instance per row.</returns>
public static IEnumerable<object[]> TransientExceptionTypes()
{
yield return new object[] { new InvalidOperationException("connection not open") };
@@ -81,6 +89,8 @@ public class SqlErrorClassifierTests
yield return new object[] { new NonSqlDbException("provider transport error") };
}
/// <summary>Verifies that transport exception types are classified as transient.</summary>
/// <param name="ex">The exception to classify.</param>
[Theory]
[MemberData(nameof(TransientExceptionTypes))]
public void IsTransient_Exception_TrueForTransportTypes(Exception ex)
@@ -88,6 +98,7 @@ public class SqlErrorClassifierTests
Assert.True(SqlErrorClassifier.IsTransient(ex));
}
/// <summary>Verifies that an unexpected exception type is not classified as transient.</summary>
[Fact]
public void IsTransient_Exception_FalseForUnexpectedType()
{
@@ -100,6 +111,8 @@ public class SqlErrorClassifierTests
/// <summary>A concrete <see cref="DbException"/> that is not a SqlException, for the classifier unit test.</summary>
private sealed class NonSqlDbException : DbException
{
/// <summary>Initializes a database exception that is not a SQL exception.</summary>
/// <param name="message">The exception message.</param>
public NonSqlDbException(string message) : base(message) { }
}
}