From 74efd4d769ac6e2873f7dee31c889b0480ffc20c Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 19:09:46 +0100 Subject: [PATCH] Add reusable SDK test infrastructure --- .gitignore | 1 + README.md | 1 + docs/API_COVERAGE.md | 4 +- .../IntegrationTestEnvironment.cs | 34 ++++++ .../TermiiClientIntegrationTests.cs | 17 +-- .../Infrastructure/TestHttpMessageHandler.cs | 44 ++++++++ .../Infrastructure/TestRequestExtensions.cs | 20 ++++ .../Infrastructure/TestTermiiClientFactory.cs | 23 ++++ tests/Termii.Tests/TermiiClientTests.cs | 106 ++++-------------- 9 files changed, 146 insertions(+), 104 deletions(-) create mode 100644 tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs create mode 100644 tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs create mode 100644 tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs create mode 100644 tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs diff --git a/.gitignore b/.gitignore index eb36e42..203e37c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ obj/ .vs/ .vscode/ .idea/ +.DS_Store TestResults/ coverage/ *.user diff --git a/README.md b/README.md index 153a19e..6134386 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Integration tests are opt-in and must not call live Termii endpoints unless the ```bash export TERMII_API_KEY="your-termii-api-key" export TERMII_BASE_URL="https://api.ng.termii.com" +export TERMII_TEST_PHONE_NUMBER="2348012345678" dotnet test tests/Termii.IntegrationTests ``` diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 8710664..4eb700a 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -29,8 +29,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Solution, SDK project, unit tests, integration test shell, examples project | Implemented | #1, PR #15 | | Client configuration, `HttpClient` pipeline, API-key injection, DI registration | Implemented | #2, PR #17 | | API coverage matrix | Implemented | #14, PR #18 | -| Error handling and request validation | In progress | #6 | -| Reusable fake HTTP test helpers and opt-in live test conventions | Planned | #10 | +| Error handling and request validation | Implemented | #6, PR #19 | +| Reusable fake HTTP test helpers and opt-in live test conventions | In progress | #10 | | README endpoint examples | Planned | #12 | | CI build/test/package validation | Planned | #11 | | NuGet package metadata and publishing workflow | Planned | #8 | diff --git a/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs b/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs new file mode 100644 index 0000000..4f70b90 --- /dev/null +++ b/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs @@ -0,0 +1,34 @@ +using Termii; + +namespace Termii.IntegrationTests; + +internal static class IntegrationTestEnvironment +{ + public static string? ApiKey => Environment.GetEnvironmentVariable("TERMII_API_KEY"); + + public static string? BaseUrl => Environment.GetEnvironmentVariable("TERMII_BASE_URL"); + + public static string? TestPhoneNumber => Environment.GetEnvironmentVariable("TERMII_TEST_PHONE_NUMBER"); + + public static bool HasCredentials => !string.IsNullOrWhiteSpace(ApiKey); + + public static TermiiOptions CreateOptions() + { + if (string.IsNullOrWhiteSpace(ApiKey)) + { + throw new InvalidOperationException("TERMII_API_KEY is required for live Termii integration tests."); + } + + var options = new TermiiOptions + { + ApiKey = ApiKey, + }; + + if (!string.IsNullOrWhiteSpace(BaseUrl)) + { + options.BaseUrl = new Uri(BaseUrl); + } + + return options; + } +} diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index df2c1d8..8828c90 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -8,25 +8,12 @@ public sealed class TermiiClientIntegrationTests [Fact] public void CanCreateClientFromIntegrationEnvironment() { - var apiKey = Environment.GetEnvironmentVariable("TERMII_API_KEY"); - - if (string.IsNullOrWhiteSpace(apiKey)) + if (!IntegrationTestEnvironment.HasCredentials) { return; } - var baseUrl = Environment.GetEnvironmentVariable("TERMII_BASE_URL"); - var options = new TermiiOptions - { - ApiKey = apiKey, - }; - - if (!string.IsNullOrWhiteSpace(baseUrl)) - { - options.BaseUrl = new Uri(baseUrl); - } - - var client = new TermiiClient(options); + var client = new TermiiClient(IntegrationTestEnvironment.CreateOptions()); Assert.False(string.IsNullOrWhiteSpace(client.Options.ApiKey)); Assert.True(client.Options.BaseUrl.IsAbsoluteUri); diff --git a/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs b/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs new file mode 100644 index 0000000..e13fd22 --- /dev/null +++ b/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs @@ -0,0 +1,44 @@ +using System.Net; + +namespace Termii.Tests.Infrastructure; + +internal sealed class TestHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue _responses = new(); + + public TestHttpMessageHandler() + : this(HttpStatusCode.OK, "{}") + { + } + + public TestHttpMessageHandler(HttpStatusCode statusCode, string responseBody) + { + Enqueue(statusCode, responseBody); + } + + public IReadOnlyList Requests => _requests; + + public HttpRequestMessage? LastRequest => _requests.Count == 0 ? null : _requests[^1]; + + private readonly List _requests = new(); + + public void Enqueue(HttpStatusCode statusCode, string responseBody) + { + _responses.Enqueue(new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseBody), + }); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _requests.Add(request); + + if (_responses.Count == 0) + { + throw new InvalidOperationException("No fake HTTP response was queued for the request."); + } + + return Task.FromResult(_responses.Dequeue()); + } +} diff --git a/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs b/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs new file mode 100644 index 0000000..9db82a3 --- /dev/null +++ b/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs @@ -0,0 +1,20 @@ +using System.Text.Json; + +namespace Termii.Tests.Infrastructure; + +internal static class TestRequestExtensions +{ + public static async Task ReadJsonBodyAsync( + this HttpRequestMessage request, + CancellationToken cancellationToken = default) + { + if (request.Content is null) + { + throw new InvalidOperationException("The request does not have a body."); + } + + var json = await request.Content.ReadAsStringAsync(cancellationToken); + + return JsonDocument.Parse(json); + } +} diff --git a/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs b/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs new file mode 100644 index 0000000..473cbcc --- /dev/null +++ b/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs @@ -0,0 +1,23 @@ +using Termii; + +namespace Termii.Tests.Infrastructure; + +internal static class TestTermiiClientFactory +{ + public static TermiiClient Create( + TestHttpMessageHandler handler, + string apiKey = "test-api-key", + string baseUrl = "https://example.test") + { + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri(baseUrl), + }; + + return new TermiiClient(httpClient, new TermiiOptions + { + ApiKey = apiKey, + BaseUrl = new Uri(baseUrl), + }); + } +} diff --git a/tests/Termii.Tests/TermiiClientTests.cs b/tests/Termii.Tests/TermiiClientTests.cs index 7ccedec..e525fc6 100644 --- a/tests/Termii.Tests/TermiiClientTests.cs +++ b/tests/Termii.Tests/TermiiClientTests.cs @@ -1,7 +1,7 @@ using Termii; +using Termii.Tests.Infrastructure; using Microsoft.Extensions.DependencyInjection; using System.Net; -using System.Text.Json; using Xunit; namespace Termii.Tests; @@ -33,38 +33,22 @@ public void ConstructorAcceptsValidOptions() [Fact] public async Task SendAsyncAddsApiKeyToQueryString() { - using var handler = new RecordingHttpMessageHandler(); - using var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://example.test"), - }; - var client = new TermiiClient(httpClient, new TermiiOptions - { - ApiKey = "test key", - BaseUrl = new Uri("https://example.test"), - }); + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler, apiKey: "test key"); using var response = await client.SendAsync(HttpMethod.Get, "/api/sms/inbox", cancellationToken: CancellationToken.None); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(HttpMethod.Get, handler.Request!.Method); - Assert.Equal("https://example.test/api/sms/inbox?api_key=test%20key", handler.Request.RequestUri!.AbsoluteUri); - Assert.Null(handler.Request.Content); + Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method); + Assert.Equal("https://example.test/api/sms/inbox?api_key=test%20key", handler.LastRequest.RequestUri!.AbsoluteUri); + Assert.Null(handler.LastRequest.Content); } [Fact] public async Task SendAsyncAddsApiKeyToJsonBody() { - using var handler = new RecordingHttpMessageHandler(); - using var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://example.test"), - }; - var client = new TermiiClient(httpClient, new TermiiOptions - { - ApiKey = "test-api-key", - BaseUrl = new Uri("https://example.test"), - }); + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); using var response = await client.SendAsync( HttpMethod.Post, @@ -73,12 +57,13 @@ public async Task SendAsyncAddsApiKeyToJsonBody() TermiiAuthenticationLocation.Body, CancellationToken.None); - var json = await handler.Request!.Content!.ReadAsStringAsync(CancellationToken.None); - using var document = JsonDocument.Parse(json); + var request = handler.LastRequest; + Assert.NotNull(request); + using var document = await request.ReadJsonBodyAsync(CancellationToken.None); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(HttpMethod.Post, handler.Request.Method); - Assert.Equal("https://example.test/api/sms/send", handler.Request.RequestUri!.ToString()); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString()); Assert.Equal("test-api-key", document.RootElement.GetProperty("api_key").GetString()); Assert.Equal("2348012345678", document.RootElement.GetProperty("to").GetString()); Assert.Equal("Termii", document.RootElement.GetProperty("from").GetString()); @@ -88,18 +73,10 @@ public async Task SendAsyncAddsApiKeyToJsonBody() [Fact] public async Task SendAsyncThrowsTermiiApiExceptionForJsonErrorResponses() { - using var handler = new RecordingHttpMessageHandler( + using var handler = new TestHttpMessageHandler( HttpStatusCode.BadRequest, """{"message":"Invalid sender ID","code":"sender_id_invalid"}"""); - using var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://example.test"), - }; - var client = new TermiiClient(httpClient, new TermiiOptions - { - ApiKey = "secret-api-key", - BaseUrl = new Uri("https://example.test"), - }); + var client = TestTermiiClientFactory.Create(handler, apiKey: "secret-api-key"); var exception = await Assert.ThrowsAsync(() => client.SendAsync( HttpMethod.Post, @@ -118,16 +95,8 @@ public async Task SendAsyncThrowsTermiiApiExceptionForJsonErrorResponses() [Fact] public async Task SendAsyncThrowsTermiiApiExceptionForPlainTextErrorResponses() { - using var handler = new RecordingHttpMessageHandler(HttpStatusCode.InternalServerError, "Service unavailable"); - using var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://example.test"), - }; - var client = new TermiiClient(httpClient, new TermiiOptions - { - ApiKey = "secret-api-key", - BaseUrl = new Uri("https://example.test"), - }); + using var handler = new TestHttpMessageHandler(HttpStatusCode.InternalServerError, "Service unavailable"); + var client = TestTermiiClientFactory.Create(handler, apiKey: "secret-api-key"); var exception = await Assert.ThrowsAsync(() => client.SendAsync( HttpMethod.Get, @@ -148,16 +117,8 @@ public async Task SendAsyncThrowsTermiiApiExceptionForPlainTextErrorResponses() [InlineData("https://example.test/api/sms/send")] public async Task SendAsyncRejectsInvalidPaths(string path) { - using var handler = new RecordingHttpMessageHandler(); - using var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://example.test"), - }; - var client = new TermiiClient(httpClient, new TermiiOptions - { - ApiKey = "test-api-key", - BaseUrl = new Uri("https://example.test"), - }); + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); await Assert.ThrowsAnyAsync(() => client.SendAsync( HttpMethod.Get, @@ -185,32 +146,3 @@ public void AddTermiiRegistersConfiguredClient() Assert.Equal(TimeSpan.FromSeconds(30), client.Options.Timeout); } } - -internal sealed class RecordingHttpMessageHandler : HttpMessageHandler, IDisposable -{ - private readonly HttpStatusCode _statusCode; - private readonly string _responseBody; - - public RecordingHttpMessageHandler() - : this(HttpStatusCode.OK, "{}") - { - } - - public RecordingHttpMessageHandler(HttpStatusCode statusCode, string responseBody) - { - _statusCode = statusCode; - _responseBody = responseBody; - } - - public HttpRequestMessage? Request { get; private set; } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - Request = request; - - return Task.FromResult(new HttpResponseMessage(_statusCode) - { - Content = new StringContent(_responseBody), - }); - } -}