From a2e8e89fcb375569df97e89de0eacc1d7a6ac5df Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 22:31:34 +0100 Subject: [PATCH] Add Termii Token API client --- README.md | 16 ++ docs/API_COVERAGE.md | 15 +- src/Termii/TermiiClient.cs | 3 + src/Termii/TermiiRequestValidation.cs | 19 ++ src/Termii/Tokens/GenerateTokenRequest.cs | 29 +++ src/Termii/Tokens/GenerateTokenResponse.cs | 18 ++ src/Termii/Tokens/ITermiiTokenClient.cs | 32 +++ src/Termii/Tokens/SendEmailTokenRequest.cs | 22 ++ src/Termii/Tokens/SendTokenRequest.cs | 44 ++++ src/Termii/Tokens/SendTokenResponse.cs | 27 ++ src/Termii/Tokens/SendVoiceTokenRequest.cs | 26 ++ src/Termii/Tokens/SendWhatsAppTokenRequest.cs | 25 ++ src/Termii/Tokens/TermiiTokenClient.cs | 246 ++++++++++++++++++ .../Tokens/TermiiTokenEnumExtensions.cs | 14 + src/Termii/Tokens/TermiiTokenPinType.cs | 7 + src/Termii/Tokens/VerifyTokenRequest.cs | 18 ++ src/Termii/Tokens/VerifyTokenResponse.cs | 15 ++ src/Termii/Tokens/VoiceCallTokenRequest.cs | 18 ++ .../TermiiClientIntegrationTests.cs | 1 + tests/Termii.Tests/TermiiTokenClientTests.cs | 231 ++++++++++++++++ 20 files changed, 819 insertions(+), 7 deletions(-) create mode 100644 src/Termii/Tokens/GenerateTokenRequest.cs create mode 100644 src/Termii/Tokens/GenerateTokenResponse.cs create mode 100644 src/Termii/Tokens/ITermiiTokenClient.cs create mode 100644 src/Termii/Tokens/SendEmailTokenRequest.cs create mode 100644 src/Termii/Tokens/SendTokenRequest.cs create mode 100644 src/Termii/Tokens/SendTokenResponse.cs create mode 100644 src/Termii/Tokens/SendVoiceTokenRequest.cs create mode 100644 src/Termii/Tokens/SendWhatsAppTokenRequest.cs create mode 100644 src/Termii/Tokens/TermiiTokenClient.cs create mode 100644 src/Termii/Tokens/TermiiTokenEnumExtensions.cs create mode 100644 src/Termii/Tokens/TermiiTokenPinType.cs create mode 100644 src/Termii/Tokens/VerifyTokenRequest.cs create mode 100644 src/Termii/Tokens/VerifyTokenResponse.cs create mode 100644 src/Termii/Tokens/VoiceCallTokenRequest.cs create mode 100644 tests/Termii.Tests/TermiiTokenClientTests.cs diff --git a/README.md b/README.md index 229defc..e425e66 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,22 @@ var numberMessage = await client.Numbers.SendAsync(new SendNumberMessageRequest }); ``` +Send an OTP token: + +```csharp +var token = await client.Tokens.SendAsync(new SendTokenRequest +{ + To = "2348012345678", + From = "Termii", + Channel = TermiiMessageChannel.Generic, + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6, + PinPlaceholder = "< 123456 >", + MessageText = "Your verification code is < 123456 >" +}); +``` + ## Examples Run the examples project after setting your Termii API key: diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 0d4055a..f8765c4 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -45,13 +45,14 @@ The Termii docs describe a REST/JSON API and state that each account has its own | 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 | -| Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | In progress | #5 | Unit tests added | -| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | -| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | -| Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | -| Token | Send voice OTP/call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | -| Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | -| Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | +| 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 | diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index a919b83..fbd11b4 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -37,6 +37,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp Messaging = new TermiiMessagingClient(_pipeline); SenderIds = new TermiiSenderIdClient(_pipeline); Numbers = new TermiiNumberClient(_pipeline); + Tokens = new TermiiTokenClient(_pipeline); } public TermiiOptions Options { get; } @@ -47,6 +48,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp public ITermiiNumberClient Numbers { get; } + public ITermiiTokenClient Tokens { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/src/Termii/TermiiRequestValidation.cs b/src/Termii/TermiiRequestValidation.cs index 20ea8c1..992a603 100644 --- a/src/Termii/TermiiRequestValidation.cs +++ b/src/Termii/TermiiRequestValidation.cs @@ -9,4 +9,23 @@ public static void Required(string? value, string parameterName) throw new ArgumentException($"{parameterName} is required.", parameterName); } } + + public static void Positive(int value, string parameterName) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(parameterName, value, $"{parameterName} must be greater than zero."); + } + } + + public static void Range(int value, int minimum, int maximum, string parameterName) + { + if (value < minimum || value > maximum) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + $"{parameterName} must be between {minimum} and {maximum}."); + } + } } diff --git a/src/Termii/Tokens/GenerateTokenRequest.cs b/src/Termii/Tokens/GenerateTokenRequest.cs new file mode 100644 index 0000000..944d6f2 --- /dev/null +++ b/src/Termii/Tokens/GenerateTokenRequest.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class GenerateTokenRequest +{ + [JsonIgnore] + public TermiiTokenPinType PinType { get; set; } = TermiiTokenPinType.Numeric; + + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; set; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; set; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts)); + TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive)); + TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength)); + } +} diff --git a/src/Termii/Tokens/GenerateTokenResponse.cs b/src/Termii/Tokens/GenerateTokenResponse.cs new file mode 100644 index 0000000..8298cd2 --- /dev/null +++ b/src/Termii/Tokens/GenerateTokenResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class GenerateTokenResponse +{ + [JsonPropertyName("phone_number_other")] + public string? PhoneNumberOther { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("otp")] + public string? Otp { get; set; } + + [JsonPropertyName("pin_id")] + public string? PinId { get; set; } +} diff --git a/src/Termii/Tokens/ITermiiTokenClient.cs b/src/Termii/Tokens/ITermiiTokenClient.cs new file mode 100644 index 0000000..b072182 --- /dev/null +++ b/src/Termii/Tokens/ITermiiTokenClient.cs @@ -0,0 +1,32 @@ +namespace Termii; + +public interface ITermiiTokenClient +{ + Task SendAsync( + SendTokenRequest request, + CancellationToken cancellationToken = default); + + Task VerifyAsync( + VerifyTokenRequest request, + CancellationToken cancellationToken = default); + + Task GenerateAsync( + GenerateTokenRequest request, + CancellationToken cancellationToken = default); + + Task SendVoiceAsync( + SendVoiceTokenRequest request, + CancellationToken cancellationToken = default); + + Task CallAsync( + VoiceCallTokenRequest request, + CancellationToken cancellationToken = default); + + Task SendEmailAsync( + SendEmailTokenRequest request, + CancellationToken cancellationToken = default); + + Task SendWhatsAppAsync( + SendWhatsAppTokenRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Tokens/SendEmailTokenRequest.cs b/src/Termii/Tokens/SendEmailTokenRequest.cs new file mode 100644 index 0000000..eb538ad --- /dev/null +++ b/src/Termii/Tokens/SendEmailTokenRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendEmailTokenRequest +{ + [JsonPropertyName("email_address")] + public string EmailAddress { get; set; } = string.Empty; + + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("email_configuration_id")] + public string EmailConfigurationId { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(EmailAddress, nameof(EmailAddress)); + TermiiRequestValidation.Required(Code, nameof(Code)); + TermiiRequestValidation.Required(EmailConfigurationId, nameof(EmailConfigurationId)); + } +} diff --git a/src/Termii/Tokens/SendTokenRequest.cs b/src/Termii/Tokens/SendTokenRequest.cs new file mode 100644 index 0000000..4c23176 --- /dev/null +++ b/src/Termii/Tokens/SendTokenRequest.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendTokenRequest +{ + [JsonIgnore] + public TermiiTokenPinType PinType { get; set; } = TermiiTokenPinType.Numeric; + + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonIgnore] + public TermiiMessageChannel Channel { get; set; } = TermiiMessageChannel.Generic; + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; set; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; set; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; set; } + + [JsonPropertyName("pin_placeholder")] + public string PinPlaceholder { get; set; } = string.Empty; + + [JsonPropertyName("message_text")] + public string MessageText { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(To, nameof(To)); + TermiiRequestValidation.Required(From, nameof(From)); + TermiiRequestValidation.Required(PinPlaceholder, nameof(PinPlaceholder)); + TermiiRequestValidation.Required(MessageText, nameof(MessageText)); + TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts)); + TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive)); + TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength)); + } +} diff --git a/src/Termii/Tokens/SendTokenResponse.cs b/src/Termii/Tokens/SendTokenResponse.cs new file mode 100644 index 0000000..da457f0 --- /dev/null +++ b/src/Termii/Tokens/SendTokenResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendTokenResponse +{ + [JsonPropertyName("smsStatus")] + public string? SmsStatus { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("to")] + public string? To { get; set; } + + [JsonPropertyName("pinId")] + public string? PinId { get; set; } + + [JsonPropertyName("pin_id")] + public string? PinIdSnakeCase { get; set; } + + [JsonPropertyName("message_id_str")] + public string? MessageIdString { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } +} diff --git a/src/Termii/Tokens/SendVoiceTokenRequest.cs b/src/Termii/Tokens/SendVoiceTokenRequest.cs new file mode 100644 index 0000000..e3f32ee --- /dev/null +++ b/src/Termii/Tokens/SendVoiceTokenRequest.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendVoiceTokenRequest +{ + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; set; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; set; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts)); + TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive)); + TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength)); + } +} diff --git a/src/Termii/Tokens/SendWhatsAppTokenRequest.cs b/src/Termii/Tokens/SendWhatsAppTokenRequest.cs new file mode 100644 index 0000000..777bb6a --- /dev/null +++ b/src/Termii/Tokens/SendWhatsAppTokenRequest.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendWhatsAppTokenRequest +{ + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonPropertyName("sms")] + public string Sms { get; set; } = string.Empty; + + [JsonIgnore] + public TermiiMessageType Type { get; set; } = TermiiMessageType.Plain; + + internal void Validate() + { + TermiiRequestValidation.Required(To, nameof(To)); + TermiiRequestValidation.Required(From, nameof(From)); + TermiiRequestValidation.Required(Sms, nameof(Sms)); + } +} diff --git a/src/Termii/Tokens/TermiiTokenClient.cs b/src/Termii/Tokens/TermiiTokenClient.cs new file mode 100644 index 0000000..e0a92da --- /dev/null +++ b/src/Termii/Tokens/TermiiTokenClient.cs @@ -0,0 +1,246 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +internal sealed class TermiiTokenClient : ITermiiTokenClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiTokenClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task SendAsync( + SendTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/send", + new SendTokenPayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task VerifyAsync( + VerifyTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/verify", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task GenerateAsync( + GenerateTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/generate", + new GenerateTokenPayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendVoiceAsync( + SendVoiceTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/send/voice", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task CallAsync( + VoiceCallTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/call", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendEmailAsync( + SendEmailTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/email/otp/send", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendWhatsAppAsync( + SendWhatsAppTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/send", + new SendWhatsAppTokenPayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + private sealed class SendTokenPayload + { + public SendTokenPayload(SendTokenRequest request) + { + MessageType = request.PinType.ToWireValue(); + PinType = request.PinType.ToWireValue(); + To = request.To; + From = request.From; + Channel = request.Channel.ToWireValue(); + PinAttempts = request.PinAttempts; + PinTimeToLive = request.PinTimeToLive; + PinLength = request.PinLength; + PinPlaceholder = request.PinPlaceholder; + MessageText = request.MessageText; + } + + [JsonPropertyName("message_type")] + public string MessageType { get; } + + [JsonPropertyName("pin_type")] + public string PinType { get; } + + [JsonPropertyName("to")] + public string To { get; } + + [JsonPropertyName("from")] + public string From { get; } + + [JsonPropertyName("channel")] + public string Channel { get; } + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; } + + [JsonPropertyName("pin_placeholder")] + public string PinPlaceholder { get; } + + [JsonPropertyName("message_text")] + public string MessageText { get; } + } + + private sealed class GenerateTokenPayload + { + public GenerateTokenPayload(GenerateTokenRequest request) + { + PinType = request.PinType.ToWireValue(); + PhoneNumber = request.PhoneNumber; + PinAttempts = request.PinAttempts; + PinTimeToLive = request.PinTimeToLive; + PinLength = request.PinLength; + } + + [JsonPropertyName("pin_type")] + public string PinType { get; } + + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; } + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; } + } + + private sealed class SendWhatsAppTokenPayload + { + public SendWhatsAppTokenPayload(SendWhatsAppTokenRequest request) + { + To = request.To; + From = request.From; + Sms = request.Sms; + Type = request.Type.ToWireValue(); + } + + [JsonPropertyName("to")] + public string To { get; } + + [JsonPropertyName("from")] + public string From { get; } + + [JsonPropertyName("sms")] + public string Sms { get; } + + [JsonPropertyName("type")] + public string Type { get; } + + [JsonPropertyName("channel")] + public string Channel => "whatsapp_otp"; + } +} diff --git a/src/Termii/Tokens/TermiiTokenEnumExtensions.cs b/src/Termii/Tokens/TermiiTokenEnumExtensions.cs new file mode 100644 index 0000000..aed901a --- /dev/null +++ b/src/Termii/Tokens/TermiiTokenEnumExtensions.cs @@ -0,0 +1,14 @@ +namespace Termii; + +internal static class TermiiTokenEnumExtensions +{ + public static string ToWireValue(this TermiiTokenPinType pinType) + { + return pinType switch + { + TermiiTokenPinType.Numeric => "NUMERIC", + TermiiTokenPinType.Alphanumeric => "ALPHANUMERIC", + _ => throw new ArgumentOutOfRangeException(nameof(pinType), pinType, "Unsupported Termii token PIN type."), + }; + } +} diff --git a/src/Termii/Tokens/TermiiTokenPinType.cs b/src/Termii/Tokens/TermiiTokenPinType.cs new file mode 100644 index 0000000..fb0dc87 --- /dev/null +++ b/src/Termii/Tokens/TermiiTokenPinType.cs @@ -0,0 +1,7 @@ +namespace Termii; + +public enum TermiiTokenPinType +{ + Numeric, + Alphanumeric, +} diff --git a/src/Termii/Tokens/VerifyTokenRequest.cs b/src/Termii/Tokens/VerifyTokenRequest.cs new file mode 100644 index 0000000..2798c54 --- /dev/null +++ b/src/Termii/Tokens/VerifyTokenRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class VerifyTokenRequest +{ + [JsonPropertyName("pin_id")] + public string PinId { get; set; } = string.Empty; + + [JsonPropertyName("pin")] + public string Pin { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(PinId, nameof(PinId)); + TermiiRequestValidation.Required(Pin, nameof(Pin)); + } +} diff --git a/src/Termii/Tokens/VerifyTokenResponse.cs b/src/Termii/Tokens/VerifyTokenResponse.cs new file mode 100644 index 0000000..16b5ad7 --- /dev/null +++ b/src/Termii/Tokens/VerifyTokenResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class VerifyTokenResponse +{ + [JsonPropertyName("pinId")] + public string? PinId { get; set; } + + [JsonPropertyName("verified")] + public string? Verified { get; set; } + + [JsonPropertyName("msisdn")] + public string? Msisdn { get; set; } +} diff --git a/src/Termii/Tokens/VoiceCallTokenRequest.cs b/src/Termii/Tokens/VoiceCallTokenRequest.cs new file mode 100644 index 0000000..2d3324b --- /dev/null +++ b/src/Termii/Tokens/VoiceCallTokenRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class VoiceCallTokenRequest +{ + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Required(Code, nameof(Code)); + } +} diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 70b64f3..30c163c 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -20,5 +20,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.NotNull(client.Messaging); Assert.NotNull(client.SenderIds); Assert.NotNull(client.Numbers); + Assert.NotNull(client.Tokens); } } diff --git a/tests/Termii.Tests/TermiiTokenClientTests.cs b/tests/Termii.Tests/TermiiTokenClientTests.cs new file mode 100644 index 0000000..56c2229 --- /dev/null +++ b/tests/Termii.Tests/TermiiTokenClientTests.cs @@ -0,0 +1,231 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiTokenClientTests +{ + [Fact] + public async Task SendAsyncPostsTokenBodyAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"smsStatus":"Message Sent","to":"2348012345678","pinId":"pin-123","message_id_str":"msg-123","status":"success"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Tokens.SendAsync( + new SendTokenRequest + { + To = "2348012345678", + From = "Termii", + Channel = TermiiMessageChannel.Dnd, + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6, + PinPlaceholder = "< 1234 >", + MessageText = "Your pin is < 1234 >", + }, + 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/sms/otp/send", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("NUMERIC", body.RootElement.GetProperty("message_type").GetString()); + Assert.Equal("NUMERIC", body.RootElement.GetProperty("pin_type").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("to").GetString()); + Assert.Equal("Termii", body.RootElement.GetProperty("from").GetString()); + Assert.Equal("dnd", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal(3, body.RootElement.GetProperty("pin_attempts").GetInt32()); + Assert.Equal(10, body.RootElement.GetProperty("pin_time_to_live").GetInt32()); + Assert.Equal(6, body.RootElement.GetProperty("pin_length").GetInt32()); + Assert.Equal("< 1234 >", body.RootElement.GetProperty("pin_placeholder").GetString()); + Assert.Equal("Your pin is < 1234 >", body.RootElement.GetProperty("message_text").GetString()); + + Assert.Equal("Message Sent", response.SmsStatus); + Assert.Equal("pin-123", response.PinId); + Assert.Equal("msg-123", response.MessageIdString); + Assert.Equal("success", response.Status); + } + + [Fact] + public async Task VerifyAsyncPostsPinDetails() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"pinId":"pin-123","verified":"true","msisdn":"2348012345678"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Tokens.VerifyAsync( + new VerifyTokenRequest + { + PinId = "pin-123", + Pin = "123456", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/verify", request.RequestUri!.ToString()); + Assert.Equal("pin-123", body.RootElement.GetProperty("pin_id").GetString()); + Assert.Equal("123456", body.RootElement.GetProperty("pin").GetString()); + Assert.Equal("true", response.Verified); + Assert.Equal("2348012345678", response.Msisdn); + } + + [Fact] + public async Task GenerateAsyncPostsInAppTokenBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"phone_number":"2348012345678","otp":"123456","pin_id":"pin-123"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Tokens.GenerateAsync( + new GenerateTokenRequest + { + PinType = TermiiTokenPinType.Alphanumeric, + PhoneNumber = "2348012345678", + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/generate", request.RequestUri!.ToString()); + Assert.Equal("ALPHANUMERIC", body.RootElement.GetProperty("pin_type").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal("123456", response.Otp); + Assert.Equal("pin-123", response.PinId); + } + + [Fact] + public async Task SendVoiceAsyncPostsVoiceTokenBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.SendVoiceAsync( + new SendVoiceTokenRequest + { + PhoneNumber = "2348012345678", + PinAttempts = 2, + PinTimeToLive = 5, + PinLength = 4, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/send/voice", request.RequestUri!.ToString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal(2, body.RootElement.GetProperty("pin_attempts").GetInt32()); + } + + [Fact] + public async Task CallAsyncPostsVoiceCallTokenBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.CallAsync( + new VoiceCallTokenRequest + { + PhoneNumber = "2348012345678", + Code = "123456", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/call", request.RequestUri!.ToString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal("123456", body.RootElement.GetProperty("code").GetString()); + } + + [Fact] + public async Task SendEmailAsyncPostsEmailTokenBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.SendEmailAsync( + new SendEmailTokenRequest + { + EmailAddress = "person@example.com", + Code = "123456", + EmailConfigurationId = "email-config", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/email/otp/send", request.RequestUri!.ToString()); + Assert.Equal("person@example.com", body.RootElement.GetProperty("email_address").GetString()); + Assert.Equal("123456", body.RootElement.GetProperty("code").GetString()); + Assert.Equal("email-config", body.RootElement.GetProperty("email_configuration_id").GetString()); + } + + [Fact] + public async Task SendWhatsAppAsyncPostsWhatsAppOtpMessage() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.SendWhatsAppAsync( + new SendWhatsAppTokenRequest + { + To = "2348012345678", + From = "Termii", + Sms = "Your code is 123456", + Type = TermiiMessageType.Unicode, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString()); + Assert.Equal("whatsapp_otp", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal("unicode", body.RootElement.GetProperty("type").GetString()); + Assert.Equal("Your code is 123456", body.RootElement.GetProperty("sms").GetString()); + } + + [Fact] + public async Task SendAsyncRejectsInvalidPinLength() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Tokens.SendAsync( + new SendTokenRequest + { + To = "2348012345678", + From = "Termii", + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 3, + PinPlaceholder = "< 1234 >", + MessageText = "Your pin is < 1234 >", + }, + CancellationToken.None)); + } +}