diff --git a/README.md b/README.md index e425e66..fa5e987 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ var token = await client.Tokens.SendAsync(new SendTokenRequest }); ``` +Check account balance: + +```csharp +var balance = await client.Insights.GetBalanceAsync(); +``` + ## Examples Run the examples project after setting your Termii API key: diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index f8765c4..2b17c02 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -46,17 +46,17 @@ The Termii docs describe a REST/JSON API and state that each account has its own | 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 | | Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | Implemented | #5, PR #25 | Unit tests added | -| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Send voice OTP | POST | `/api/sms/otp/send/voice` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Send voice call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Insights | Get balance | GET | `/api/get-balance` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | -| Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | -| Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | -| Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | +| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send voice OTP | POST | `/api/sms/otp/send/voice` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send voice call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Insights | Get balance | GET | `/api/get-balance` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | +| Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | +| Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | +| Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | | Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Needs verification | #9 | Planned unit + optional integration | ## Deferred Coverage diff --git a/src/Termii/Insights/CheckDndRequest.cs b/src/Termii/Insights/CheckDndRequest.cs new file mode 100644 index 0000000..6583920 --- /dev/null +++ b/src/Termii/Insights/CheckDndRequest.cs @@ -0,0 +1,16 @@ +namespace Termii; + +public sealed class CheckDndRequest +{ + public string PhoneNumber { get; set; } = string.Empty; + + internal string ToPath() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber); + + return TermiiInsightsQueryString.Append("/api/check/dnd", query); + } +} diff --git a/src/Termii/Insights/CheckDndResponse.cs b/src/Termii/Insights/CheckDndResponse.cs new file mode 100644 index 0000000..ad683b6 --- /dev/null +++ b/src/Termii/Insights/CheckDndResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class CheckDndResponse +{ + [JsonPropertyName("number")] + public string? Number { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("network")] + public string? Network { get; set; } + + [JsonPropertyName("dnd_active")] + public bool? DndActive { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/GetBalanceResponse.cs b/src/Termii/Insights/GetBalanceResponse.cs new file mode 100644 index 0000000..590a5e0 --- /dev/null +++ b/src/Termii/Insights/GetBalanceResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class GetBalanceResponse +{ + [JsonPropertyName("user")] + public string? User { get; set; } + + [JsonPropertyName("balance")] + public decimal? Balance { get; set; } + + [JsonPropertyName("currency")] + public string? Currency { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/GetMessageHistoryRequest.cs b/src/Termii/Insights/GetMessageHistoryRequest.cs new file mode 100644 index 0000000..7ad4778 --- /dev/null +++ b/src/Termii/Insights/GetMessageHistoryRequest.cs @@ -0,0 +1,35 @@ +namespace Termii; + +public sealed class GetMessageHistoryRequest +{ + public int? Page { get; set; } + + public int? Size { get; set; } + + public string? Sender { get; set; } + + public string? Receiver { get; set; } + + public string? MessageId { get; set; } + + public string? Status { get; set; } + + public string? FromDate { get; set; } + + public string? ToDate { get; set; } + + internal string ToPath() + { + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "page", Page); + TermiiInsightsQueryString.AddIfPresent(query, "size", Size); + TermiiInsightsQueryString.AddIfPresent(query, "sender", Sender); + TermiiInsightsQueryString.AddIfPresent(query, "receiver", Receiver); + TermiiInsightsQueryString.AddIfPresent(query, "message_id", MessageId); + TermiiInsightsQueryString.AddIfPresent(query, "status", Status); + TermiiInsightsQueryString.AddIfPresent(query, "from", FromDate); + TermiiInsightsQueryString.AddIfPresent(query, "to", ToDate); + + return TermiiInsightsQueryString.Append("/api/sms/inbox", query); + } +} diff --git a/src/Termii/Insights/ITermiiInsightsClient.cs b/src/Termii/Insights/ITermiiInsightsClient.cs new file mode 100644 index 0000000..fe99310 --- /dev/null +++ b/src/Termii/Insights/ITermiiInsightsClient.cs @@ -0,0 +1,18 @@ +namespace Termii; + +public interface ITermiiInsightsClient +{ + Task GetBalanceAsync(CancellationToken cancellationToken = default); + + Task CheckDndAsync( + CheckDndRequest request, + CancellationToken cancellationToken = default); + + Task QueryNumberAsync( + QueryNumberRequest request, + CancellationToken cancellationToken = default); + + Task GetMessageHistoryAsync( + GetMessageHistoryRequest? request = null, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Insights/MessageHistoryRecord.cs b/src/Termii/Insights/MessageHistoryRecord.cs new file mode 100644 index 0000000..878abec --- /dev/null +++ b/src/Termii/Insights/MessageHistoryRecord.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class MessageHistoryRecord +{ + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("sender")] + public string? Sender { get; set; } + + [JsonPropertyName("receiver")] + public string? Receiver { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("amount")] + public decimal? Amount { get; set; } + + [JsonPropertyName("reroute")] + public int? Reroute { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("sms_type")] + public string? SmsType { get; set; } + + [JsonPropertyName("send_by")] + public string? SentBy { get; set; } + + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/MessageHistoryResponse.cs b/src/Termii/Insights/MessageHistoryResponse.cs new file mode 100644 index 0000000..e365ef6 --- /dev/null +++ b/src/Termii/Insights/MessageHistoryResponse.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class MessageHistoryResponse +{ + [JsonPropertyName("content")] + public IReadOnlyList Content { get; set; } = Array.Empty(); + + [JsonPropertyName("totalElements")] + public int? TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int? TotalPages { get; set; } + + [JsonPropertyName("last")] + public bool? Last { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonPropertyName("first")] + public bool? First { get; set; } + + [JsonPropertyName("empty")] + public bool? Empty { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/QueryNumberRequest.cs b/src/Termii/Insights/QueryNumberRequest.cs new file mode 100644 index 0000000..e00f1b1 --- /dev/null +++ b/src/Termii/Insights/QueryNumberRequest.cs @@ -0,0 +1,19 @@ +namespace Termii; + +public sealed class QueryNumberRequest +{ + public string PhoneNumber { get; set; } = string.Empty; + + public string? CountryCode { get; set; } + + internal string ToPath() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber); + TermiiInsightsQueryString.AddIfPresent(query, "country_code", CountryCode); + + return TermiiInsightsQueryString.Append("/api/insight/number/query", query); + } +} diff --git a/src/Termii/Insights/QueryNumberResponse.cs b/src/Termii/Insights/QueryNumberResponse.cs new file mode 100644 index 0000000..a108030 --- /dev/null +++ b/src/Termii/Insights/QueryNumberResponse.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class QueryNumberResponse +{ + [JsonPropertyName("number")] + public string? Number { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("network")] + public string? Network { get; set; } + + [JsonPropertyName("network_code")] + public string? NetworkCode { get; set; } + + [JsonPropertyName("country_code")] + public string? CountryCode { get; set; } + + [JsonPropertyName("country_name")] + public string? CountryName { get; set; } + + [JsonPropertyName("ported")] + public bool? Ported { get; set; } + + [JsonPropertyName("roaming")] + public bool? Roaming { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/TermiiInsightsClient.cs b/src/Termii/Insights/TermiiInsightsClient.cs new file mode 100644 index 0000000..024190b --- /dev/null +++ b/src/Termii/Insights/TermiiInsightsClient.cs @@ -0,0 +1,67 @@ +namespace Termii; + +internal sealed class TermiiInsightsClient : ITermiiInsightsClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiInsightsClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task GetBalanceAsync(CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + "/api/get-balance", + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task CheckDndAsync( + CheckDndRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return _pipeline.SendJsonAsync( + HttpMethod.Get, + request.ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task QueryNumberAsync( + QueryNumberRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return _pipeline.SendJsonAsync( + HttpMethod.Get, + request.ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task GetMessageHistoryAsync( + GetMessageHistoryRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetMessageHistoryRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } +} diff --git a/src/Termii/Insights/TermiiInsightsQueryString.cs b/src/Termii/Insights/TermiiInsightsQueryString.cs new file mode 100644 index 0000000..b86862c --- /dev/null +++ b/src/Termii/Insights/TermiiInsightsQueryString.cs @@ -0,0 +1,27 @@ +namespace Termii; + +internal static class TermiiInsightsQueryString +{ + public static void AddIfPresent(List query, string name, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + query.Add($"{name}={Uri.EscapeDataString(value)}"); + } + } + + public static void AddIfPresent(List query, string name, int? value) + { + if (value.HasValue) + { + query.Add($"{name}={value.Value}"); + } + } + + public static string Append(string path, List query) + { + return query.Count == 0 + ? path + : $"{path}?{string.Join("&", query)}"; + } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index fbd11b4..cd4a3bd 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -38,6 +38,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp SenderIds = new TermiiSenderIdClient(_pipeline); Numbers = new TermiiNumberClient(_pipeline); Tokens = new TermiiTokenClient(_pipeline); + Insights = new TermiiInsightsClient(_pipeline); } public TermiiOptions Options { get; } @@ -50,6 +51,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp public ITermiiTokenClient Tokens { get; } + public ITermiiInsightsClient Insights { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 30c163c..0f3b046 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -21,5 +21,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.NotNull(client.SenderIds); Assert.NotNull(client.Numbers); Assert.NotNull(client.Tokens); + Assert.NotNull(client.Insights); } } diff --git a/tests/Termii.Tests/TermiiInsightsClientTests.cs b/tests/Termii.Tests/TermiiInsightsClientTests.cs new file mode 100644 index 0000000..041b450 --- /dev/null +++ b/tests/Termii.Tests/TermiiInsightsClientTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiInsightsClientTests +{ + [Fact] + public async Task GetBalanceAsyncAddsApiKeyAndDeserializesBalance() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"user":"termii-user","balance":"125.50","currency":"NGN"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.GetBalanceAsync(CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("https://example.test/api/get-balance?api_key=test-api-key", request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal("termii-user", response.User); + Assert.Equal(125.50m, response.Balance); + Assert.Equal("NGN", response.Currency); + } + + [Fact] + public async Task CheckDndAsyncAddsPhoneNumberAndApiKey() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"phone_number":"2348012345678","status":"DND","network":"MTN","dnd_active":true}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.CheckDndAsync( + new CheckDndRequest + { + PhoneNumber = "2348012345678", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal( + "https://example.test/api/check/dnd?phone_number=2348012345678&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal("2348012345678", response.PhoneNumber); + Assert.Equal("DND", response.Status); + Assert.Equal("MTN", response.Network); + Assert.True(response.DndActive); + } + + [Fact] + public async Task QueryNumberAsyncAddsCountryCodeAndDeserializesStatus() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"number":"2348012345678","status":"active","network":"MTN","country_code":"NG","country_name":"Nigeria","ported":false}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.QueryNumberAsync( + new QueryNumberRequest + { + PhoneNumber = "2348012345678", + CountryCode = "NG", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal( + "https://example.test/api/insight/number/query?phone_number=2348012345678&country_code=NG&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal("active", response.Status); + Assert.Equal("MTN", response.Network); + Assert.Equal("Nigeria", response.CountryName); + Assert.False(response.Ported); + } + + [Fact] + public async Task GetMessageHistoryAsyncAddsFiltersAndDeserializesContent() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "content": [ + { + "message_id": "msg-123", + "sender": "Termii", + "receiver": "2348012345678", + "message": "Hello", + "amount": "4.25", + "reroute": 0, + "status": "sent", + "sms_type": "plain", + "send_by": "api", + "created_at": "2026-06-13 10:00:00", + "updated_at": "2026-06-13 10:01:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "size": 15, + "number": 0, + "first": true, + "last": true, + "empty": false + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.GetMessageHistoryAsync( + new GetMessageHistoryRequest + { + Page = 0, + Size = 15, + Sender = "Termii", + Receiver = "2348012345678", + Status = "sent", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var message = Assert.Single(response.Content); + + Assert.Equal( + "https://example.test/api/sms/inbox?page=0&size=15&sender=Termii&receiver=2348012345678&status=sent&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal(1, response.TotalElements); + Assert.Equal("msg-123", message.MessageId); + Assert.Equal("Termii", message.Sender); + Assert.Equal("2348012345678", message.Receiver); + Assert.Equal(4.25m, message.Amount); + Assert.Equal("sent", message.Status); + } + + [Fact] + public async Task CheckDndAsyncRejectsMissingPhoneNumber() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Insights.CheckDndAsync( + new CheckDndRequest + { + PhoneNumber = "", + }, + CancellationToken.None)); + } +}