diff --git a/README.md b/README.md index 6935849..85ca782 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,12 @@ var response = await client.Messaging.SendAsync(new SendMessageRequest }); ``` +Fetch sender IDs: + +```csharp +var senderIds = await client.SenderIds.GetAsync(); +``` + ## Examples Run the examples project after setting your Termii API key: diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index e153d47..6f09efb 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -40,8 +40,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Area | Capability | Method | Path | Auth placement | SDK surface | SDK status | Tracking | Test status | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | Planned | #3 | Planned unit + optional integration | -| Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | Planned | #3 | Planned unit + optional integration | +| Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | In progress | #3 | Unit tests added | +| Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | In progress | #3 | Unit tests added | | Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | diff --git a/src/Termii/SenderIds/GetSenderIdsRequest.cs b/src/Termii/SenderIds/GetSenderIdsRequest.cs new file mode 100644 index 0000000..6096906 --- /dev/null +++ b/src/Termii/SenderIds/GetSenderIdsRequest.cs @@ -0,0 +1,27 @@ +namespace Termii; + +public sealed class GetSenderIdsRequest +{ + public string? SenderId { get; set; } + + public string? Status { get; set; } + + internal string ToPath() + { + var query = new List(); + + if (!string.IsNullOrWhiteSpace(SenderId)) + { + query.Add($"sender_id={Uri.EscapeDataString(SenderId)}"); + } + + if (!string.IsNullOrWhiteSpace(Status)) + { + query.Add($"status={Uri.EscapeDataString(Status)}"); + } + + return query.Count == 0 + ? "/api/sender-id" + : $"/api/sender-id?{string.Join("&", query)}"; + } +} diff --git a/src/Termii/SenderIds/ITermiiSenderIdClient.cs b/src/Termii/SenderIds/ITermiiSenderIdClient.cs new file mode 100644 index 0000000..c9382ca --- /dev/null +++ b/src/Termii/SenderIds/ITermiiSenderIdClient.cs @@ -0,0 +1,12 @@ +namespace Termii; + +public interface ITermiiSenderIdClient +{ + Task GetAsync( + GetSenderIdsRequest? request = null, + CancellationToken cancellationToken = default); + + Task RequestAsync( + RequestSenderIdRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/SenderIds/RequestSenderIdRequest.cs b/src/Termii/SenderIds/RequestSenderIdRequest.cs new file mode 100644 index 0000000..e1850d3 --- /dev/null +++ b/src/Termii/SenderIds/RequestSenderIdRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class RequestSenderIdRequest +{ + [JsonPropertyName("sender_id")] + public string SenderId { get; set; } = string.Empty; + + [JsonPropertyName("use_case")] + public string UseCase { get; set; } = string.Empty; + + [JsonPropertyName("company")] + public string Company { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(SenderId, nameof(SenderId)); + TermiiRequestValidation.Required(UseCase, nameof(UseCase)); + TermiiRequestValidation.Required(Company, nameof(Company)); + } +} diff --git a/src/Termii/SenderIds/RequestSenderIdResponse.cs b/src/Termii/SenderIds/RequestSenderIdResponse.cs new file mode 100644 index 0000000..2f2c056 --- /dev/null +++ b/src/Termii/SenderIds/RequestSenderIdResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class RequestSenderIdResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/src/Termii/SenderIds/SenderIdListResponse.cs b/src/Termii/SenderIds/SenderIdListResponse.cs new file mode 100644 index 0000000..e14922e --- /dev/null +++ b/src/Termii/SenderIds/SenderIdListResponse.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SenderIdListResponse +{ + [JsonPropertyName("content")] + public IReadOnlyCollection Content { get; set; } = Array.Empty(); + + [JsonPropertyName("totalElements")] + public int? TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int? TotalPages { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonPropertyName("first")] + public bool? First { get; set; } + + [JsonPropertyName("last")] + public bool? Last { get; set; } + + [JsonPropertyName("empty")] + public bool? Empty { get; set; } +} diff --git a/src/Termii/SenderIds/SenderIdRecord.cs b/src/Termii/SenderIds/SenderIdRecord.cs new file mode 100644 index 0000000..746bb2c --- /dev/null +++ b/src/Termii/SenderIds/SenderIdRecord.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SenderIdRecord +{ + [JsonPropertyName("sender_id")] + public string? SenderId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("country")] + public string? Country { get; set; } + + [JsonPropertyName("company")] + public string? Company { get; set; } + + [JsonPropertyName("usecase")] + public string? UseCase { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } +} diff --git a/src/Termii/SenderIds/TermiiSenderIdClient.cs b/src/Termii/SenderIds/TermiiSenderIdClient.cs new file mode 100644 index 0000000..0affe28 --- /dev/null +++ b/src/Termii/SenderIds/TermiiSenderIdClient.cs @@ -0,0 +1,42 @@ +namespace Termii; + +internal sealed class TermiiSenderIdClient : ITermiiSenderIdClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiSenderIdClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task GetAsync( + GetSenderIdsRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetSenderIdsRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task RequestAsync( + RequestSenderIdRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sender-id/request", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index a57e19f..f546976 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -35,12 +35,15 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp _pipeline = new TermiiJsonHttpPipeline(_httpClient, Options); Messaging = new TermiiMessagingClient(_pipeline); + SenderIds = new TermiiSenderIdClient(_pipeline); } public TermiiOptions Options { get; } public ITermiiMessagingClient Messaging { get; } + public ITermiiSenderIdClient SenderIds { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 7a7fcd6..1d1065c 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -18,5 +18,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.False(string.IsNullOrWhiteSpace(client.Options.ApiKey)); Assert.True(client.Options.BaseUrl.IsAbsoluteUri); Assert.NotNull(client.Messaging); + Assert.NotNull(client.SenderIds); } } diff --git a/tests/Termii.Tests/TermiiSenderIdClientTests.cs b/tests/Termii.Tests/TermiiSenderIdClientTests.cs new file mode 100644 index 0000000..6fdeb27 --- /dev/null +++ b/tests/Termii.Tests/TermiiSenderIdClientTests.cs @@ -0,0 +1,110 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiSenderIdClientTests +{ + [Fact] + public async Task GetAsyncAddsApiKeyAndDeserializesSenderIds() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "content": [ + { + "sender_id": "Termii", + "status": "active", + "country": "NG", + "company": "Teesoft Tech", + "usecase": "Transactional alerts", + "createdAt": "2026-06-13 10:00:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "size": 15, + "number": 0, + "first": true, + "last": true, + "empty": false + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.SenderIds.GetAsync( + new GetSenderIdsRequest + { + SenderId = "Termii", + Status = "active", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var senderId = Assert.Single(response.Content); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal( + "https://example.test/api/sender-id?sender_id=Termii&status=active&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal(1, response.TotalElements); + Assert.Equal("Termii", senderId.SenderId); + Assert.Equal("active", senderId.Status); + Assert.Equal("NG", senderId.Country); + Assert.Equal("Teesoft Tech", senderId.Company); + Assert.Equal("Transactional alerts", senderId.UseCase); + Assert.Equal("2026-06-13 10:00:00", senderId.CreatedAt); + } + + [Fact] + public async Task RequestAsyncPostsRequestBodyAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Sender ID request received"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.SenderIds.RequestAsync( + new RequestSenderIdRequest + { + SenderId = "Termii", + UseCase = "Transactional alerts", + Company = "Teesoft Tech", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sender-id/request", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("Termii", body.RootElement.GetProperty("sender_id").GetString()); + Assert.Equal("Transactional alerts", body.RootElement.GetProperty("use_case").GetString()); + Assert.Equal("Teesoft Tech", body.RootElement.GetProperty("company").GetString()); + Assert.Equal("ok", response.Code); + Assert.Equal("Sender ID request received", response.Message); + } + + [Fact] + public async Task RequestAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.SenderIds.RequestAsync( + new RequestSenderIdRequest + { + SenderId = "", + UseCase = "Transactional alerts", + Company = "Teesoft Tech", + }, + CancellationToken.None)); + } +}