Skip to content

Commit 74efd4d

Browse files
committed
Add reusable SDK test infrastructure
1 parent d2974a9 commit 74efd4d

9 files changed

Lines changed: 146 additions & 104 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ obj/
33
.vs/
44
.vscode/
55
.idea/
6+
.DS_Store
67
TestResults/
78
coverage/
89
*.user

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Integration tests are opt-in and must not call live Termii endpoints unless the
5757
```bash
5858
export TERMII_API_KEY="your-termii-api-key"
5959
export TERMII_BASE_URL="https://api.ng.termii.com"
60+
export TERMII_TEST_PHONE_NUMBER="2348012345678"
6061
dotnet test tests/Termii.IntegrationTests
6162
```
6263

docs/API_COVERAGE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own
2929
| Solution, SDK project, unit tests, integration test shell, examples project | Implemented | #1, PR #15 |
3030
| Client configuration, `HttpClient` pipeline, API-key injection, DI registration | Implemented | #2, PR #17 |
3131
| API coverage matrix | Implemented | #14, PR #18 |
32-
| Error handling and request validation | In progress | #6 |
33-
| Reusable fake HTTP test helpers and opt-in live test conventions | Planned | #10 |
32+
| Error handling and request validation | Implemented | #6, PR #19 |
33+
| Reusable fake HTTP test helpers and opt-in live test conventions | In progress | #10 |
3434
| README endpoint examples | Planned | #12 |
3535
| CI build/test/package validation | Planned | #11 |
3636
| NuGet package metadata and publishing workflow | Planned | #8 |
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using Termii;
2+
3+
namespace Termii.IntegrationTests;
4+
5+
internal static class IntegrationTestEnvironment
6+
{
7+
public static string? ApiKey => Environment.GetEnvironmentVariable("TERMII_API_KEY");
8+
9+
public static string? BaseUrl => Environment.GetEnvironmentVariable("TERMII_BASE_URL");
10+
11+
public static string? TestPhoneNumber => Environment.GetEnvironmentVariable("TERMII_TEST_PHONE_NUMBER");
12+
13+
public static bool HasCredentials => !string.IsNullOrWhiteSpace(ApiKey);
14+
15+
public static TermiiOptions CreateOptions()
16+
{
17+
if (string.IsNullOrWhiteSpace(ApiKey))
18+
{
19+
throw new InvalidOperationException("TERMII_API_KEY is required for live Termii integration tests.");
20+
}
21+
22+
var options = new TermiiOptions
23+
{
24+
ApiKey = ApiKey,
25+
};
26+
27+
if (!string.IsNullOrWhiteSpace(BaseUrl))
28+
{
29+
options.BaseUrl = new Uri(BaseUrl);
30+
}
31+
32+
return options;
33+
}
34+
}

tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,12 @@ public sealed class TermiiClientIntegrationTests
88
[Fact]
99
public void CanCreateClientFromIntegrationEnvironment()
1010
{
11-
var apiKey = Environment.GetEnvironmentVariable("TERMII_API_KEY");
12-
13-
if (string.IsNullOrWhiteSpace(apiKey))
11+
if (!IntegrationTestEnvironment.HasCredentials)
1412
{
1513
return;
1614
}
1715

18-
var baseUrl = Environment.GetEnvironmentVariable("TERMII_BASE_URL");
19-
var options = new TermiiOptions
20-
{
21-
ApiKey = apiKey,
22-
};
23-
24-
if (!string.IsNullOrWhiteSpace(baseUrl))
25-
{
26-
options.BaseUrl = new Uri(baseUrl);
27-
}
28-
29-
var client = new TermiiClient(options);
16+
var client = new TermiiClient(IntegrationTestEnvironment.CreateOptions());
3017

3118
Assert.False(string.IsNullOrWhiteSpace(client.Options.ApiKey));
3219
Assert.True(client.Options.BaseUrl.IsAbsoluteUri);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Net;
2+
3+
namespace Termii.Tests.Infrastructure;
4+
5+
internal sealed class TestHttpMessageHandler : HttpMessageHandler
6+
{
7+
private readonly Queue<HttpResponseMessage> _responses = new();
8+
9+
public TestHttpMessageHandler()
10+
: this(HttpStatusCode.OK, "{}")
11+
{
12+
}
13+
14+
public TestHttpMessageHandler(HttpStatusCode statusCode, string responseBody)
15+
{
16+
Enqueue(statusCode, responseBody);
17+
}
18+
19+
public IReadOnlyList<HttpRequestMessage> Requests => _requests;
20+
21+
public HttpRequestMessage? LastRequest => _requests.Count == 0 ? null : _requests[^1];
22+
23+
private readonly List<HttpRequestMessage> _requests = new();
24+
25+
public void Enqueue(HttpStatusCode statusCode, string responseBody)
26+
{
27+
_responses.Enqueue(new HttpResponseMessage(statusCode)
28+
{
29+
Content = new StringContent(responseBody),
30+
});
31+
}
32+
33+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
34+
{
35+
_requests.Add(request);
36+
37+
if (_responses.Count == 0)
38+
{
39+
throw new InvalidOperationException("No fake HTTP response was queued for the request.");
40+
}
41+
42+
return Task.FromResult(_responses.Dequeue());
43+
}
44+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System.Text.Json;
2+
3+
namespace Termii.Tests.Infrastructure;
4+
5+
internal static class TestRequestExtensions
6+
{
7+
public static async Task<JsonDocument> ReadJsonBodyAsync(
8+
this HttpRequestMessage request,
9+
CancellationToken cancellationToken = default)
10+
{
11+
if (request.Content is null)
12+
{
13+
throw new InvalidOperationException("The request does not have a body.");
14+
}
15+
16+
var json = await request.Content.ReadAsStringAsync(cancellationToken);
17+
18+
return JsonDocument.Parse(json);
19+
}
20+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using Termii;
2+
3+
namespace Termii.Tests.Infrastructure;
4+
5+
internal static class TestTermiiClientFactory
6+
{
7+
public static TermiiClient Create(
8+
TestHttpMessageHandler handler,
9+
string apiKey = "test-api-key",
10+
string baseUrl = "https://example.test")
11+
{
12+
var httpClient = new HttpClient(handler)
13+
{
14+
BaseAddress = new Uri(baseUrl),
15+
};
16+
17+
return new TermiiClient(httpClient, new TermiiOptions
18+
{
19+
ApiKey = apiKey,
20+
BaseUrl = new Uri(baseUrl),
21+
});
22+
}
23+
}
Lines changed: 19 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using Termii;
2+
using Termii.Tests.Infrastructure;
23
using Microsoft.Extensions.DependencyInjection;
34
using System.Net;
4-
using System.Text.Json;
55
using Xunit;
66

77
namespace Termii.Tests;
@@ -33,38 +33,22 @@ public void ConstructorAcceptsValidOptions()
3333
[Fact]
3434
public async Task SendAsyncAddsApiKeyToQueryString()
3535
{
36-
using var handler = new RecordingHttpMessageHandler();
37-
using var httpClient = new HttpClient(handler)
38-
{
39-
BaseAddress = new Uri("https://example.test"),
40-
};
41-
var client = new TermiiClient(httpClient, new TermiiOptions
42-
{
43-
ApiKey = "test key",
44-
BaseUrl = new Uri("https://example.test"),
45-
});
36+
using var handler = new TestHttpMessageHandler();
37+
var client = TestTermiiClientFactory.Create(handler, apiKey: "test key");
4638

4739
using var response = await client.SendAsync(HttpMethod.Get, "/api/sms/inbox", cancellationToken: CancellationToken.None);
4840

4941
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
50-
Assert.Equal(HttpMethod.Get, handler.Request!.Method);
51-
Assert.Equal("https://example.test/api/sms/inbox?api_key=test%20key", handler.Request.RequestUri!.AbsoluteUri);
52-
Assert.Null(handler.Request.Content);
42+
Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method);
43+
Assert.Equal("https://example.test/api/sms/inbox?api_key=test%20key", handler.LastRequest.RequestUri!.AbsoluteUri);
44+
Assert.Null(handler.LastRequest.Content);
5345
}
5446

5547
[Fact]
5648
public async Task SendAsyncAddsApiKeyToJsonBody()
5749
{
58-
using var handler = new RecordingHttpMessageHandler();
59-
using var httpClient = new HttpClient(handler)
60-
{
61-
BaseAddress = new Uri("https://example.test"),
62-
};
63-
var client = new TermiiClient(httpClient, new TermiiOptions
64-
{
65-
ApiKey = "test-api-key",
66-
BaseUrl = new Uri("https://example.test"),
67-
});
50+
using var handler = new TestHttpMessageHandler();
51+
var client = TestTermiiClientFactory.Create(handler);
6852

6953
using var response = await client.SendAsync(
7054
HttpMethod.Post,
@@ -73,12 +57,13 @@ public async Task SendAsyncAddsApiKeyToJsonBody()
7357
TermiiAuthenticationLocation.Body,
7458
CancellationToken.None);
7559

76-
var json = await handler.Request!.Content!.ReadAsStringAsync(CancellationToken.None);
77-
using var document = JsonDocument.Parse(json);
60+
var request = handler.LastRequest;
61+
Assert.NotNull(request);
62+
using var document = await request.ReadJsonBodyAsync(CancellationToken.None);
7863

7964
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
80-
Assert.Equal(HttpMethod.Post, handler.Request.Method);
81-
Assert.Equal("https://example.test/api/sms/send", handler.Request.RequestUri!.ToString());
65+
Assert.Equal(HttpMethod.Post, request.Method);
66+
Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString());
8267
Assert.Equal("test-api-key", document.RootElement.GetProperty("api_key").GetString());
8368
Assert.Equal("2348012345678", document.RootElement.GetProperty("to").GetString());
8469
Assert.Equal("Termii", document.RootElement.GetProperty("from").GetString());
@@ -88,18 +73,10 @@ public async Task SendAsyncAddsApiKeyToJsonBody()
8873
[Fact]
8974
public async Task SendAsyncThrowsTermiiApiExceptionForJsonErrorResponses()
9075
{
91-
using var handler = new RecordingHttpMessageHandler(
76+
using var handler = new TestHttpMessageHandler(
9277
HttpStatusCode.BadRequest,
9378
"""{"message":"Invalid sender ID","code":"sender_id_invalid"}""");
94-
using var httpClient = new HttpClient(handler)
95-
{
96-
BaseAddress = new Uri("https://example.test"),
97-
};
98-
var client = new TermiiClient(httpClient, new TermiiOptions
99-
{
100-
ApiKey = "secret-api-key",
101-
BaseUrl = new Uri("https://example.test"),
102-
});
79+
var client = TestTermiiClientFactory.Create(handler, apiKey: "secret-api-key");
10380

10481
var exception = await Assert.ThrowsAsync<TermiiApiException>(() => client.SendAsync(
10582
HttpMethod.Post,
@@ -118,16 +95,8 @@ public async Task SendAsyncThrowsTermiiApiExceptionForJsonErrorResponses()
11895
[Fact]
11996
public async Task SendAsyncThrowsTermiiApiExceptionForPlainTextErrorResponses()
12097
{
121-
using var handler = new RecordingHttpMessageHandler(HttpStatusCode.InternalServerError, "Service unavailable");
122-
using var httpClient = new HttpClient(handler)
123-
{
124-
BaseAddress = new Uri("https://example.test"),
125-
};
126-
var client = new TermiiClient(httpClient, new TermiiOptions
127-
{
128-
ApiKey = "secret-api-key",
129-
BaseUrl = new Uri("https://example.test"),
130-
});
98+
using var handler = new TestHttpMessageHandler(HttpStatusCode.InternalServerError, "Service unavailable");
99+
var client = TestTermiiClientFactory.Create(handler, apiKey: "secret-api-key");
131100

132101
var exception = await Assert.ThrowsAsync<TermiiApiException>(() => client.SendAsync(
133102
HttpMethod.Get,
@@ -148,16 +117,8 @@ public async Task SendAsyncThrowsTermiiApiExceptionForPlainTextErrorResponses()
148117
[InlineData("https://example.test/api/sms/send")]
149118
public async Task SendAsyncRejectsInvalidPaths(string path)
150119
{
151-
using var handler = new RecordingHttpMessageHandler();
152-
using var httpClient = new HttpClient(handler)
153-
{
154-
BaseAddress = new Uri("https://example.test"),
155-
};
156-
var client = new TermiiClient(httpClient, new TermiiOptions
157-
{
158-
ApiKey = "test-api-key",
159-
BaseUrl = new Uri("https://example.test"),
160-
});
120+
using var handler = new TestHttpMessageHandler();
121+
var client = TestTermiiClientFactory.Create(handler);
161122

162123
await Assert.ThrowsAnyAsync<ArgumentException>(() => client.SendAsync(
163124
HttpMethod.Get,
@@ -185,32 +146,3 @@ public void AddTermiiRegistersConfiguredClient()
185146
Assert.Equal(TimeSpan.FromSeconds(30), client.Options.Timeout);
186147
}
187148
}
188-
189-
internal sealed class RecordingHttpMessageHandler : HttpMessageHandler, IDisposable
190-
{
191-
private readonly HttpStatusCode _statusCode;
192-
private readonly string _responseBody;
193-
194-
public RecordingHttpMessageHandler()
195-
: this(HttpStatusCode.OK, "{}")
196-
{
197-
}
198-
199-
public RecordingHttpMessageHandler(HttpStatusCode statusCode, string responseBody)
200-
{
201-
_statusCode = statusCode;
202-
_responseBody = responseBody;
203-
}
204-
205-
public HttpRequestMessage? Request { get; private set; }
206-
207-
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
208-
{
209-
Request = request;
210-
211-
return Task.FromResult(new HttpResponseMessage(_statusCode)
212-
{
213-
Content = new StringContent(_responseBody),
214-
});
215-
}
216-
}

0 commit comments

Comments
 (0)