fix(external-system-gateway): resolve ExternalSystemGateway-011 — name-keyed repository lookups replace fetch-all-then-filter on the call hot path

This commit is contained in:
Joseph Doherty
2026-05-17 00:02:45 -04:00
parent 1e2e7d2e7c
commit a55502254e
10 changed files with 448 additions and 131 deletions

View File

@@ -18,10 +18,31 @@ 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()
{
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
StubResolution(system: null, method: null);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
@@ -37,8 +58,7 @@ public class ExternalSystemClientTests
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>());
StubResolution(system, method: null);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
@@ -56,8 +76,7 @@ public class ExternalSystemClientTests
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 });
StubResolution(system, method);
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"result\": 42}");
var httpClient = new HttpClient(handler);
@@ -79,8 +98,7 @@ public class ExternalSystemClientTests
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 });
StubResolution(system, method);
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "server error");
var httpClient = new HttpClient(handler);
@@ -102,8 +120,7 @@ public class ExternalSystemClientTests
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 });
StubResolution(system, method);
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
var httpClient = new HttpClient(handler);
@@ -122,7 +139,7 @@ public class ExternalSystemClientTests
[Fact]
public async Task CachedCall_SystemNotFound_ReturnsError()
{
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
StubResolution(system: null, method: null);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
@@ -140,8 +157,7 @@ public class ExternalSystemClientTests
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 });
StubResolution(system, method);
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\": true}");
var httpClient = new HttpClient(handler);
@@ -175,8 +191,7 @@ public class ExternalSystemClientTests
{
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 });
StubResolution(system, method);
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\":true}"));
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
@@ -192,7 +207,7 @@ public class ExternalSystemClientTests
[Fact]
public async Task DeliverBuffered_SystemNoLongerExists_ReturnsFalseSoMessageParks()
{
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
StubResolution(system: null, method: null);
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
@@ -207,8 +222,7 @@ public class ExternalSystemClientTests
{
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 });
StubResolution(system, method);
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
@@ -227,10 +241,7 @@ public class ExternalSystemClientTests
{
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<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
// The HTTP layer always fails transiently (500).
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
@@ -275,10 +286,7 @@ public class ExternalSystemClientTests
{
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<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
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)));
@@ -308,10 +316,7 @@ public class ExternalSystemClientTests
{
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<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
@@ -342,10 +347,7 @@ public class ExternalSystemClientTests
RetryDelay = TimeSpan.FromSeconds(42),
};
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
@@ -404,10 +406,7 @@ public class ExternalSystemClientTests
RetryDelay = TimeSpan.FromSeconds(5),
};
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
@@ -443,10 +442,7 @@ public class ExternalSystemClientTests
{
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<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new DisposalTrackingHandler(HttpStatusCode.OK, "{\"ok\":true}");
var httpClient = new HttpClient(handler);
@@ -466,10 +462,7 @@ public class ExternalSystemClientTests
{
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(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new DisposalTrackingHandler(HttpStatusCode.BadRequest, "bad request");
var httpClient = new HttpClient(handler);
@@ -491,10 +484,7 @@ public class ExternalSystemClientTests
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 };
var method = new ExternalSystemMethod("root", "GET", "") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
var httpClient = new HttpClient(handler);
@@ -513,10 +503,7 @@ public class ExternalSystemClientTests
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 };
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
var httpClient = new HttpClient(handler);
@@ -537,10 +524,7 @@ public class ExternalSystemClientTests
{
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(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var hugeBody = new string('X', 500_000);
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, hugeBody);
@@ -566,10 +550,7 @@ public class ExternalSystemClientTests
{
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<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
@@ -612,10 +593,7 @@ public class ExternalSystemClientTests
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
@@ -642,10 +620,7 @@ public class ExternalSystemClientTests
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("create", "POST", "/create") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
@@ -668,10 +643,7 @@ public class ExternalSystemClientTests
AuthConfiguration = "secret-key-123",
};
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
@@ -694,10 +666,7 @@ public class ExternalSystemClientTests
AuthConfiguration = "Authorization-Token:abc",
};
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
@@ -720,10 +689,7 @@ public class ExternalSystemClientTests
AuthConfiguration = "alice:s3cret",
};
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
@@ -745,10 +711,7 @@ public class ExternalSystemClientTests
{
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<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
// A connection-level failure (e.g. host unreachable) surfaces as HttpRequestException.
var handler = new ThrowingHttpMessageHandler(new HttpRequestException("connection refused"));
@@ -770,10 +733,7 @@ public class ExternalSystemClientTests
{
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(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
var httpClient = new HttpClient(handler);
@@ -795,10 +755,7 @@ public class ExternalSystemClientTests
{
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(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
StubResolution(system, method);
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom");
var httpClient = new HttpClient(handler);