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:
@@ -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()
|
||||
{
|
||||
|
||||
+159
-21
@@ -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)
|
||||
{
|
||||
|
||||
+6
@@ -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) { }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user