1
0

Extensions instead of partial classes
All checks were successful
Branch Build / build-test-deploy (push) Successful in 44s

This commit is contained in:
2026-03-16 20:13:25 +01:00
parent 94706dd82d
commit 79567c730c
16 changed files with 303 additions and 169 deletions

View File

@@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
_No changes yet_ ### Added
- `Validation` utility class for specifications as MSISDN
### Changed
- Channel implementations (SMS, WhatsApp, ...) are extensions to the `ILinkMobilityClient` interface.
## [v0.1.1] - 2026-03-13 ## [v0.1.1] - 2026-03-13

View File

@@ -9,17 +9,14 @@ namespace AMWD.Net.Api.LinkMobility
public interface ILinkMobilityClient public interface ILinkMobilityClient
{ {
/// <summary> /// <summary>
/// Sends a text message to a list of recipients. /// Performs a POST request to the LINK mobility API.
/// </summary> /// </summary>
/// <typeparam name="TResponse">The type of the response.</typeparam>
/// <typeparam name="TRequest">The type of the request.</typeparam>
/// <param name="requestPath">The path of the API endpoint.</param>
/// <param name="request">The request data.</param> /// <param name="request">The request data.</param>
/// <param name="queryParams">Optional query parameters.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param> /// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
Task<SendMessageResponse> SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default); Task<TResponse> PostAsync<TResponse, TRequest>(string requestPath, TRequest? request, IQueryParameter? queryParams = null, 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

@@ -1,68 +0,0 @@
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);
}
// https://en.wikipedia.org/wiki/MSISDN
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

@@ -13,7 +13,7 @@ namespace AMWD.Net.Api.LinkMobility
/// <summary> /// <summary>
/// Provides a client for interacting with the Link Mobility API. /// Provides a client for interacting with the Link Mobility API.
/// </summary> /// </summary>
public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable public class LinkMobilityClient : ILinkMobilityClient, IDisposable
{ {
private readonly ClientOptions _clientOptions; private readonly ClientOptions _clientOptions;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -78,6 +78,52 @@ namespace AMWD.Net.Api.LinkMobility
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
/// <inheritdoc/>
public async Task<TResponse> PostAsync<TResponse, TRequest>(string requestPath, TRequest? request, IQueryParameter? queryParams = null, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ValidateRequestPath(requestPath);
string requestUrl = BuildRequestUrl(requestPath, queryParams);
var httpContent = ConvertRequest(request);
var httpRequest = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri(requestUrl, UriKind.Relative),
Content = httpContent,
};
var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
var response = await GetResponse<TResponse>(httpResponse, cancellationToken).ConfigureAwait(false);
return response;
}
private string BuildRequestUrl(string requestPath, IQueryParameter? queryParams = null)
{
string path = requestPath.Trim().TrimStart('/');
var param = new Dictionary<string, string>();
if (_clientOptions.DefaultQueryParams.Count > 0)
{
foreach (var kvp in _clientOptions.DefaultQueryParams)
param[kvp.Key] = kvp.Value;
}
var customQueryParams = queryParams?.GetQueryParameters();
if (customQueryParams?.Count > 0)
{
foreach (var kvp in customQueryParams)
param[kvp.Key] = kvp.Value;
}
if (param.Count == 0)
return path;
string queryString = string.Join("&", param.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
return $"{path}?{queryString}";
}
private void ValidateClientOptions() private void ValidateClientOptions()
{ {
if (string.IsNullOrWhiteSpace(_clientOptions.BaseUrl)) if (string.IsNullOrWhiteSpace(_clientOptions.BaseUrl))
@@ -148,51 +194,6 @@ namespace AMWD.Net.Api.LinkMobility
} }
} }
private async Task<TResponse> PostAsync<TResponse, TRequest>(string requestPath, TRequest? request, IQueryParameter? queryParams = null, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ValidateRequestPath(requestPath);
string requestUrl = BuildRequestUrl(requestPath, queryParams);
var httpContent = ConvertRequest(request);
var httpRequest = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri(requestUrl, UriKind.Relative),
Content = httpContent,
};
var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
var response = await GetResponse<TResponse>(httpResponse, cancellationToken).ConfigureAwait(false);
return response;
}
private string BuildRequestUrl(string requestPath, IQueryParameter? queryParams = null)
{
string path = requestPath.Trim().TrimStart('/');
var param = new Dictionary<string, string>();
if (_clientOptions.DefaultQueryParams.Count > 0)
{
foreach (var kvp in _clientOptions.DefaultQueryParams)
param[kvp.Key] = kvp.Value;
}
var customQueryParams = queryParams?.GetQueryParameters();
if (customQueryParams?.Count > 0)
{
foreach (var kvp in customQueryParams)
param[kvp.Key] = kvp.Value;
}
if (param.Count == 0)
return path;
string queryString = string.Join("&", param.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
return $"{path}?{queryString}";
}
private static HttpContent? ConvertRequest<T>(T request) private static HttpContent? ConvertRequest<T>(T request)
{ {
if (request == null) if (request == null)

View File

@@ -56,7 +56,7 @@ namespace AMWD.Net.Api.LinkMobility
/// <summary> /// <summary>
/// <see cref="Type.Text"/>, <see cref="Type.Binary"/>: /// <see cref="Type.Text"/>, <see cref="Type.Binary"/>:
/// <br/> /// <br/>
/// <see cref="AddressType.International"/> defines the number format of the mobile originated <see cref="SenderAddress"/>. /// <see cref="AddressType.International"/> - defines the number format of the mobile originated <see cref="SenderAddress"/>.
/// International numbers always includes the country prefix. /// International numbers always includes the country prefix.
/// </summary> /// </summary>
[JsonProperty("senderAddressType")] [JsonProperty("senderAddressType")]

View File

@@ -8,9 +8,11 @@
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SendBinaryMessageRequest"/> class. /// Initializes a new instance of the <see cref="SendBinaryMessageRequest"/> class.
/// </summary> /// </summary>
/// <param name="recipientAddressList">The recipient list.</param> /// <param name="messageContent">A binary message as base64 encoded lines.</param>
public SendBinaryMessageRequest(IReadOnlyCollection<string> recipientAddressList) /// <param name="recipientAddressList">A list of recipient numbers.</param>
public SendBinaryMessageRequest(IReadOnlyCollection<string> messageContent, IReadOnlyCollection<string> recipientAddressList)
{ {
MessageContent = messageContent;
RecipientAddressList = recipientAddressList; RecipientAddressList = recipientAddressList;
} }
@@ -41,7 +43,7 @@
/// The binary data is transmitted without being changed (using 8 bit alphabet). /// The binary data is transmitted without being changed (using 8 bit alphabet).
/// </remarks> /// </remarks>
[JsonProperty("messageContent")] [JsonProperty("messageContent")]
public IReadOnlyCollection<string>? MessageContent { get; set; } public IReadOnlyCollection<string> MessageContent { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.

View File

@@ -8,8 +8,8 @@
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SendTextMessageRequest"/> class. /// Initializes a new instance of the <see cref="SendTextMessageRequest"/> class.
/// </summary> /// </summary>
/// <param name="messageContent">The message.</param> /// <param name="messageContent">A text message.</param>
/// <param name="recipientAddressList">The recipient list.</param> /// <param name="recipientAddressList">A list of recipient numbers.</param>
public SendTextMessageRequest(string messageContent, IReadOnlyCollection<string> recipientAddressList) public SendTextMessageRequest(string messageContent, IReadOnlyCollection<string> recipientAddressList)
{ {
MessageContent = messageContent; MessageContent = messageContent;

View File

@@ -11,12 +11,6 @@
[JsonProperty("clientMessageId")] [JsonProperty("clientMessageId")]
public string? ClientMessageId { get; set; } public string? ClientMessageId { get; set; }
/// <summary>
/// The actual number of generated SMS.
/// </summary>
[JsonProperty("smsCount")]
public int? SmsCount { get; set; }
/// <summary> /// <summary>
/// Status code. /// Status code.
/// </summary> /// </summary>

View File

@@ -0,0 +1,14 @@
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Response of a text message sent to a list of recipients.
/// </summary>
public class SendTextMessageResponse : SendMessageResponse
{
/// <summary>
/// The actual number of generated SMS.
/// </summary>
[JsonProperty("smsCount")]
public int? SmsCount { get; set; }
}
}

View File

@@ -0,0 +1,78 @@
using System.Threading;
using System.Threading.Tasks;
using AMWD.Net.Api.LinkMobility.Utils;
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 static class TextMessageExtensions
{
/// <summary>
/// Sends a text message to a list of recipients.
/// </summary>
/// <param name="client">The <see cref="ILinkMobilityClient"/> instance.</param>
/// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
public static Task<SendTextMessageResponse> SendTextMessage(this ILinkMobilityClient client, 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));
ValidateRecipientList(request.RecipientAddressList);
ValidateContentCategory(request.ContentCategory);
return client.PostAsync<SendTextMessageResponse, SendTextMessageRequest>("/smsmessaging/text", request, cancellationToken: cancellationToken);
}
/// <summary>
/// Sends a binary message to a list of recipients.
/// </summary>
/// <param name="client">The <see cref="ILinkMobilityClient"/> instance.</param>
/// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
public static Task<SendTextMessageResponse> SendBinaryMessage(this ILinkMobilityClient client, SendBinaryMessageRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.MessageContent == null || request.MessageContent.Count == 0)
throw new ArgumentException("A message must be provided.", nameof(request.MessageContent));
// Easiest way to validate that the string is a valid Base64 string.
// Might throw a ArgumentNullException or FormatException.
foreach (string str in request.MessageContent)
Convert.FromBase64String(str);
ValidateRecipientList(request.RecipientAddressList);
ValidateContentCategory(request.ContentCategory);
return client.PostAsync<SendTextMessageResponse, SendBinaryMessageRequest>("/smsmessaging/binary", request, cancellationToken: cancellationToken);
}
private static void ValidateRecipientList(IReadOnlyCollection<string>? recipientAddressList)
{
if (recipientAddressList == null || recipientAddressList.Count == 0)
throw new ArgumentException("At least one recipient must be provided.", nameof(recipientAddressList));
foreach (string recipient in recipientAddressList)
{
if (!Validation.IsValidMSISDN(recipient))
throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(recipientAddressList));
}
}
private static void ValidateContentCategory(ContentCategory? contentCategory)
{
if (!contentCategory.HasValue)
return;
if (contentCategory.Value != ContentCategory.Informational && contentCategory.Value != ContentCategory.Advertisement)
throw new ArgumentException($"Content category '{contentCategory.Value}' is not valid.", nameof(contentCategory));
}
}
}

View File

@@ -0,0 +1,25 @@
using System.Text.RegularExpressions;
namespace AMWD.Net.Api.LinkMobility.Utils
{
/// <summary>
/// Validation helper for LINK Mobility API requirements.
/// </summary>
public static class Validation
{
/// <summary>
/// Validates whether the provided string is a valid MSISDN (E.164 formatted).
/// <br/>
/// See <see href="https://en.wikipedia.org/wiki/MSISDN">Wikipedia: MSISDN</see> for more information.
/// </summary>
/// <param name="msisdn">The string to validate.</param>
/// <returns><see langword="true"/> for a valid MSISDN number, <see langword="false"/> otherwise.</returns>
public 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

@@ -39,6 +39,8 @@ namespace LinkMobility.Tests.Helpers
public Queue<HttpResponseMessage> Responses { get; } = new(); public Queue<HttpResponseMessage> Responses { get; } = new();
public Mock<HttpClientHandler> Mock { get; } public Mock<HttpClientHandler> Mock { get; }
public IProtectedMock<HttpClientHandler> Protected => Mock.Protected();
} }
internal class HttpRequestMessageCallback internal class HttpRequestMessageCallback

View File

@@ -145,7 +145,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "test", _request, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, TestClass>("test", _request, null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -188,7 +188,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "params/path", _request, queryParams, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, TestClass>("params/path", _request, queryParams, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -256,7 +256,7 @@ namespace LinkMobility.Tests
public void ShouldAssertClientOptions() public void ShouldAssertClientOptions()
{ {
// Arrange + Act // Arrange + Act
var client = GetClient(); _ = GetClient();
// Assert // Assert
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -320,7 +320,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ObjectDisposedException>(async () => await Assert.ThrowsExactlyAsync<ObjectDisposedException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "/request/path", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("/request/path", _request, null, TestContext.CancellationToken);
}); });
} }
@@ -336,7 +336,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", path, _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>(path, _request, null, TestContext.CancellationToken);
}); });
} }
@@ -349,7 +349,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ArgumentException>(async () => await Assert.ThrowsExactlyAsync<ArgumentException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo?bar=baz", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("foo?bar=baz", _request, null, TestContext.CancellationToken);
}); });
} }
@@ -366,7 +366,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "/request/path", _request, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, TestClass>("/request/path", _request, null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -411,7 +411,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "/request/path", stringContent, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, HttpContent>("/request/path", stringContent, null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -455,7 +455,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "posting", null, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, object>("posting", null, null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -501,7 +501,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
var ex = await Assert.ThrowsExactlyAsync<AuthenticationException>(async () => var ex = await Assert.ThrowsExactlyAsync<AuthenticationException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("foo", _request, null, TestContext.CancellationToken);
}); });
Assert.IsNull(ex.InnerException); Assert.IsNull(ex.InnerException);
Assert.AreEqual($"HTTP auth missing: {statusCode}", ex.Message); Assert.AreEqual($"HTTP auth missing: {statusCode}", ex.Message);
@@ -524,7 +524,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
var ex = await Assert.ThrowsExactlyAsync<ApplicationException>(async () => var ex = await Assert.ThrowsExactlyAsync<ApplicationException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("foo", _request, null, TestContext.CancellationToken);
}); });
Assert.IsNull(ex.InnerException); Assert.IsNull(ex.InnerException);
Assert.AreEqual($"Unknown HTTP response: {statusCode}", ex.Message); Assert.AreEqual($"Unknown HTTP response: {statusCode}", ex.Message);
@@ -545,7 +545,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<JsonReaderException>(async () => await Assert.ThrowsExactlyAsync<JsonReaderException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "some-path", _request, null, TestContext.CancellationToken); await client.PostAsync<TestClass, TestClass>("some-path", _request, null, TestContext.CancellationToken);
}); });
} }
@@ -563,7 +563,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
string response = await ReflectionHelper.InvokePrivateMethodAsync<string>(client, "PostAsync", "path", _request, null, TestContext.CancellationToken); string response = await client.PostAsync<string, TestClass>("path", _request, null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);

View File

@@ -42,10 +42,7 @@ namespace LinkMobility.Tests.Sms
_clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true); _clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true);
_clientOptionsMock.Setup(c => c.UseProxy).Returns(false); _clientOptionsMock.Setup(c => c.UseProxy).Returns(false);
_request = new SendBinaryMessageRequest(["436991234567"]) _request = new SendBinaryMessageRequest(["SGVsbG8gV29ybGQ="], ["436991234567"]); // "Hello World" in Base64
{
MessageContent = ["SGVsbG8gV29ybGQ="] // "Hello World" base64
};
} }
[TestMethod] [TestMethod]
@@ -87,14 +84,27 @@ namespace LinkMobility.Tests.Sms
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
[TestMethod]
public void ShouldThrowOnInvalidContentCategoryForBinary()
{
// Arrange
_request.ContentCategory = 0;
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("contentCategory", ex.ParamName);
Assert.StartsWith("Content category '0' is not valid.", ex.Message);
VerifyNoOtherCalls();
}
[TestMethod] [TestMethod]
public async Task ShouldSendBinaryMessageFullDetails() public async Task ShouldSendBinaryMessageFullDetails()
{ {
@@ -145,9 +155,7 @@ namespace LinkMobility.Tests.Sms
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -167,6 +175,36 @@ namespace LinkMobility.Tests.Sms
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
[TestMethod]
public void ShouldThrowOnNullMessageContentList()
{
// Arrange
_request.MessageContent = null;
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("MessageContent", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnEmptyMessageContentList()
{
// Arrange
_request.MessageContent = [];
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("MessageContent", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod] [TestMethod]
public void ShouldThrowOnInvalidMessageEncoding() public void ShouldThrowOnInvalidMessageEncoding()
{ {
@@ -205,7 +243,7 @@ namespace LinkMobility.Tests.Sms
// Act & Assert // Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName); Assert.AreEqual("recipientAddressList", ex.ParamName);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
@@ -224,7 +262,7 @@ namespace LinkMobility.Tests.Sms
// Act & Assert // Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName); Assert.AreEqual("recipientAddressList", ex.ParamName);
Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message); Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message);
VerifyNoOtherCalls(); VerifyNoOtherCalls();

View File

@@ -85,14 +85,27 @@ namespace LinkMobility.Tests.Sms
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
[TestMethod]
public void ShouldThrowOnInvalidContentCategory()
{
// Arrange
_request.ContentCategory = 0;
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("contentCategory", ex.ParamName);
Assert.StartsWith("Content category '0' is not valid.", ex.Message);
VerifyNoOtherCalls();
}
[TestMethod] [TestMethod]
public async Task ShouldSendTextMessageFullDetails() public async Task ShouldSendTextMessageFullDetails()
{ {
@@ -145,9 +158,7 @@ namespace LinkMobility.Tests.Sms
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -192,7 +203,7 @@ namespace LinkMobility.Tests.Sms
// Act & Assert // Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken)); var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName); Assert.AreEqual("recipientAddressList", ex.ParamName);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
@@ -211,7 +222,7 @@ namespace LinkMobility.Tests.Sms
// Act & Assert // Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken)); var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName); Assert.AreEqual("recipientAddressList", ex.ParamName);
Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message); Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message);
VerifyNoOtherCalls(); VerifyNoOtherCalls();

View File

@@ -0,0 +1,33 @@
using AMWD.Net.Api.LinkMobility.Utils;
namespace LinkMobility.Tests.Utils
{
[TestClass]
public class ValidationTest
{
[TestMethod]
[DataRow("10000000")]
[DataRow("12345678")]
[DataRow("123456789012345")]
[DataRow("14155552671")]
public void ShouldValidateMSISDNSuccessful(string msisdn)
{
Assert.IsTrue(Validation.IsValidMSISDN(msisdn));
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("012345678")]
[DataRow("+123456789")]
[DataRow("1234 5678")]
[DataRow("1234567")]
[DataRow("1234567890123456")]
[DataRow("abc1234567")]
public void ShouldValidateMSISDNNotSuccessful(string msisdn)
{
Assert.IsFalse(Validation.IsValidMSISDN(msisdn));
}
}
}