1
0

Implemented SMS messaging

This commit is contained in:
2025-12-03 18:46:08 +01:00
parent 17ff8f7371
commit 4d2d1fb9d5
22 changed files with 1000 additions and 84 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -7,7 +7,7 @@ namespace AMWD.Net.Api.LinkMobility
/// Specifies the type of sender address.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum SenderAddressType
public enum AddressType
{
/// <summary>
/// National number.

View File

@@ -0,0 +1,48 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Defines the delivery status of a message on a report.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum DeliveryStatus
{
/// <summary>
/// Message has been delivered to the recipient.
/// </summary>
[EnumMember(Value = "delivered")]
Delivered = 1,
/// <summary>
/// Message not delivered and will be re-tried.
/// </summary>
[EnumMember(Value = "undelivered")]
Undelivered = 2,
/// <summary>
/// Message has expired and will no longer re-tried.
/// </summary>
[EnumMember(Value = "expired")]
Expired = 3,
/// <summary>
/// Message has been deleted.
/// </summary>
[EnumMember(Value = "deleted")]
Deleted = 4,
/// <summary>
/// Message has been accepted by the carrier.
/// </summary>
[EnumMember(Value = "accepted")]
Accepted = 5,
/// <summary>
/// Message has been rejected by the carrier.
/// </summary>
[EnumMember(Value = "rejected")]
Rejected = 6
}
}

View File

@@ -0,0 +1,36 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Defines the types of delivery methods on a report.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum DeliveryType
{
/// <summary>
/// Message sent via SMS.
/// </summary>
[EnumMember(Value = "sms")]
Sms = 1,
/// <summary>
/// Message sent as Push message.
/// </summary>
[EnumMember(Value = "push")]
Push = 2,
/// <summary>
/// Message sent as failover SMS.
/// </summary>
[EnumMember(Value = "failover-sms")]
FailoverSms = 3,
/// <summary>
/// Message sent as voice message.
/// </summary>
[EnumMember(Value = "voice")]
Voice = 4
}
}

View File

@@ -1,18 +1,24 @@
namespace AMWD.Net.Api.LinkMobility
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Specifies the message type.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum MessageType
{
/// <summary>
/// The message is sent as defined in the account settings.
/// </summary>
[EnumMember(Value = "default")]
Default = 1,
/// <summary>
/// The message is sent as voice call.
/// </summary>
[EnumMember(Value = "voice")]
Voice = 2,
}
}

View File

@@ -3,7 +3,7 @@
/// <summary>
/// Custom status codes as defined by <see href="https://developer.linkmobility.eu/sms-api/rest-api#section/Status-codes">Link Mobility</see>.
/// </summary>
public enum StatusCodes
public enum StatusCodes : int
{
/// <summary>
/// Request accepted, Message(s) sent.

View File

@@ -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
/// </summary>
/// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
Task<SendTextMessageResponse> SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default);
Task<SendMessageResponse> SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a binary message to a list of recipients.
/// </summary>
/// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
Task<SendMessageResponse> SendBinaryMessage(SendBinaryMessageRequest request, CancellationToken cancellationToken = default);
}
}

View File

@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<PackageId>AMWD.Net.Api.LinkMobility</PackageId>
<PackageTags>link mobility api</PackageTags>
<PackageTags>link mobility api messaging sms</PackageTags>
<AssemblyName>amwd-linkmobility</AssemblyName>
<RootNamespace>AMWD.Net.Api.LinkMobility</RootNamespace>
@@ -18,8 +18,8 @@
<PackageIcon>package-icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Product>LinkMobility API</Product>
<Description>Implementation of the Link Mobility REST API</Description>
<Product>LINK Mobility REST API</Product>
<Description>Implementation of the LINK Mobility REST API using .NET</Description>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>

View File

@@ -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
{
/// <summary>
/// Sends a text message to a list of recipients.
/// </summary>
/// <param name="request">The request.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
public Task<SendTextMessageResponse> 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<SendTextMessageResponse, SendTextMessageRequest>("/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);
}
}
}

View File

@@ -0,0 +1,67 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Implementation of text messaging (SMS). <see href="https://developer.linkmobility.eu/sms-api/rest-api">API</see>
/// </summary>
public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable
{
/// <inheritdoc/>
public Task<SendMessageResponse> 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<SendMessageResponse, SendTextMessageRequest>("/smsmessaging/text", request, cancellationToken: cancellationToken);
}
/// <inheritdoc/>
public Task<SendMessageResponse> 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<SendMessageResponse, SendBinaryMessageRequest>("/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);
}
}
}

View File

@@ -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
/// </summary>
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<TResponse>(content, _jsonSerializerSettings)
content.DeserializeObject<TResponse>()
?? throw new ApplicationException("Response could not be deserialized"),
_ => throw new ApplicationException($"Unknown HTTP response: {httpResponse.StatusCode}"),
};

View File

@@ -0,0 +1,194 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Represents a notification for an incoming message or delivery report. (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>)
/// </summary>
public class IncomingMessageNotification
{
/// <summary>
/// Initializes a new instance of the <see cref="IncomingMessageNotification"/> class.
/// </summary>
/// <param name="notificationId">The notification id.</param>
/// <param name="transferId">The transfer id.</param>
public IncomingMessageNotification(string notificationId, string transferId)
{
NotificationId = notificationId;
TransferId = transferId;
}
/// <summary>
/// Defines the content type of your notification.
/// </summary>
[JsonProperty("messageType")]
public Type MessageType { get; set; }
/// <summary>
/// 20 digit long identification of your notification.
/// </summary>
[JsonProperty("notificationId")]
public string NotificationId { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>:
/// <br/>
/// Unique transfer-id to connect the deliveryReport to the initial message.
/// </summary>
[JsonProperty("transferId")]
public string TransferId { get; set; }
/// <summary>
/// <see cref="Type.Text"/>, <see cref="Type.Binary"/>:
/// <br/>
/// Indicates whether you received message is a SMS or a flash-SMS.
/// </summary>
[JsonProperty("messageFlashSms")]
public bool? MessageFlashSms { get; set; }
/// <summary>
/// Originator of the sender.
/// </summary>
[JsonProperty("senderAddress")]
public string? SenderAddress { get; set; }
/// <summary>
/// <see cref="Type.Text"/>, <see cref="Type.Binary"/>:
/// <br/>
/// <see cref="AddressType.International"/> defines the number format of the mobile originated <see cref="SenderAddress"/>.
/// International numbers always includes the country prefix.
/// </summary>
[JsonProperty("senderAddressType")]
public AddressType? SenderAddressType { get; set; }
/// <summary>
/// Senders address, can either be
/// <see cref="AddressType.International"/> (4366012345678),
/// <see cref="AddressType.National"/> (066012345678) or a
/// <see cref="AddressType.Shortcode"/> (1234).
/// </summary>
[JsonProperty("recipientAddress")]
public string? RecipientAddress { get; set; }
/// <summary>
/// <see cref="Type.Text"/>, <see cref="Type.Binary"/>:
/// <br/>
/// Defines the number format of the mobile originated message.
/// </summary>
[JsonProperty("recipientAddressType")]
public AddressType? RecipientAddressType { get; set; }
/// <summary>
/// <see cref="Type.Text"/>:
/// <br/>
/// Text body of the message encoded in <c>UTF-8</c>.
/// In the case of concatenated SMS it will contain the complete content of all segments.
/// </summary>
[JsonProperty("textMessageContent")]
public string? TextMessageContent { get; set; }
/// <summary>
/// <see cref="Type.Binary"/>:
/// <br/>
/// Indicates whether a user-data-header is included within a <c>Base64</c> encoded byte segment.
/// </summary>
[JsonProperty("userDataHeaderPresent")]
public bool? UserDataHeaderPresent { get; set; }
/// <summary>
/// <see cref="Type.Binary"/>:
/// <br/>
/// Content of a binary SMS in an array of <c>Base64</c> strings (URL safe).
/// </summary>
[JsonProperty("binaryMessageContent")]
public IReadOnlyCollection<string>? BinaryMessageContent { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>:
/// <br/>
/// Status of the message.
/// </summary>
[JsonProperty("deliveryReportMessageStatus")]
public DeliveryStatus? DeliveryReportMessageStatus { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>:
/// <br/>
/// ISO 8601 timestamp. Point of time sending the message to recipients address.
/// </summary>
[JsonProperty("sentOn")]
public DateTime? SentOn { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>:
/// <br/>
/// ISO 8601 timestamp. Point of time of submitting the message to the mobile operators network.
/// </summary>
[JsonProperty("deliveredOn")]
public DateTime? DeliveredOn { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>:
/// <br/>
/// Type of delivery used to send the message.
/// </summary>
[JsonProperty("deliveredAs")]
public DeliveryType? DeliveredAs { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>:
/// <br/>
/// In the case of a delivery report, the <see cref="ClientMessageId"/> contains the optional submitted message id.
/// </summary>
[JsonProperty("clientMessageId")]
public string? ClientMessageId { get; set; }
/// <summary>
/// Defines the type of notification.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum Type
{
/// <summary>
/// Notification of an incoming text message.
/// </summary>
[EnumMember(Value = "text")]
Text = 1,
/// <summary>
/// Notification of an incoming binary message.
/// </summary>
[EnumMember(Value = "binary")]
Binary = 2,
/// <summary>
/// Notification of a delivery report.
/// </summary>
[EnumMember(Value = "deliveryReport")]
DeliveryReport = 3
}
/// <summary>
/// Tries to parse the given content as <see cref="IncomingMessageNotification"/>.
/// </summary>
/// <param name="json">The given content (should be the notification json).</param>
/// <param name="notification">The deserialized notification.</param>
/// <returns>
/// <see langword="true"/> if the content could be parsed; otherwise, <see langword="false"/>.
/// </returns>
public static bool TryParse(string json, out IncomingMessageNotification? notification)
{
try
{
notification = json.DeserializeObject<IncomingMessageNotification>();
return notification != null;
}
catch
{
notification = null;
return false;
}
}
}
}

View File

@@ -0,0 +1,27 @@
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Representes the response to an incoming message notification. (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>)
/// </summary>
public class IncomingMessageNotificationResponse
{
/// <summary>
/// Gets or sets the status code of the response.
/// </summary>
[JsonProperty("statusCode")]
public StatusCodes StatusCode { get; set; } = StatusCodes.Ok;
/// <summary>
/// Gets or sets the status message of the response.
/// </summary>
[JsonProperty("statusMessage")]
public string StatusMessage { get; set; } = "OK";
/// <summary>
/// Returns a string representation of the current object in serialized format.
/// </summary>
/// <returns>A string containing the serialized form of the object (json).</returns>
public override string ToString()
=> this.SerializeObject();
}
}

View File

@@ -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

View File

@@ -0,0 +1,128 @@
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Request to send a text message to a list of recipients.
/// </summary>
public class SendBinaryMessageRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="SendBinaryMessageRequest"/> class.
/// </summary>
/// <param name="recipientAddressList">The recipient list.</param>
public SendBinaryMessageRequest(IReadOnlyCollection<string> recipientAddressList)
{
RecipientAddressList = recipientAddressList;
}
/// <summary>
/// <em>Optional</em>.
/// May contain a freely definable message id.
/// </summary>
[JsonProperty("clientMessageId")]
public string? ClientMessageId { get; set; }
/// <summary>
/// <em>Optional</em>.
/// The content category that is used to categorize the message (used for blacklisting).
/// </summary>
/// <remarks>
/// The following content categories are supported: <see cref="ContentCategory.Informational"/> or <see cref="ContentCategory.Advertisement"/>.
/// If no content category is provided, the default setting is used (may be changed inside the web interface).
/// </remarks>
[JsonProperty("contentCategory")]
public ContentCategory? ContentCategory { get; set; }
/// <summary>
/// <em>Optional</em>.
/// Array of <c>Base64</c> encoded binary data.
/// </summary>
/// <remarks>
/// Every element of the array corresponds to a message segment.
/// The binary data is transmitted without being changed (using 8 bit alphabet).
/// </remarks>
[JsonProperty("messageContent")]
public IReadOnlyCollection<string>? MessageContent { get; set; }
/// <summary>
/// <em>Optional</em>.
/// When setting a <c>NotificationCallbackUrl</c> all delivery reports are forwarded to this URL.
/// </summary>
[JsonProperty("notificationCallbackUrl")]
public string? NotificationCallbackUrl { get; set; }
/// <summary>
/// <em>Optional</em>.
/// Priority of the message.
/// </summary>
/// <remarks>
/// Must not exceed the value configured for the account used to send the message.
/// For more information please contact our customer service.
/// </remarks>
[JsonProperty("priority")]
public int? Priority { get; set; }
/// <summary>
/// List of recipients (E.164 formatted <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>s)
/// to whom the message should be sent.
/// <br/>
/// The list of recipients may contain a maximum of <em>1000</em> entries.
/// </summary>
[JsonProperty("recipientAddressList")]
public IReadOnlyCollection<string> RecipientAddressList { get; set; }
/// <summary>
/// <em>Optional</em>.
/// <br/>
/// <see langword="true"/>: The message is sent as flash SMS (displayed directly on the screen of the mobile phone).
/// <br/>
/// <see langword="false"/>: The message is sent as standard text SMS (default).
/// </summary>
[JsonProperty("sendAsFlashSms")]
public bool? SendAsFlashSms { get; set; }
/// <summary>
/// <em>Optional</em>.
/// Address of the sender (assigned to the account) from which the message is sent.
/// </summary>
[JsonProperty("senderAddress")]
public string? SenderAddress { get; set; }
/// <summary>
/// <em>Optional</em>.
/// The sender address type.
/// </summary>
[JsonProperty("senderAddressType")]
public AddressType? SenderAddressType { get; set; }
/// <summary>
/// <em>Optional</em>.
/// <br/>
/// <see langword="true"/>: The transmission is only simulated, no SMS is sent.
/// Depending on the number of recipients the status code <see cref="StatusCodes.Ok"/> or <see cref="StatusCodes.OkQueued"/> is returned.
/// <br/>
/// <see langword="false"/>: No simulation is done. The SMS is sent via the SMS Gateway. (default)
/// </summary>
[JsonProperty("test")]
public bool? Test { get; set; }
/// <summary>
/// <em>Optional</em>.
/// <br/>
/// <see langword="true"/>: Indicates the presence of a user data header in the <see cref="MessageContent"/> property.
/// <br/>
/// <see langword="false"/>: Indicates the absence of a user data header in the <see cref="MessageContent"/> property. (default)
/// </summary>
[JsonProperty("userDataHeaderPresent")]
public bool? UserDataHeaderPresent { get; set; }
/// <summary>
/// <em>Optional</em>.
/// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient.
/// </summary>
/// <remarks>
/// A minimum of 1 minute (<c>60</c> seconds) and a maximum of 3 days (<c>259200</c> seconds) are allowed.
/// </remarks>
[JsonProperty("validityPeriode")]
public int? ValidityPeriode { get; set; }
}
}

View File

@@ -1,4 +1,4 @@
namespace AMWD.Net.Api.LinkMobility.Requests
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Request to send a text message to a list of recipients.
@@ -113,7 +113,7 @@
/// The sender address type.
/// </summary>
[JsonProperty("senderAddressType")]
public SenderAddressType? SenderAddressType { get; set; }
public AddressType? SenderAddressType { get; set; }
/// <summary>
/// <em>Optional</em>.

View File

@@ -3,7 +3,7 @@
/// <summary>
/// Response of a text message sent to a list of recipients.
/// </summary>
public class SendTextMessageResponse
public class SendMessageResponse
{
/// <summary>
/// Contains the message id defined in the request.

View File

@@ -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<T>(this string json)
=> JsonConvert.DeserializeObject<T>(json, _jsonSerializerSettings);
}
}

View File

@@ -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<string> { "SGVsbG8=" }, new List<string>(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);
}
}
}

View File

@@ -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<IAuthentication> _authenticationMock;
private Mock<ClientOptions> _clientOptionsMock;
private HttpMessageHandlerMock _httpMessageHandlerMock;
private SendBinaryMessageRequest _request;
[TestInitialize]
public void Initialize()
{
_authenticationMock = new Mock<IAuthentication>();
_clientOptionsMock = new Mock<ClientOptions>();
_httpMessageHandlerMock = new HttpMessageHandlerMock();
_authenticationMock
.Setup(a => a.AddHeader(It.IsAny<HttpClient>()))
.Callback<HttpClient>(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<string, string>());
_clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary<string, string>());
_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<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_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<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnNullRequest()
{
// Arrange
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentNullException>(() => 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<FormatException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnNullMessageContent()
{
// Arrange
_request.MessageContent = [null];
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentNullException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<HttpClient>(client, "_httpClient")?.Dispose();
ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient);
return client;
}
}
}

View File

@@ -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<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_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();