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
@@ -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)
{