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("{}") }; } } }