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; /// /// 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(); /// /// Configures the repository substitute for the name-keyed resolution path used by /// ExternalSystemClient (ExternalSystemGateway-011). A null system or /// method models a "not found" — the substitute returns null by default, so /// no stub is needed for the absent entity. /// private void StubResolution(ExternalSystemDefinition? system, ExternalSystemMethod? method) { if (system != null) { _repository.GetExternalSystemByNameAsync(system.Name, Arg.Any()) .Returns(system); } if (system != null && method != null) { _repository.GetMethodByNameAsync(system.Id, method.Name, Arg.Any()) .Returns(method); } } [Fact] public async Task Call_SystemNotFound_ReturnsError() { StubResolution(system: null, method: null); 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 }; StubResolution(system, method: null); 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 }; StubResolution(system, 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 }; StubResolution(system, 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 }; StubResolution(system, 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() { StubResolution(system: null, method: null); 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 }; StubResolution(system, 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 }; StubResolution(system, 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() { StubResolution(system: null, method: null); 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 }; StubResolution(system, 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 }; StubResolution(system, 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 }; 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()).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 }; StubResolution(system, 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)); } // ── 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()).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.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.Instance); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.Instance, storeAndForward: sf); // Audit Log #23 (ExecutionId Task 4): a known execution id / source // script so the gateway -> EnqueueAsync hop can be asserted below. var executionId = Guid.NewGuid(); const string sourceScript = "ScriptActor:CheckPressure"; var result = await client.CachedCallAsync( "TestAPI", "postData", executionId: executionId, sourceScript: sourceScript); Assert.True(result.WasBuffered); var depth = await storage.GetBufferDepthByCategoryAsync(); Assert.Equal(1, depth[ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem]); var buffered = ReadBufferedRetrySettings(connStr); Assert.Equal(7, buffered.MaxRetries); Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, buffered.RetryIntervalMs); // ExecutionId Task 4: 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); Assert.Equal(sourceScript, buffered.SourceScript); } private static (int MaxRetries, long RetryIntervalMs, Guid? ExecutionId, string? SourceScript) ReadBufferedRetrySettings(string connStr) { using var conn = new SqliteConnection(connStr); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT max_retries, retry_interval_ms, execution_id, source_script 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), reader.IsDBNull(2) ? (Guid?)null : Guid.Parse(reader.GetString(2)), reader.IsDBNull(3) ? null : reader.GetString(3)); 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()).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.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.Instance); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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()).Returns(httpClient); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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()).Returns(httpClient); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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()).Returns(httpClient); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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()).Returns(httpClient); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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()).Returns(httpClient); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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()).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.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 options = new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromMinutes(5) }; var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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( () => 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()).Returns(new HttpClient(handler)); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.Instance); await client.CallAsync("TestAPI", "search", new Dictionary { ["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()).Returns(new HttpClient(handler)); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.Instance); await client.CallAsync("TestAPI", "search", new Dictionary { ["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()).Returns(new HttpClient(handler)); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.Instance); await client.CallAsync("TestAPI", "create", new Dictionary { ["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()).Returns(new HttpClient(handler)); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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()).Returns(new HttpClient(handler)); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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()).Returns(new HttpClient(handler)); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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()).Returns(new HttpClient(handler)); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.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()).Returns(httpClient); var logger = new CapturingLogger(); 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()).Returns(httpClient); var logger = new CapturingLogger(); 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); } /// Test helper: an ILogger that records every entry for assertions. private sealed class CapturingLogger : ILogger { public List<(LogLevel Level, string Message)> Entries { get; } = new(); public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Entries.Add((logLevel, formatter(state, exception))); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } /// /// 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: tracks disposal of the request and the response content. /// 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 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); } } } /// /// Test helper: captures the URI, headers and body of the last request. /// 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 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) }; } } /// Test helper: an HTTP handler that throws a connection-level exception. private class ThrowingHttpMessageHandler : HttpMessageHandler { private readonly Exception _exception; public ThrowingHttpMessageHandler(Exception exception) => _exception = exception; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => throw _exception; } /// /// 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("{}") }; } } }