diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c3af2..59994e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,4 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -[Unreleased]: https://github.com/AM-WD/LinkMobility/ + +### Added + +- `(I)LinkMobilityClient` with client options to communicate with the REST API +- Methods to send SMS messages + - `SendTextMessage()` sending a normal text message + - `SendBinaryMessage()` sending a binary message as _base64_ encoded message segments +- `IncomingMessageNotification` to support receiving incoming messages or delivery reports via WebHooks +- UnitTesting + + + +[Unreleased]: https://github.com/AM-WD/LinkMobility diff --git a/README.md b/README.md index ada0d36..777d9e4 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@ -# LinkMobility API +# LINK Mobility REST API for .NET -This project aims to implement the [LinkMobility API]. +This project aims to implement the [LINK Mobility REST API]. + +## Overview + +Looking for a .NET library to interact with LINK Mobility ended with an [outdated repository] using .NET Framework 3.5. + +So I decided to implement the current available API myself with a more modern (and flexible) [.NET Standard 2.0] as target. --- Published under [MIT License] (see [**tl;dr**Legal]) -[LinkMobility API]: https://developer.linkmobility.eu/ + +[LINK Mobility REST API]: https://developer.linkmobility.eu/ +[outdated repository]: https://github.com/websms-com/websmscom-csharp +[.NET Standard 2.0]: https://learn.microsoft.com/en-us/dotnet/standard/net-standard?tabs=net-standard-2-0 [MIT License]: LICENSE.txt [**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license diff --git a/src/LinkMobility/Enums/SenderAddressType.cs b/src/LinkMobility/Enums/AddressType.cs similarity index 91% rename from src/LinkMobility/Enums/SenderAddressType.cs rename to src/LinkMobility/Enums/AddressType.cs index 0b65865..465eda5 100644 --- a/src/LinkMobility/Enums/SenderAddressType.cs +++ b/src/LinkMobility/Enums/AddressType.cs @@ -7,7 +7,7 @@ namespace AMWD.Net.Api.LinkMobility /// Specifies the type of sender address. /// [JsonConverter(typeof(StringEnumConverter))] - public enum SenderAddressType + public enum AddressType { /// /// National number. diff --git a/src/LinkMobility/Enums/DeliveryStatus.cs b/src/LinkMobility/Enums/DeliveryStatus.cs new file mode 100644 index 0000000..36c4bbf --- /dev/null +++ b/src/LinkMobility/Enums/DeliveryStatus.cs @@ -0,0 +1,48 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.LinkMobility +{ + /// + /// Defines the delivery status of a message on a report. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum DeliveryStatus + { + /// + /// Message has been delivered to the recipient. + /// + [EnumMember(Value = "delivered")] + Delivered = 1, + + /// + /// Message not delivered and will be re-tried. + /// + [EnumMember(Value = "undelivered")] + Undelivered = 2, + + /// + /// Message has expired and will no longer re-tried. + /// + [EnumMember(Value = "expired")] + Expired = 3, + + /// + /// Message has been deleted. + /// + [EnumMember(Value = "deleted")] + Deleted = 4, + + /// + /// Message has been accepted by the carrier. + /// + [EnumMember(Value = "accepted")] + Accepted = 5, + + /// + /// Message has been rejected by the carrier. + /// + [EnumMember(Value = "rejected")] + Rejected = 6 + } +} diff --git a/src/LinkMobility/Enums/DeliveryType.cs b/src/LinkMobility/Enums/DeliveryType.cs new file mode 100644 index 0000000..84a4b10 --- /dev/null +++ b/src/LinkMobility/Enums/DeliveryType.cs @@ -0,0 +1,36 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.LinkMobility +{ + /// + /// Defines the types of delivery methods on a report. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum DeliveryType + { + /// + /// Message sent via SMS. + /// + [EnumMember(Value = "sms")] + Sms = 1, + + /// + /// Message sent as Push message. + /// + [EnumMember(Value = "push")] + Push = 2, + + /// + /// Message sent as failover SMS. + /// + [EnumMember(Value = "failover-sms")] + FailoverSms = 3, + + /// + /// Message sent as voice message. + /// + [EnumMember(Value = "voice")] + Voice = 4 + } +} diff --git a/src/LinkMobility/Enums/MessageType.cs b/src/LinkMobility/Enums/MessageType.cs index 64af266..474c002 100644 --- a/src/LinkMobility/Enums/MessageType.cs +++ b/src/LinkMobility/Enums/MessageType.cs @@ -1,18 +1,24 @@ -namespace AMWD.Net.Api.LinkMobility +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.LinkMobility { /// /// Specifies the message type. /// + [JsonConverter(typeof(StringEnumConverter))] public enum MessageType { /// /// The message is sent as defined in the account settings. /// + [EnumMember(Value = "default")] Default = 1, /// /// The message is sent as voice call. /// + [EnumMember(Value = "voice")] Voice = 2, } } diff --git a/src/LinkMobility/Enums/StatusCodes.cs b/src/LinkMobility/Enums/StatusCodes.cs index 9853904..c912d70 100644 --- a/src/LinkMobility/Enums/StatusCodes.cs +++ b/src/LinkMobility/Enums/StatusCodes.cs @@ -3,7 +3,7 @@ /// /// Custom status codes as defined by Link Mobility. /// - public enum StatusCodes + public enum StatusCodes : int { /// /// Request accepted, Message(s) sent. diff --git a/src/LinkMobility/ILinkMobilityClient.cs b/src/LinkMobility/ILinkMobilityClient.cs index 304848f..1d80245 100644 --- a/src/LinkMobility/ILinkMobilityClient.cs +++ b/src/LinkMobility/ILinkMobilityClient.cs @@ -1,6 +1,5 @@ using System.Threading; using System.Threading.Tasks; -using AMWD.Net.Api.LinkMobility.Requests; namespace AMWD.Net.Api.LinkMobility { @@ -14,6 +13,13 @@ namespace AMWD.Net.Api.LinkMobility /// /// The request data. /// A cancellation token to propagate notification that operations should be canceled. - Task SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default); + Task SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default); + + /// + /// Sends a binary message to a list of recipients. + /// + /// The request data. + /// A cancellation token to propagate notification that operations should be canceled. + Task SendBinaryMessage(SendBinaryMessageRequest request, CancellationToken cancellationToken = default); } } diff --git a/src/LinkMobility/LinkMobility.csproj b/src/LinkMobility/LinkMobility.csproj index f839917..7ae8d6c 100644 --- a/src/LinkMobility/LinkMobility.csproj +++ b/src/LinkMobility/LinkMobility.csproj @@ -5,7 +5,7 @@ enable AMWD.Net.Api.LinkMobility - link mobility api + link mobility api messaging sms amwd-linkmobility AMWD.Net.Api.LinkMobility @@ -18,8 +18,8 @@ package-icon.png README.md - LinkMobility API - Implementation of the Link Mobility REST API + LINK Mobility REST API + Implementation of the LINK Mobility REST API using .NET true diff --git a/src/LinkMobility/LinkMobilityClient.Messaging.cs b/src/LinkMobility/LinkMobilityClient.Messaging.cs deleted file mode 100644 index 6586ed2..0000000 --- a/src/LinkMobility/LinkMobilityClient.Messaging.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using AMWD.Net.Api.LinkMobility.Requests; - -namespace AMWD.Net.Api.LinkMobility -{ - public partial class LinkMobilityClient - { - /// - /// Sends a text message to a list of recipients. - /// - /// The request. - /// A cancellation token to propagate notification that operations should be canceled. - public Task SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default) - { - if (request == null) - throw new ArgumentNullException(nameof(request)); - - if (string.IsNullOrWhiteSpace(request.MessageContent)) - throw new ArgumentException("A message must be provided.", nameof(request.MessageContent)); - - if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0) - throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList)); - - foreach (string recipient in request.RecipientAddressList) - { - if (!IsValidMSISDN(recipient)) - throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList)); - } - - return PostAsync("/smsmessaging/text", request, cancellationToken: cancellationToken); - } - - private static bool IsValidMSISDN(string msisdn) - { - if (string.IsNullOrWhiteSpace(msisdn)) - return false; - - return Regex.IsMatch(msisdn, @"^[1-9][0-9]{7,14}$", RegexOptions.Compiled); - } - } -} diff --git a/src/LinkMobility/LinkMobilityClient.Sms.cs b/src/LinkMobility/LinkMobilityClient.Sms.cs new file mode 100644 index 0000000..d1986c3 --- /dev/null +++ b/src/LinkMobility/LinkMobilityClient.Sms.cs @@ -0,0 +1,67 @@ +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace AMWD.Net.Api.LinkMobility +{ + /// + /// Implementation of text messaging (SMS). API + /// + public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable + { + /// + public Task SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (string.IsNullOrWhiteSpace(request.MessageContent)) + throw new ArgumentException("A message must be provided.", nameof(request.MessageContent)); + + if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0) + throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList)); + + foreach (string recipient in request.RecipientAddressList) + { + if (!IsValidMSISDN(recipient)) + throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList)); + } + + return PostAsync("/smsmessaging/text", request, cancellationToken: cancellationToken); + } + + /// + public Task SendBinaryMessage(SendBinaryMessageRequest request, CancellationToken cancellationToken = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (request.MessageContent?.Count > 0) + { + // Validate that the string is a valid Base64 string + // Might throw a ArgumentNullException or FormatException + foreach (string str in request.MessageContent) + Convert.FromBase64String(str); + } + + if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0) + throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList)); + + foreach (string recipient in request.RecipientAddressList) + { + if (!IsValidMSISDN(recipient)) + throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList)); + } + + return PostAsync("/smsmessaging/binary", request, cancellationToken: cancellationToken); + } + + private static bool IsValidMSISDN(string msisdn) + { + if (string.IsNullOrWhiteSpace(msisdn)) + return false; + + return Regex.IsMatch(msisdn, @"^[1-9][0-9]{7,14}$", RegexOptions.Compiled); + } + } +} diff --git a/src/LinkMobility/LinkMobilityClient.cs b/src/LinkMobility/LinkMobilityClient.cs index a6642e2..fea1793 100644 --- a/src/LinkMobility/LinkMobilityClient.cs +++ b/src/LinkMobility/LinkMobilityClient.cs @@ -1,5 +1,4 @@ -using System.Globalization; -using System.Linq; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -16,13 +15,6 @@ namespace AMWD.Net.Api.LinkMobility /// public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable { - private static readonly JsonSerializerSettings _jsonSerializerSettings = new() - { - Culture = CultureInfo.InvariantCulture, - Formatting = Formatting.None, - NullValueHandling = NullValueHandling.Ignore - }; - private readonly ClientOptions _clientOptions; private readonly HttpClient _httpClient; @@ -191,7 +183,7 @@ namespace AMWD.Net.Api.LinkMobility if (request is HttpContent httpContent) return httpContent; - string json = JsonConvert.SerializeObject(request, _jsonSerializerSettings); + string json = request.SerializeObject(); return new StringContent(json, Encoding.UTF8, "application/json"); } @@ -208,7 +200,7 @@ namespace AMWD.Net.Api.LinkMobility HttpStatusCode.Unauthorized => throw new AuthenticationException($"HTTP auth missing: {httpResponse.StatusCode}"), HttpStatusCode.Forbidden => throw new AuthenticationException($"HTTP auth missing: {httpResponse.StatusCode}"), HttpStatusCode.OK => - JsonConvert.DeserializeObject(content, _jsonSerializerSettings) + content.DeserializeObject() ?? throw new ApplicationException("Response could not be deserialized"), _ => throw new ApplicationException($"Unknown HTTP response: {httpResponse.StatusCode}"), }; diff --git a/src/LinkMobility/Models/IncomingMessageNotification.cs b/src/LinkMobility/Models/IncomingMessageNotification.cs new file mode 100644 index 0000000..df13c52 --- /dev/null +++ b/src/LinkMobility/Models/IncomingMessageNotification.cs @@ -0,0 +1,194 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.LinkMobility +{ + /// + /// Represents a notification for an incoming message or delivery report. (API) + /// + public class IncomingMessageNotification + { + /// + /// Initializes a new instance of the class. + /// + /// The notification id. + /// The transfer id. + public IncomingMessageNotification(string notificationId, string transferId) + { + NotificationId = notificationId; + TransferId = transferId; + } + + /// + /// Defines the content type of your notification. + /// + [JsonProperty("messageType")] + public Type MessageType { get; set; } + + /// + /// 20 digit long identification of your notification. + /// + [JsonProperty("notificationId")] + public string NotificationId { get; set; } + + /// + /// : + ///
+ /// Unique transfer-id to connect the deliveryReport to the initial message. + ///
+ [JsonProperty("transferId")] + public string TransferId { get; set; } + + /// + /// , : + ///
+ /// Indicates whether you received message is a SMS or a flash-SMS. + ///
+ [JsonProperty("messageFlashSms")] + public bool? MessageFlashSms { get; set; } + + /// + /// Originator of the sender. + /// + [JsonProperty("senderAddress")] + public string? SenderAddress { get; set; } + + /// + /// , : + ///
+ /// – defines the number format of the mobile originated . + /// International numbers always includes the country prefix. + ///
+ [JsonProperty("senderAddressType")] + public AddressType? SenderAddressType { get; set; } + + /// + /// Senders address, can either be + /// (4366012345678), + /// (066012345678) or a + /// (1234). + /// + [JsonProperty("recipientAddress")] + public string? RecipientAddress { get; set; } + + /// + /// , : + ///
+ /// Defines the number format of the mobile originated message. + ///
+ [JsonProperty("recipientAddressType")] + public AddressType? RecipientAddressType { get; set; } + + /// + /// : + ///
+ /// Text body of the message encoded in UTF-8. + /// In the case of concatenated SMS it will contain the complete content of all segments. + ///
+ [JsonProperty("textMessageContent")] + public string? TextMessageContent { get; set; } + + /// + /// : + ///
+ /// Indicates whether a user-data-header is included within a Base64 encoded byte segment. + ///
+ [JsonProperty("userDataHeaderPresent")] + public bool? UserDataHeaderPresent { get; set; } + + /// + /// : + ///
+ /// Content of a binary SMS in an array of Base64 strings (URL safe). + ///
+ [JsonProperty("binaryMessageContent")] + public IReadOnlyCollection? BinaryMessageContent { get; set; } + + /// + /// : + ///
+ /// Status of the message. + ///
+ [JsonProperty("deliveryReportMessageStatus")] + public DeliveryStatus? DeliveryReportMessageStatus { get; set; } + + /// + /// : + ///
+ /// ISO 8601 timestamp. Point of time sending the message to recipients address. + ///
+ [JsonProperty("sentOn")] + public DateTime? SentOn { get; set; } + + /// + /// : + ///
+ /// ISO 8601 timestamp. Point of time of submitting the message to the mobile operators network. + ///
+ [JsonProperty("deliveredOn")] + public DateTime? DeliveredOn { get; set; } + + /// + /// : + ///
+ /// Type of delivery used to send the message. + ///
+ [JsonProperty("deliveredAs")] + public DeliveryType? DeliveredAs { get; set; } + + /// + /// : + ///
+ /// In the case of a delivery report, the contains the optional submitted message id. + ///
+ [JsonProperty("clientMessageId")] + public string? ClientMessageId { get; set; } + + /// + /// Defines the type of notification. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum Type + { + /// + /// Notification of an incoming text message. + /// + [EnumMember(Value = "text")] + Text = 1, + + /// + /// Notification of an incoming binary message. + /// + [EnumMember(Value = "binary")] + Binary = 2, + + /// + /// Notification of a delivery report. + /// + [EnumMember(Value = "deliveryReport")] + DeliveryReport = 3 + } + + /// + /// Tries to parse the given content as . + /// + /// The given content (should be the notification json). + /// The deserialized notification. + /// + /// if the content could be parsed; otherwise, . + /// + public static bool TryParse(string json, out IncomingMessageNotification? notification) + { + try + { + notification = json.DeserializeObject(); + return notification != null; + } + catch + { + notification = null; + return false; + } + } + } +} diff --git a/src/LinkMobility/Models/IncomingMessageNotificationResponse.cs b/src/LinkMobility/Models/IncomingMessageNotificationResponse.cs new file mode 100644 index 0000000..a1e04ca --- /dev/null +++ b/src/LinkMobility/Models/IncomingMessageNotificationResponse.cs @@ -0,0 +1,27 @@ +namespace AMWD.Net.Api.LinkMobility +{ + /// + /// Representes the response to an incoming message notification. (API) + /// + public class IncomingMessageNotificationResponse + { + /// + /// Gets or sets the status code of the response. + /// + [JsonProperty("statusCode")] + public StatusCodes StatusCode { get; set; } = StatusCodes.Ok; + + /// + /// Gets or sets the status message of the response. + /// + [JsonProperty("statusMessage")] + public string StatusMessage { get; set; } = "OK"; + + /// + /// Returns a string representation of the current object in serialized format. + /// + /// A string containing the serialized form of the object (json). + public override string ToString() + => this.SerializeObject(); + } +} diff --git a/src/LinkMobility/README.md b/src/LinkMobility/README.md index e526b30..d0984c7 100644 --- a/src/LinkMobility/README.md +++ b/src/LinkMobility/README.md @@ -1,12 +1,12 @@ -# LinkMobility API +# LINK Mobility REST API -This project aims to implement the [LinkMobility REST API]. +This project aims to implement the [LINK Mobility REST API]. ## Overview -Link Mobility is a provider for communication with customers via SMS, RCS or WhatsApp. +LINK Mobility is a provider for communication with customers via SMS, RCS or WhatsApp. -With this project the REST API of Link Mobility will be implemented. +In this project the REST API of LINK Mobility will be implemented. - [SMS API](https://developer.linkmobility.eu/sms-api/rest-api) @@ -14,5 +14,5 @@ With this project the REST API of Link Mobility will be implemented. Published under MIT License (see [**tl;dr**Legal]) -[LinkMobility REST API]: https://developer.linkmobility.eu/ +[LINK Mobility REST API]: https://developer.linkmobility.eu/ [**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license diff --git a/src/LinkMobility/Requests/SendBinaryMessageRequest.cs b/src/LinkMobility/Requests/SendBinaryMessageRequest.cs new file mode 100644 index 0000000..b3276b3 --- /dev/null +++ b/src/LinkMobility/Requests/SendBinaryMessageRequest.cs @@ -0,0 +1,128 @@ +namespace AMWD.Net.Api.LinkMobility +{ + /// + /// Request to send a text message to a list of recipients. + /// + public class SendBinaryMessageRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The recipient list. + public SendBinaryMessageRequest(IReadOnlyCollection recipientAddressList) + { + RecipientAddressList = recipientAddressList; + } + + /// + /// Optional. + /// May contain a freely definable message id. + /// + [JsonProperty("clientMessageId")] + public string? ClientMessageId { get; set; } + + /// + /// Optional. + /// The content category that is used to categorize the message (used for blacklisting). + /// + /// + /// The following content categories are supported: or . + /// If no content category is provided, the default setting is used (may be changed inside the web interface). + /// + [JsonProperty("contentCategory")] + public ContentCategory? ContentCategory { get; set; } + + /// + /// Optional. + /// Array of Base64 encoded binary data. + /// + /// + /// Every element of the array corresponds to a message segment. + /// The binary data is transmitted without being changed (using 8 bit alphabet). + /// + [JsonProperty("messageContent")] + public IReadOnlyCollection? MessageContent { get; set; } + + /// + /// Optional. + /// When setting a NotificationCallbackUrl all delivery reports are forwarded to this URL. + /// + [JsonProperty("notificationCallbackUrl")] + public string? NotificationCallbackUrl { get; set; } + + /// + /// Optional. + /// Priority of the message. + /// + /// + /// Must not exceed the value configured for the account used to send the message. + /// For more information please contact our customer service. + /// + [JsonProperty("priority")] + public int? Priority { get; set; } + + /// + /// List of recipients (E.164 formatted MSISDNs) + /// to whom the message should be sent. + ///
+ /// The list of recipients may contain a maximum of 1000 entries. + ///
+ [JsonProperty("recipientAddressList")] + public IReadOnlyCollection RecipientAddressList { get; set; } + + /// + /// Optional. + ///
+ /// : The message is sent as flash SMS (displayed directly on the screen of the mobile phone). + ///
+ /// : The message is sent as standard text SMS (default). + ///
+ [JsonProperty("sendAsFlashSms")] + public bool? SendAsFlashSms { get; set; } + + /// + /// Optional. + /// Address of the sender (assigned to the account) from which the message is sent. + /// + [JsonProperty("senderAddress")] + public string? SenderAddress { get; set; } + + /// + /// Optional. + /// The sender address type. + /// + [JsonProperty("senderAddressType")] + public AddressType? SenderAddressType { get; set; } + + /// + /// Optional. + ///
+ /// : The transmission is only simulated, no SMS is sent. + /// Depending on the number of recipients the status code or is returned. + ///
+ /// : No simulation is done. The SMS is sent via the SMS Gateway. (default) + ///
+ [JsonProperty("test")] + public bool? Test { get; set; } + + /// + /// Optional. + ///
+ /// : Indicates the presence of a user data header in the property. + ///
+ /// : Indicates the absence of a user data header in the property. (default) + ///
+ [JsonProperty("userDataHeaderPresent")] + public bool? UserDataHeaderPresent { get; set; } + + /// + /// Optional. + /// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient. + /// + /// + /// A minimum of 1 minute (60 seconds) and a maximum of 3 days (259200 seconds) are allowed. + /// + [JsonProperty("validityPeriode")] + public int? ValidityPeriode { get; set; } + } +} diff --git a/src/LinkMobility/Requests/SendTextMessageRequest.cs b/src/LinkMobility/Requests/SendTextMessageRequest.cs index 8215641..c232052 100644 --- a/src/LinkMobility/Requests/SendTextMessageRequest.cs +++ b/src/LinkMobility/Requests/SendTextMessageRequest.cs @@ -1,4 +1,4 @@ -namespace AMWD.Net.Api.LinkMobility.Requests +namespace AMWD.Net.Api.LinkMobility { /// /// Request to send a text message to a list of recipients. @@ -113,7 +113,7 @@ /// The sender address type. /// [JsonProperty("senderAddressType")] - public SenderAddressType? SenderAddressType { get; set; } + public AddressType? SenderAddressType { get; set; } /// /// Optional. diff --git a/src/LinkMobility/Responses/SendTextMessageResponse.cs b/src/LinkMobility/Responses/SendMessageResponse.cs similarity index 92% rename from src/LinkMobility/Responses/SendTextMessageResponse.cs rename to src/LinkMobility/Responses/SendMessageResponse.cs index a4860ba..b5d5edd 100644 --- a/src/LinkMobility/Responses/SendTextMessageResponse.cs +++ b/src/LinkMobility/Responses/SendMessageResponse.cs @@ -3,7 +3,7 @@ /// /// Response of a text message sent to a list of recipients. /// - public class SendTextMessageResponse + public class SendMessageResponse { /// /// Contains the message id defined in the request. diff --git a/src/LinkMobility/Utils/SerializerExtensions.cs b/src/LinkMobility/Utils/SerializerExtensions.cs new file mode 100644 index 0000000..2fd0596 --- /dev/null +++ b/src/LinkMobility/Utils/SerializerExtensions.cs @@ -0,0 +1,20 @@ +using System.Globalization; + +namespace AMWD.Net.Api.LinkMobility +{ + internal static class SerializerExtensions + { + private static readonly JsonSerializerSettings _jsonSerializerSettings = new() + { + Culture = CultureInfo.InvariantCulture, + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore + }; + + public static string SerializeObject(this object? obj) + => JsonConvert.SerializeObject(obj, _jsonSerializerSettings); + + public static T? DeserializeObject(this string json) + => JsonConvert.DeserializeObject(json, _jsonSerializerSettings); + } +} diff --git a/test/LinkMobility.Tests/Models/IncomingMessageNotificationTest.cs b/test/LinkMobility.Tests/Models/IncomingMessageNotificationTest.cs new file mode 100644 index 0000000..966aaf6 --- /dev/null +++ b/test/LinkMobility.Tests/Models/IncomingMessageNotificationTest.cs @@ -0,0 +1,90 @@ +using AMWD.Net.Api.LinkMobility; + +namespace LinkMobility.Tests.Models +{ + [TestClass] + public class IncomingMessageNotificationTest + { + [TestMethod] + public void ShouldParseAllPropertiesForTextNotification() + { + // Arrange + string json = @"{ + ""messageType"": ""text"", + ""notificationId"": ""notif-123"", + ""transferId"": ""trans-456"", + ""messageFlashSms"": true, + ""senderAddress"": ""436991234567"", + ""senderAddressType"": ""international"", + ""recipientAddress"": ""066012345678"", + ""recipientAddressType"": ""national"", + ""textMessageContent"": ""Hello from user"", + ""userDataHeaderPresent"": false, + ""binaryMessageContent"": [""SGVsbG8=""], + ""deliveryReportMessageStatus"": 2, + ""sentOn"": ""2025-12-03T12:34:56Z"", + ""deliveredOn"": ""2025-12-03T12:35:30Z"", + ""deliveredAs"": 1, + ""clientMessageId"": ""client-789"" + }"; + + // Act + bool successful = IncomingMessageNotification.TryParse(json, out var notification); + + // Assert + Assert.IsTrue(successful, "TryParse should return true for valid json"); + Assert.IsNotNull(notification); + + Assert.AreEqual(IncomingMessageNotification.Type.Text, notification.MessageType); + Assert.AreEqual("notif-123", notification.NotificationId); + Assert.AreEqual("trans-456", notification.TransferId); + + Assert.IsTrue(notification.MessageFlashSms.HasValue && notification.MessageFlashSms.Value); + Assert.AreEqual("436991234567", notification.SenderAddress); + Assert.IsTrue(notification.SenderAddressType.HasValue); + Assert.AreEqual(AddressType.International, notification.SenderAddressType.Value); + + Assert.AreEqual("066012345678", notification.RecipientAddress); + Assert.IsTrue(notification.RecipientAddressType.HasValue); + Assert.AreEqual(AddressType.National, notification.RecipientAddressType.Value); + + Assert.AreEqual("Hello from user", notification.TextMessageContent); + Assert.IsTrue(notification.UserDataHeaderPresent.HasValue && !notification.UserDataHeaderPresent.Value); + + Assert.IsNotNull(notification.BinaryMessageContent); + CollectionAssert.AreEqual(new List { "SGVsbG8=" }, new List(notification.BinaryMessageContent)); + + // delivery status and deliveredAs are numeric in the test json: assert underlying integral values + Assert.IsTrue(notification.DeliveryReportMessageStatus.HasValue); + Assert.AreEqual(2, (int)notification.DeliveryReportMessageStatus.Value); + + Assert.IsTrue(notification.SentOn.HasValue); + Assert.IsTrue(notification.DeliveredOn.HasValue); + + // Compare instants in UTC + var expectedSent = DateTime.Parse("2025-12-03T12:34:56Z").ToUniversalTime(); + var expectedDelivered = DateTime.Parse("2025-12-03T12:35:30Z").ToUniversalTime(); + Assert.AreEqual(expectedSent, notification.SentOn.Value.ToUniversalTime()); + Assert.AreEqual(expectedDelivered, notification.DeliveredOn.Value.ToUniversalTime()); + + Assert.IsTrue(notification.DeliveredAs.HasValue); + Assert.AreEqual(1, (int)notification.DeliveredAs.Value); + + Assert.AreEqual("client-789", notification.ClientMessageId); + } + + [TestMethod] + public void TryParseShouldReturnFalseOnInvalidJson() + { + // Arrange + string invalid = "this is not json"; + + // Act + bool successful = IncomingMessageNotification.TryParse(invalid, out var notification); + + // Assert + Assert.IsFalse(successful); + Assert.IsNull(notification); + } + } +} diff --git a/test/LinkMobility.Tests/Sms/SendBinaryMessageTest.cs b/test/LinkMobility.Tests/Sms/SendBinaryMessageTest.cs new file mode 100644 index 0000000..80740de --- /dev/null +++ b/test/LinkMobility.Tests/Sms/SendBinaryMessageTest.cs @@ -0,0 +1,264 @@ +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.LinkMobility; +using LinkMobility.Tests.Helpers; +using Moq.Protected; + +namespace LinkMobility.Tests.Sms +{ + [TestClass] + public class SendBinaryMessageTest + { + public TestContext TestContext { get; set; } + + private const string BASE_URL = "https://localhost/rest/"; + + private Mock _authenticationMock; + private Mock _clientOptionsMock; + private HttpMessageHandlerMock _httpMessageHandlerMock; + + private SendBinaryMessageRequest _request; + + [TestInitialize] + public void Initialize() + { + _authenticationMock = new Mock(); + _clientOptionsMock = new Mock(); + _httpMessageHandlerMock = new HttpMessageHandlerMock(); + + _authenticationMock + .Setup(a => a.AddHeader(It.IsAny())) + .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Scheme", "Parameter")); + + _clientOptionsMock.Setup(c => c.BaseUrl).Returns(BASE_URL); + _clientOptionsMock.Setup(c => c.Timeout).Returns(TimeSpan.FromSeconds(30)); + _clientOptionsMock.Setup(c => c.DefaultHeaders).Returns(new Dictionary()); + _clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary()); + _clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true); + _clientOptionsMock.Setup(c => c.UseProxy).Returns(false); + + _request = new SendBinaryMessageRequest(["436991234567"]) + { + MessageContent = ["SGVsbG8gV29ybGQ="] // "Hello World" base64 + }; + } + + [TestMethod] + public async Task ShouldSendBinaryMessage() + { + // Arrange + _httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ ""clientMessageId"": ""binId"", ""smsCount"": 1, ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""abc123"" }", Encoding.UTF8, "application/json"), + }); + + var client = GetClient(); + + // Act + var response = await client.SendBinaryMessage(_request, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + Assert.AreEqual("binId", response.ClientMessageId); + Assert.AreEqual(1, response.SmsCount); + Assert.AreEqual(StatusCodes.Ok, response.StatusCode); + Assert.AreEqual("OK", response.StatusMessage); + Assert.AreEqual("abc123", response.TransferId); + + Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks); + + var callback = _httpMessageHandlerMock.RequestCallbacks.First(); + Assert.AreEqual(HttpMethod.Post, callback.HttpMethod); + Assert.AreEqual("https://localhost/rest/smsmessaging/binary", callback.Url); + Assert.AreEqual(@"{""messageContent"":[""SGVsbG8gV29ybGQ=""],""recipientAddressList"":[""436991234567""]}", callback.Content); + + Assert.HasCount(3, callback.Headers); + Assert.IsTrue(callback.Headers.ContainsKey("Accept")); + Assert.IsTrue(callback.Headers.ContainsKey("Authorization")); + Assert.IsTrue(callback.Headers.ContainsKey("User-Agent")); + + Assert.AreEqual("application/json", callback.Headers["Accept"]); + Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); + Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); + + _httpMessageHandlerMock.Mock + .Protected() + .Verify("SendAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny()); + + _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldSendBinaryMessageFullDetails() + { + // Arrange + _request.ClientMessageId = "myCustomId"; + _request.ContentCategory = ContentCategory.Advertisement; + _request.NotificationCallbackUrl = "https://user:pass@example.com/callback/"; + _request.Priority = 5; + _request.SendAsFlashSms = false; + _request.SenderAddress = "4369912345678"; + _request.SenderAddressType = AddressType.International; + _request.Test = false; + _request.UserDataHeaderPresent = true; + _request.ValidityPeriode = 300; + + _httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ ""clientMessageId"": ""myCustomId"", ""smsCount"": 1, ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""abc123"" }", Encoding.UTF8, "application/json"), + }); + + var client = GetClient(); + + // Act + var response = await client.SendBinaryMessage(_request, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + Assert.AreEqual("myCustomId", response.ClientMessageId); + Assert.AreEqual(1, response.SmsCount); + Assert.AreEqual(StatusCodes.Ok, response.StatusCode); + Assert.AreEqual("OK", response.StatusMessage); + Assert.AreEqual("abc123", response.TransferId); + + Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks); + + var callback = _httpMessageHandlerMock.RequestCallbacks.First(); + Assert.AreEqual(HttpMethod.Post, callback.HttpMethod); + Assert.AreEqual("https://localhost/rest/smsmessaging/binary", callback.Url); + Assert.AreEqual(@"{""clientMessageId"":""myCustomId"",""contentCategory"":""advertisement"",""messageContent"":[""SGVsbG8gV29ybGQ=""],""notificationCallbackUrl"":""https://user:pass@example.com/callback/"",""priority"":5,""recipientAddressList"":[""436991234567""],""sendAsFlashSms"":false,""senderAddress"":""4369912345678"",""senderAddressType"":""international"",""test"":false,""userDataHeaderPresent"":true,""validityPeriode"":300}", callback.Content); + + Assert.HasCount(3, callback.Headers); + Assert.IsTrue(callback.Headers.ContainsKey("Accept")); + Assert.IsTrue(callback.Headers.ContainsKey("Authorization")); + Assert.IsTrue(callback.Headers.ContainsKey("User-Agent")); + + Assert.AreEqual("application/json", callback.Headers["Accept"]); + Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); + Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); + + _httpMessageHandlerMock.Mock + .Protected() + .Verify("SendAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny()); + + _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldThrowOnNullRequest() + { + // Arrange + var client = GetClient(); + + // Act & Assert + var ex = Assert.ThrowsExactly(() => client.SendBinaryMessage(null, TestContext.CancellationToken)); + + Assert.AreEqual("request", ex.ParamName); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldThrowOnInvalidMessageEncoding() + { + // Arrange + _request.MessageContent = ["InvalidBase64!!"]; + var client = GetClient(); + + // Act & Assert + Assert.ThrowsExactly(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); + + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldThrowOnNullMessageContent() + { + // Arrange + _request.MessageContent = [null]; + var client = GetClient(); + + // Act & Assert + var ex = Assert.ThrowsExactly(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); + + VerifyNoOtherCalls(); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + public void ShouldThrowOnNoRecipients(string recipients) + { + // Arrange + _request.RecipientAddressList = recipients?.Split(','); + var client = GetClient(); + + // Act & Assert + var ex = Assert.ThrowsExactly(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); + + Assert.AreEqual("RecipientAddressList", ex.ParamName); + + VerifyNoOtherCalls(); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("invalid-recipient")] + public void ShouldThrowOnInvalidRecipient(string recipient) + { + // Arrange + _request.RecipientAddressList = ["436991234567", recipient]; + var client = GetClient(); + + // Act & Assert + var ex = Assert.ThrowsExactly(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); + + Assert.AreEqual("RecipientAddressList", ex.ParamName); + Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message); + + VerifyNoOtherCalls(); + } + + private void VerifyNoOtherCalls() + { + _authenticationMock.VerifyNoOtherCalls(); + _clientOptionsMock.VerifyNoOtherCalls(); + _httpMessageHandlerMock.Mock.VerifyNoOtherCalls(); + } + + private ILinkMobilityClient GetClient() + { + var client = new LinkMobilityClient(_authenticationMock.Object, _clientOptionsMock.Object); + + var httpClient = new HttpClient(_httpMessageHandlerMock.Mock.Object) + { + Timeout = _clientOptionsMock.Object.Timeout, + BaseAddress = new Uri(_clientOptionsMock.Object.BaseUrl) + }; + + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LinkMobilityClient", "1.0.0")); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + _authenticationMock.Object.AddHeader(httpClient); + + _authenticationMock.Invocations.Clear(); + _clientOptionsMock.Invocations.Clear(); + + ReflectionHelper.GetPrivateField(client, "_httpClient")?.Dispose(); + ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient); + + return client; + } + } +} diff --git a/test/LinkMobility.Tests/SendTextMessageTest.cs b/test/LinkMobility.Tests/Sms/SendTextMessageTest.cs similarity index 59% rename from test/LinkMobility.Tests/SendTextMessageTest.cs rename to test/LinkMobility.Tests/Sms/SendTextMessageTest.cs index 6abdcb5..3ba925b 100644 --- a/test/LinkMobility.Tests/SendTextMessageTest.cs +++ b/test/LinkMobility.Tests/Sms/SendTextMessageTest.cs @@ -6,11 +6,10 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using AMWD.Net.Api.LinkMobility; -using AMWD.Net.Api.LinkMobility.Requests; using LinkMobility.Tests.Helpers; using Moq.Protected; -namespace LinkMobility.Tests +namespace LinkMobility.Tests.Sms { [TestClass] public class SendTextMessageTest @@ -43,7 +42,7 @@ namespace LinkMobility.Tests _clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true); _clientOptionsMock.Setup(c => c.UseProxy).Returns(false); - _request = new SendTextMessageRequest("Happy Testing", ["4791234567"]); + _request = new SendTextMessageRequest("example message content", ["436991234567"]); } [TestMethod] @@ -53,7 +52,7 @@ namespace LinkMobility.Tests _httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent("{}", Encoding.UTF8, "application/json"), + Content = new StringContent(@"{ ""clientMessageId"": ""myUniqueId"", ""smsCount"": 1, ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""0059d0b20100a0a8b803"" }", Encoding.UTF8, "application/json"), }); var client = GetClient(); @@ -64,12 +63,78 @@ namespace LinkMobility.Tests // Assert Assert.IsNotNull(response); + Assert.AreEqual("myUniqueId", response.ClientMessageId); + Assert.AreEqual(1, response.SmsCount); + Assert.AreEqual(StatusCodes.Ok, response.StatusCode); + Assert.AreEqual("OK", response.StatusMessage); + Assert.AreEqual("0059d0b20100a0a8b803", response.TransferId); + Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks); var callback = _httpMessageHandlerMock.RequestCallbacks.First(); Assert.AreEqual(HttpMethod.Post, callback.HttpMethod); Assert.AreEqual("https://localhost/rest/smsmessaging/text", callback.Url); - Assert.AreEqual(@"{""messageContent"":""Happy Testing"",""recipientAddressList"":[""4791234567""]}", callback.Content); + Assert.AreEqual(@"{""messageContent"":""example message content"",""recipientAddressList"":[""436991234567""]}", callback.Content); + + Assert.HasCount(3, callback.Headers); + Assert.IsTrue(callback.Headers.ContainsKey("Accept")); + Assert.IsTrue(callback.Headers.ContainsKey("Authorization")); + Assert.IsTrue(callback.Headers.ContainsKey("User-Agent")); + + Assert.AreEqual("application/json", callback.Headers["Accept"]); + Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); + Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); + + _httpMessageHandlerMock.Mock + .Protected() + .Verify("SendAsync", Times.Once(), ItExpr.IsAny(), ItExpr.IsAny()); + + _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); + VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldSendTextMessageFullDetails() + { + // Arrange + _request.ClientMessageId = "myCustomId"; + _request.ContentCategory = ContentCategory.Informational; + _request.MaxSmsPerMessage = 1; + _request.MessageType = MessageType.Voice; + _request.NotificationCallbackUrl = "https://user:pass@example.com/callback/"; + _request.Priority = 5; + _request.SendAsFlashSms = false; + _request.SenderAddress = "4369912345678"; + _request.SenderAddressType = AddressType.International; + _request.Test = false; + _request.ValidityPeriode = 300; + + _httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(@"{ ""clientMessageId"": ""myCustomId"", ""smsCount"": 1, ""statusCode"": 4035, ""statusMessage"": ""SMS_DISABLED"", ""transferId"": ""0059d0b20100a0a8b803"" }", Encoding.UTF8, "application/json"), + }); + + var client = GetClient(); + + // Act + var response = await client.SendTextMessage(_request, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + + Assert.AreEqual("myCustomId", response.ClientMessageId); + Assert.AreEqual(1, response.SmsCount); + Assert.AreEqual(StatusCodes.SmsDisabled, response.StatusCode); + Assert.AreEqual("SMS_DISABLED", response.StatusMessage); + Assert.AreEqual("0059d0b20100a0a8b803", response.TransferId); + + Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks); + + var callback = _httpMessageHandlerMock.RequestCallbacks.First(); + Assert.AreEqual(HttpMethod.Post, callback.HttpMethod); + Assert.AreEqual("https://localhost/rest/smsmessaging/text", callback.Url); + Assert.AreEqual(@"{""clientMessageId"":""myCustomId"",""contentCategory"":""informational"",""maxSmsPerMessage"":1,""messageContent"":""example message content"",""messageType"":""voice"",""notificationCallbackUrl"":""https://user:pass@example.com/callback/"",""priority"":5,""recipientAddressList"":[""436991234567""],""sendAsFlashSms"":false,""senderAddress"":""4369912345678"",""senderAddressType"":""international"",""test"":false,""validityPeriode"":300}", callback.Content); Assert.HasCount(3, callback.Headers); Assert.IsTrue(callback.Headers.ContainsKey("Accept")); @@ -172,11 +237,6 @@ namespace LinkMobility.Tests httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LinkMobilityClient", "1.0.0")); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - if (_clientOptionsMock.Object.DefaultHeaders.Count > 0) - { - foreach (var headerKvp in _clientOptionsMock.Object.DefaultHeaders) - httpClient.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value); - } _authenticationMock.Object.AddHeader(httpClient); _authenticationMock.Invocations.Clear();