949 lines
41 KiB
C#
949 lines
41 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.StoreAndForward;
|
|
|
|
namespace ScadaLink.ExternalSystemGateway.Tests;
|
|
|
|
/// <summary>
|
|
/// WP-6/7: Tests for ExternalSystemClient — HTTP client, call modes, error handling.
|
|
/// </summary>
|
|
public class ExternalSystemClientTests
|
|
{
|
|
private readonly IExternalSystemRepository _repository = Substitute.For<IExternalSystemRepository>();
|
|
private readonly IHttpClientFactory _httpClientFactory = Substitute.For<IHttpClientFactory>();
|
|
|
|
/// <summary>
|
|
/// Configures the repository substitute for the name-keyed resolution path used by
|
|
/// <c>ExternalSystemClient</c> (ExternalSystemGateway-011). 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>
|
|
private void StubResolution(ExternalSystemDefinition? system, ExternalSystemMethod? method)
|
|
{
|
|
if (system != null)
|
|
{
|
|
_repository.GetExternalSystemByNameAsync(system.Name, Arg.Any<CancellationToken>())
|
|
.Returns(system);
|
|
}
|
|
|
|
if (system != null && method != null)
|
|
{
|
|
_repository.GetMethodByNameAsync(system.Id, method.Name, Arg.Any<CancellationToken>())
|
|
.Returns(method);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_SystemNotFound_ReturnsError()
|
|
{
|
|
StubResolution(system: null, method: null);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository,
|
|
NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var result = await client.CallAsync("nonexistent", "method");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("not found", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_MethodNotFound_ReturnsError()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
StubResolution(system, method: null);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository,
|
|
NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var result = await client.CallAsync("TestAPI", "missingMethod");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("not found", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_SuccessfulHttp_ReturnsResponse()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
|
|
StubResolution(system, method);
|
|
|
|
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"result\": 42}");
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository,
|
|
NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var result = await client.CallAsync("TestAPI", "getData");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Contains("42", result.ResponseJson!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_Transient500_ReturnsTransientError()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
|
|
StubResolution(system, method);
|
|
|
|
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "server error");
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository,
|
|
NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var result = await client.CallAsync("TestAPI", "failMethod");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Transient error", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_Permanent400_ReturnsPermanentError()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
|
|
StubResolution(system, method);
|
|
|
|
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository,
|
|
NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var result = await client.CallAsync("TestAPI", "badMethod");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Permanent error", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedCall_SystemNotFound_ReturnsError()
|
|
{
|
|
StubResolution(system: null, method: null);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository,
|
|
NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var result = await client.CachedCallAsync("nonexistent", "method");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("not found", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedCall_Success_ReturnsDirectly()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
|
|
StubResolution(system, method);
|
|
|
|
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\": true}");
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository,
|
|
NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var result = await client.CachedCallAsync("TestAPI", "getData");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.False(result.WasBuffered);
|
|
}
|
|
|
|
// ── ExternalSystemGateway-001: buffered-call delivery handler ──
|
|
|
|
private static ScadaLink.StoreAndForward.StoreAndForwardMessage BufferedCall(
|
|
string systemName, string methodName) =>
|
|
new()
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
Category = ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem,
|
|
Target = systemName,
|
|
PayloadJson =
|
|
$$"""{"SystemName":"{{systemName}}","MethodName":"{{methodName}}","Parameters":null}""",
|
|
};
|
|
|
|
[Fact]
|
|
public async Task DeliverBuffered_SuccessfulHttp_ReturnsTrue()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\":true}"));
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var delivered = await client.DeliverBufferedAsync(BufferedCall("TestAPI", "getData"));
|
|
|
|
Assert.True(delivered);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeliverBuffered_SystemNoLongerExists_ReturnsFalseSoMessageParks()
|
|
{
|
|
StubResolution(system: null, method: null);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var delivered = await client.DeliverBufferedAsync(BufferedCall("GoneAPI", "method"));
|
|
|
|
Assert.False(delivered); // permanent — the S&F engine parks the message
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeliverBuffered_Transient500_ThrowsSoEngineRetries()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await Assert.ThrowsAsync<TransientExternalSystemException>(
|
|
() => client.DeliverBufferedAsync(BufferedCall("TestAPI", "failMethod")));
|
|
}
|
|
|
|
// ── ExternalSystemGateway-003: CachedCall must not double-dispatch ──
|
|
|
|
[Fact]
|
|
public async Task CachedCall_TransientFailure_DoesNotImmediatelyRedispatchViaRegisteredHandler()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
// The HTTP layer always fails transiently (500).
|
|
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
// A real S&F service with a registered delivery handler that counts invocations.
|
|
var dbName = $"EsgDoubleDispatch_{Guid.NewGuid():N}";
|
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
|
using var keepAlive = new SqliteConnection(connStr);
|
|
keepAlive.Open();
|
|
var storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
|
|
await storage.InitializeAsync();
|
|
var sfOptions = new StoreAndForwardOptions
|
|
{
|
|
DefaultRetryInterval = TimeSpan.FromMinutes(10),
|
|
RetryTimerInterval = TimeSpan.FromMinutes(10),
|
|
};
|
|
var sf = new StoreAndForwardService(storage, sfOptions, NullLogger<StoreAndForwardService>.Instance);
|
|
|
|
var handlerInvocations = 0;
|
|
sf.RegisterDeliveryHandler(
|
|
ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem,
|
|
_ => { Interlocked.Increment(ref handlerInvocations); return Task.FromResult(false); });
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository,
|
|
NullLogger<ExternalSystemClient>.Instance,
|
|
storeAndForward: sf);
|
|
|
|
var result = await client.CachedCallAsync("TestAPI", "postData");
|
|
|
|
// The call already made one HTTP attempt; EnqueueAsync must NOT invoke the
|
|
// registered handler again synchronously (which would dispatch a 2nd request).
|
|
Assert.True(result.WasBuffered);
|
|
Assert.Equal(0, handlerInvocations);
|
|
}
|
|
|
|
// ── ExternalSystemGateway-002: per-system call timeout ──
|
|
|
|
[Fact]
|
|
public async Task Call_SlowSystem_TimesOutAsTransientErrorWithinConfiguredWindow()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
// Handler that hangs far longer than the configured timeout and the test budget.
|
|
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
// Configure a short timeout so the call must fail quickly.
|
|
var options = new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromMilliseconds(200) };
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository,
|
|
NullLogger<ExternalSystemClient>.Instance,
|
|
options: Microsoft.Extensions.Options.Options.Create(options));
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
var result = await client.CallAsync("TestAPI", "getData");
|
|
sw.Stop();
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Transient error", result.ErrorMessage);
|
|
Assert.Contains("Timeout", result.ErrorMessage);
|
|
// Must fail near the configured 200ms, well before HttpClient's default 100s.
|
|
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10),
|
|
$"Call took {sw.Elapsed}, expected to time out near the configured 200ms window");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_CallerCancellation_IsNotMisreportedAsTimeout()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var options = new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromMinutes(5) };
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository,
|
|
NullLogger<ExternalSystemClient>.Instance,
|
|
options: Microsoft.Extensions.Options.Options.Create(options));
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
|
|
|
|
// Caller-initiated cancellation must surface as OperationCanceledException,
|
|
// not be swallowed as a transient timeout error.
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => client.CallAsync("TestAPI", "getData", cancellationToken: cts.Token));
|
|
}
|
|
|
|
// ── ExternalSystemGateway-004: per-system retry settings honoured for cached calls ──
|
|
|
|
[Fact]
|
|
public async Task CachedCall_TransientFailure_BuffersWithSystemRetrySettings()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none")
|
|
{
|
|
Id = 1,
|
|
MaxRetries = 7,
|
|
RetryDelay = TimeSpan.FromSeconds(42),
|
|
};
|
|
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var dbName = $"EsgRetry_{Guid.NewGuid():N}";
|
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
|
using var keepAlive = new SqliteConnection(connStr);
|
|
keepAlive.Open();
|
|
var storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
|
|
await storage.InitializeAsync();
|
|
// S&F defaults deliberately different from the system's settings.
|
|
var sfOptions = new StoreAndForwardOptions
|
|
{
|
|
DefaultMaxRetries = 3,
|
|
DefaultRetryInterval = TimeSpan.FromMinutes(10),
|
|
RetryTimerInterval = TimeSpan.FromMinutes(10),
|
|
};
|
|
var sf = new StoreAndForwardService(storage, sfOptions, NullLogger<StoreAndForwardService>.Instance);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance,
|
|
storeAndForward: sf);
|
|
|
|
var result = await client.CachedCallAsync("TestAPI", "postData");
|
|
Assert.True(result.WasBuffered);
|
|
|
|
var depth = await storage.GetBufferDepthByCategoryAsync();
|
|
Assert.Equal(1, depth[ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem]);
|
|
|
|
var (maxRetries, retryIntervalMs) = ReadBufferedRetrySettings(connStr);
|
|
Assert.Equal(7, maxRetries);
|
|
Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, retryIntervalMs);
|
|
}
|
|
|
|
private static (int MaxRetries, long RetryIntervalMs) ReadBufferedRetrySettings(string connStr)
|
|
{
|
|
using var conn = new SqliteConnection(connStr);
|
|
conn.Open();
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT max_retries, retry_interval_ms FROM sf_messages";
|
|
using var reader = cmd.ExecuteReader();
|
|
Assert.True(reader.Read(), "expected exactly one buffered message");
|
|
var result = (reader.GetInt32(0), reader.GetInt64(1));
|
|
Assert.False(reader.Read(), "expected exactly one buffered message");
|
|
return result;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedCall_TransientFailure_ZeroMaxRetriesIsTreatedAsUnsetNotRetryForever()
|
|
{
|
|
// ExternalSystemGateway-015: 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
|
|
// as an unbounded retry loop. The ESG must therefore treat the entity's
|
|
// MaxRetries == 0 as "unset" and pass null, so the bounded S&F default applies.
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none")
|
|
{
|
|
Id = 1,
|
|
MaxRetries = 0,
|
|
RetryDelay = TimeSpan.FromSeconds(5),
|
|
};
|
|
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var dbName = $"EsgRetryZero_{Guid.NewGuid():N}";
|
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
|
using var keepAlive = new SqliteConnection(connStr);
|
|
keepAlive.Open();
|
|
var storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
|
|
await storage.InitializeAsync();
|
|
var sfOptions = new StoreAndForwardOptions
|
|
{
|
|
DefaultMaxRetries = 99,
|
|
DefaultRetryInterval = TimeSpan.FromMinutes(10),
|
|
RetryTimerInterval = TimeSpan.FromMinutes(10),
|
|
};
|
|
var sf = new StoreAndForwardService(storage, sfOptions, NullLogger<StoreAndForwardService>.Instance);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance,
|
|
storeAndForward: sf);
|
|
|
|
await client.CachedCallAsync("TestAPI", "postData");
|
|
|
|
var (maxRetries, _) = ReadBufferedRetrySettings(connStr);
|
|
// Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever.
|
|
Assert.Equal(99, maxRetries);
|
|
Assert.NotEqual(0, maxRetries);
|
|
}
|
|
|
|
// ── ExternalSystemGateway-005: HttpRequestMessage / HttpResponseMessage disposal ──
|
|
|
|
[Fact]
|
|
public async Task Call_SuccessfulHttp_DisposesRequestAndResponse()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new DisposalTrackingHandler(HttpStatusCode.OK, "{\"ok\":true}");
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await client.CallAsync("TestAPI", "getData");
|
|
|
|
Assert.True(handler.RequestDisposed, "HttpRequestMessage was not disposed");
|
|
Assert.True(handler.ResponseContentDisposed, "HttpResponseMessage content was not disposed");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_PermanentFailure_StillDisposesRequestAndResponse()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new DisposalTrackingHandler(HttpStatusCode.BadRequest, "bad request");
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await client.CallAsync("TestAPI", "badMethod");
|
|
|
|
Assert.True(handler.RequestDisposed, "HttpRequestMessage was not disposed on the error path");
|
|
Assert.True(handler.ResponseContentDisposed, "HttpResponseMessage content was not disposed on the error path");
|
|
}
|
|
|
|
// ── ExternalSystemGateway-006: BuildUrl — empty path must not append a trailing slash ──
|
|
|
|
[Fact]
|
|
public async Task Call_MethodWithEmptyPath_DoesNotAppendTrailingSlash()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("root", "GET", "") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await client.CallAsync("TestAPI", "root");
|
|
|
|
Assert.Equal("https://api.example.com/api", handler.LastUri!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_MethodWithPath_BuildsExpectedUrl()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await client.CallAsync("TestAPI", "getData");
|
|
|
|
Assert.Equal("https://api.example.com/api/data", handler.LastUri!.ToString());
|
|
}
|
|
|
|
// ── ExternalSystemGateway-007: external error body must be truncated, not echoed verbatim ──
|
|
|
|
[Fact]
|
|
public async Task Call_PermanentFailureWithHugeErrorBody_TruncatesErrorMessage()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var hugeBody = new string('X', 500_000);
|
|
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, hugeBody);
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var result = await client.CallAsync("TestAPI", "badMethod");
|
|
|
|
Assert.False(result.Success);
|
|
// The error message must be bounded — a misbehaving endpoint cannot inflate
|
|
// every script-visible error string / event-log entry.
|
|
Assert.True(result.ErrorMessage!.Length < 4096,
|
|
$"Error message was {result.ErrorMessage.Length} chars — expected it to be truncated");
|
|
}
|
|
|
|
// ── ExternalSystemGateway-008: cancellation of a CachedCall must not be buffered ──
|
|
|
|
[Fact]
|
|
public async Task CachedCall_CallerCancellation_IsNotBufferedAsTransient()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var dbName = $"EsgCancel_{Guid.NewGuid():N}";
|
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
|
using var keepAlive = new SqliteConnection(connStr);
|
|
keepAlive.Open();
|
|
var storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
|
|
await storage.InitializeAsync();
|
|
var sfOptions = new StoreAndForwardOptions
|
|
{
|
|
DefaultRetryInterval = TimeSpan.FromMinutes(10),
|
|
RetryTimerInterval = TimeSpan.FromMinutes(10),
|
|
};
|
|
var sf = new StoreAndForwardService(storage, sfOptions, NullLogger<StoreAndForwardService>.Instance);
|
|
|
|
var options = new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromMinutes(5) };
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance,
|
|
storeAndForward: sf,
|
|
options: Microsoft.Extensions.Options.Options.Create(options));
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
|
|
|
|
// Caller asked to abandon the work — it must NOT be buffered for retry.
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => client.CachedCallAsync("TestAPI", "postData", cancellationToken: cts.Token));
|
|
|
|
var depth = await storage.GetBufferDepthByCategoryAsync();
|
|
Assert.False(
|
|
depth.TryGetValue(ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem, out var n) && n > 0,
|
|
"A caller-cancelled CachedCall must not be buffered for retry");
|
|
}
|
|
|
|
// ── ExternalSystemGateway-014: BuildUrl query-string, ApplyAuth, connection errors ──
|
|
|
|
[Fact]
|
|
public async Task Call_GetWithParameters_AppendsEscapedQueryString()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await client.CallAsync("TestAPI", "search", new Dictionary<string, object?>
|
|
{
|
|
["q"] = "a b&c",
|
|
["page"] = 2,
|
|
});
|
|
|
|
// AbsoluteUri preserves percent-encoding; the '&' inside a value must be
|
|
// escaped so it is not mistaken for a parameter separator.
|
|
var uri = handler.LastUri!.AbsoluteUri;
|
|
Assert.StartsWith("https://api.example.com/search?", uri);
|
|
Assert.Contains("q=a%20b%26c", uri);
|
|
Assert.Contains("page=2", uri);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_GetWithAllNullParameters_DoesNotAppendTrailingQuestionMark()
|
|
{
|
|
// ExternalSystemGateway-017: 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 };
|
|
var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await client.CallAsync("TestAPI", "search", new Dictionary<string, object?>
|
|
{
|
|
["q"] = null,
|
|
["page"] = null,
|
|
});
|
|
|
|
var uri = handler.LastUri!.AbsoluteUri;
|
|
Assert.Equal("https://api.example.com/search", uri);
|
|
Assert.DoesNotContain("?", uri);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_PostWithParameters_SendsJsonBody()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("create", "POST", "/create") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await client.CallAsync("TestAPI", "create", new Dictionary<string, object?> { ["name"] = "widget" });
|
|
|
|
Assert.Equal("https://api.example.com/create", handler.LastUri!.ToString());
|
|
Assert.Contains("\"name\":\"widget\"", handler.LastBody);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_ApiKeyAuthWithDefaultHeader_SendsXApiKeyHeader()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
|
|
{
|
|
Id = 1,
|
|
AuthConfiguration = "secret-key-123",
|
|
};
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await client.CallAsync("TestAPI", "getData");
|
|
|
|
Assert.True(handler.LastHeaders!.TryGetValues("X-API-Key", out var values));
|
|
Assert.Equal("secret-key-123", values!.Single());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_ApiKeyAuthWithCustomHeader_SendsNamedHeader()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
|
|
{
|
|
Id = 1,
|
|
AuthConfiguration = "Authorization-Token:abc",
|
|
};
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await client.CallAsync("TestAPI", "getData");
|
|
|
|
Assert.True(handler.LastHeaders!.TryGetValues("Authorization-Token", out var values));
|
|
Assert.Equal("abc", values!.Single());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_BasicAuth_SendsBase64AuthorizationHeader()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "basic")
|
|
{
|
|
Id = 1,
|
|
AuthConfiguration = "alice:s3cret",
|
|
};
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
await client.CallAsync("TestAPI", "getData");
|
|
|
|
var auth = handler.LastHeaders!.Authorization;
|
|
Assert.NotNull(auth);
|
|
Assert.Equal("Basic", auth!.Scheme);
|
|
var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(auth.Parameter!));
|
|
Assert.Equal("alice:s3cret", decoded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_ConnectionError_IsClassifiedAsTransient()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
// A connection-level failure (e.g. host unreachable) surfaces as HttpRequestException.
|
|
var handler = new ThrowingHttpMessageHandler(new HttpRequestException("connection refused"));
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
|
|
|
var client = new ExternalSystemClient(
|
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
|
|
|
var result = await client.CallAsync("TestAPI", "getData");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Transient error", result.ErrorMessage);
|
|
}
|
|
|
|
// ── ExternalSystemGateway-012: permanent failures must be logged ──
|
|
|
|
[Fact]
|
|
public async Task Call_PermanentFailure_LogsAWarning()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var logger = new CapturingLogger<ExternalSystemClient>();
|
|
var client = new ExternalSystemClient(_httpClientFactory, _repository, logger);
|
|
|
|
await client.CallAsync("TestAPI", "badMethod");
|
|
|
|
// The design doc requires permanent failures to be surfaced to Site Event
|
|
// Logging — the gateway must emit at least a warning, not stay silent.
|
|
Assert.Contains(logger.Entries, e =>
|
|
e.Level >= LogLevel.Warning && e.Message.Contains("TestAPI"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Call_TransientFailure_DoesNotLogAtWarningOrAbove()
|
|
{
|
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
|
var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
|
|
StubResolution(system, method);
|
|
|
|
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom");
|
|
var httpClient = new HttpClient(handler);
|
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
|
|
|
var logger = new CapturingLogger<ExternalSystemClient>();
|
|
var client = new ExternalSystemClient(_httpClientFactory, _repository, logger);
|
|
|
|
await client.CallAsync("TestAPI", "failMethod");
|
|
|
|
// A transient failure is normal operation handled by retry/S&F — it must not
|
|
// be logged at warning level (only permanent failures are).
|
|
Assert.DoesNotContain(logger.Entries, e => e.Level >= LogLevel.Warning);
|
|
}
|
|
|
|
/// <summary>Test helper: an ILogger that records every entry for assertions.</summary>
|
|
private sealed class CapturingLogger<T> : ILogger<T>
|
|
{
|
|
public List<(LogLevel Level, string Message)> Entries { get; } = new();
|
|
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
Entries.Add((logLevel, formatter(state, exception)));
|
|
}
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
public static readonly NullScope Instance = new();
|
|
public void Dispose() { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test helper: mock HTTP message handler.
|
|
/// </summary>
|
|
private class MockHttpMessageHandler : HttpMessageHandler
|
|
{
|
|
private readonly HttpStatusCode _statusCode;
|
|
private readonly string _body;
|
|
|
|
public MockHttpMessageHandler(HttpStatusCode statusCode, string body)
|
|
{
|
|
_statusCode = statusCode;
|
|
_body = body;
|
|
}
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(new HttpResponseMessage(_statusCode)
|
|
{
|
|
Content = new StringContent(_body)
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test helper: tracks disposal of the request and the response content.
|
|
/// </summary>
|
|
private class DisposalTrackingHandler : HttpMessageHandler
|
|
{
|
|
private readonly HttpStatusCode _statusCode;
|
|
private readonly string _body;
|
|
|
|
public DisposalTrackingHandler(HttpStatusCode statusCode, string body)
|
|
{
|
|
_statusCode = statusCode;
|
|
_body = body;
|
|
}
|
|
|
|
public bool RequestDisposed { get; private set; }
|
|
public bool ResponseContentDisposed { get; private set; }
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
request.Content = new TrackingContent(string.Empty, () => RequestDisposed = true);
|
|
var response = new HttpResponseMessage(_statusCode)
|
|
{
|
|
Content = new TrackingContent(_body, () => ResponseContentDisposed = true)
|
|
};
|
|
return Task.FromResult(response);
|
|
}
|
|
|
|
private sealed class TrackingContent : StringContent
|
|
{
|
|
private readonly Action _onDispose;
|
|
public TrackingContent(string content, Action onDispose) : base(content) => _onDispose = onDispose;
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing) _onDispose();
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test helper: captures the URI, headers and body of the last request.
|
|
/// </summary>
|
|
private class RequestCapturingHandler : HttpMessageHandler
|
|
{
|
|
private readonly HttpStatusCode _statusCode;
|
|
private readonly string _body;
|
|
|
|
public RequestCapturingHandler(HttpStatusCode statusCode, string body)
|
|
{
|
|
_statusCode = statusCode;
|
|
_body = body;
|
|
}
|
|
|
|
public Uri? LastUri { get; private set; }
|
|
public HttpRequestHeaders? LastHeaders { get; private set; }
|
|
public string? LastBody { get; private set; }
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
LastUri = request.RequestUri;
|
|
LastHeaders = request.Headers;
|
|
LastBody = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
|
return new HttpResponseMessage(_statusCode)
|
|
{
|
|
Content = new StringContent(_body)
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>Test helper: an HTTP handler that throws a connection-level exception.</summary>
|
|
private class ThrowingHttpMessageHandler : HttpMessageHandler
|
|
{
|
|
private readonly Exception _exception;
|
|
public ThrowingHttpMessageHandler(Exception exception) => _exception = exception;
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
=> throw _exception;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test helper: an HTTP handler that hangs until cancelled (simulates a slow/hung system).
|
|
/// </summary>
|
|
private class HangingHttpMessageHandler : HttpMessageHandler
|
|
{
|
|
private readonly TimeSpan _delay;
|
|
|
|
public HangingHttpMessageHandler(TimeSpan delay) => _delay = delay;
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(_delay, cancellationToken);
|
|
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") };
|
|
}
|
|
}
|
|
}
|