Resolves StoreAndForward-001, ExternalSystemGateway-001, NotificationService-001 — one systemic gap where buffered messages were persisted but never delivered, and the active node never replicated its buffer to the standby. Delivery handlers (ExternalSystemGateway-001 / NotificationService-001): - AkkaHostedService registers delivery handlers for the ExternalSystem, CachedDbWrite and Notification categories after StoreAndForwardService starts; each resolves its scoped consumer in a fresh DI scope. - ExternalSystemClient, DatabaseGateway and NotificationDeliveryService each gain a DeliverBufferedAsync method: re-resolve the target and re-attempt delivery, returning true/false/throwing per the transient-vs-permanent contract. - EnqueueAsync gains an attemptImmediateDelivery flag; CachedCallAsync and NotificationDeliveryService.SendAsync pass false (they already attempted delivery themselves) so registering a handler does not dispatch twice. Replication (StoreAndForward-001): - ReplicationService is injected into StoreAndForwardService; a new BufferAsync helper replicates every enqueue, and successful-retry removes and parks are replicated too. Fire-and-forget, no-op when replication is disabled. Tests: StoreAndForwardReplicationTests (Add/Remove/Park observed), attemptImmediateDelivery behaviour, and DeliverBufferedAsync paths for each consumer. Full solution builds; StoreAndForward/ExternalSystemGateway/ NotificationService suites green.
242 lines
10 KiB
C#
242 lines
10 KiB
C#
using System.Net;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
|
|
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>();
|
|
|
|
[Fact]
|
|
public async Task Call_SystemNotFound_ReturnsError()
|
|
{
|
|
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
|
|
|
|
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 };
|
|
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
|
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod>());
|
|
|
|
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 };
|
|
|
|
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
|
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { 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 };
|
|
|
|
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
|
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { 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 };
|
|
|
|
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
|
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { 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()
|
|
{
|
|
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
|
|
|
|
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 };
|
|
|
|
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
|
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { 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 };
|
|
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
|
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { 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()
|
|
{
|
|
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
|
|
|
|
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 };
|
|
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
|
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { 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")));
|
|
}
|
|
|
|
/// <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)
|
|
});
|
|
}
|
|
}
|
|
}
|