using System.Net;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.StoreAndForward;
namespace ScadaLink.ExternalSystemGateway.Tests;
///
/// WP-6/7: Tests for ExternalSystemClient — HTTP client, call modes, error handling.
///
public class ExternalSystemClientTests
{
private readonly IExternalSystemRepository _repository = Substitute.For();
private readonly IHttpClientFactory _httpClientFactory = Substitute.For();
[Fact]
public async Task Call_SystemNotFound_ReturnsError()
{
_repository.GetAllExternalSystemsAsync().Returns(new List());
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger.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 };
_repository.GetAllExternalSystemsAsync().Returns(new List { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List());
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger.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 };
_repository.GetAllExternalSystemsAsync().Returns(new List { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method });
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"result\": 42}");
var httpClient = new HttpClient(handler);
_httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger.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 };
_repository.GetAllExternalSystemsAsync().Returns(new List { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method });
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "server error");
var httpClient = new HttpClient(handler);
_httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger.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 };
_repository.GetAllExternalSystemsAsync().Returns(new List { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method });
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
var httpClient = new HttpClient(handler);
_httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger.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()
{
_repository.GetAllExternalSystemsAsync().Returns(new List());
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger.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 };
_repository.GetAllExternalSystemsAsync().Returns(new List { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method });
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\": true}");
var httpClient = new HttpClient(handler);
_httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger.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 };
_repository.GetAllExternalSystemsAsync().Returns(new List { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method });
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\":true}"));
_httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient);
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger.Instance);
var delivered = await client.DeliverBufferedAsync(BufferedCall("TestAPI", "getData"));
Assert.True(delivered);
}
[Fact]
public async Task DeliverBuffered_SystemNoLongerExists_ReturnsFalseSoMessageParks()
{
_repository.GetAllExternalSystemsAsync().Returns(new List());
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger.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 };
_repository.GetAllExternalSystemsAsync().Returns(new List { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method });
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
_httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient);
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger.Instance);
await Assert.ThrowsAsync(
() => 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 };
_repository.GetAllExternalSystemsAsync(Arg.Any())
.Returns(new List { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any())
.Returns(new List { method });
// The HTTP layer always fails transiently (500).
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
_httpClientFactory.CreateClient(Arg.Any()).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.Instance);
await storage.InitializeAsync();
var sfOptions = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.FromMinutes(10),
RetryTimerInterval = TimeSpan.FromMinutes(10),
};
var sf = new StoreAndForwardService(storage, sfOptions, NullLogger.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.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 };
_repository.GetAllExternalSystemsAsync(Arg.Any())
.Returns(new List { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any())
.Returns(new List { 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()).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.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 };
_repository.GetAllExternalSystemsAsync(Arg.Any())
.Returns(new List { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any())
.Returns(new List { method });
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
_httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient);
var options = new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromMinutes(5) };
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger.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(
() => client.CallAsync("TestAPI", "getData", cancellationToken: cts.Token));
}
///
/// Test helper: mock HTTP message handler.
///
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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_body)
});
}
}
///
/// Test helper: an HTTP handler that hangs until cancelled (simulates a slow/hung system).
///
private class HangingHttpMessageHandler : HttpMessageHandler
{
private readonly TimeSpan _delay;
public HangingHttpMessageHandler(TimeSpan delay) => _delay = delay;
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(_delay, cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") };
}
}
}