Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ obj/
.vs/
.vscode/
.idea/
.DS_Store
TestResults/
coverage/
*.user
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
4 changes: 2 additions & 2 deletions docs/API_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
34 changes: 34 additions & 0 deletions tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
17 changes: 2 additions & 15 deletions tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
44 changes: 44 additions & 0 deletions tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Net;

namespace Termii.Tests.Infrastructure;

internal sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new();

public TestHttpMessageHandler()
: this(HttpStatusCode.OK, "{}")
{
}

public TestHttpMessageHandler(HttpStatusCode statusCode, string responseBody)
{
Enqueue(statusCode, responseBody);
}

public IReadOnlyList<HttpRequestMessage> Requests => _requests;

public HttpRequestMessage? LastRequest => _requests.Count == 0 ? null : _requests[^1];

private readonly List<HttpRequestMessage> _requests = new();

public void Enqueue(HttpStatusCode statusCode, string responseBody)
{
_responses.Enqueue(new HttpResponseMessage(statusCode)
{
Content = new StringContent(responseBody),
});
}

protected override Task<HttpResponseMessage> 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());
}
}
20 changes: 20 additions & 0 deletions tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Text.Json;

namespace Termii.Tests.Infrastructure;

internal static class TestRequestExtensions
{
public static async Task<JsonDocument> 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);
}
}
23 changes: 23 additions & 0 deletions tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs
Original file line number Diff line number Diff line change
@@ -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),
});
}
}
106 changes: 19 additions & 87 deletions tests/Termii.Tests/TermiiClientTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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());
Expand All @@ -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<TermiiApiException>(() => client.SendAsync(
HttpMethod.Post,
Expand All @@ -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<TermiiApiException>(() => client.SendAsync(
HttpMethod.Get,
Expand All @@ -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<ArgumentException>(() => client.SendAsync(
HttpMethod.Get,
Expand Down Expand Up @@ -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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;

return Task.FromResult(new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_responseBody),
});
}
}
Loading