1
0

Send Text Message

This commit is contained in:
2025-12-02 22:49:51 +01:00
parent 23ad083d1d
commit 17ff8f7371
27 changed files with 2157 additions and 0 deletions

130
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,130 @@
image: mcr.microsoft.com/dotnet/sdk:10.0
variables:
TZ: "Europe/Berlin"
LANG: "de"
stages:
- build
- test
- deploy
build-debug:
stage: build
tags:
- docker
- lnx
- 64bit
rules:
- if: $CI_COMMIT_TAG == null
script:
- dotnet build -c Debug --nologo
- mkdir ./artifacts
- shopt -s globstar
- mv ./**/*.nupkg ./artifacts/ || true
- mv ./**/*.snupkg ./artifacts/ || true
artifacts:
paths:
- artifacts/*.nupkg
- artifacts/*.snupkg
expire_in: 1 days
test-debug:
stage: test
dependencies:
- build-debug
tags:
- docker
- lnx
- 64bit
rules:
- if: $CI_COMMIT_TAG == null
coverage: /Branch coverage[\s\S].+%/
before_script:
- dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools
script:
- dotnet test -c Debug --nologo /p:CoverletOutputFormat=Cobertura
- /dotnet-tools/reportgenerator "-reports:${CI_PROJECT_DIR}/**/coverage.cobertura.xml" "-targetdir:/reports" "-reportType:TextSummary"
- cat /reports/Summary.txt
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: ./**/coverage.cobertura.xml
deploy-debug:
stage: deploy
dependencies:
- build-debug
- test-debug
tags:
- docker
- lnx
- server
rules:
- if: $CI_COMMIT_TAG == null
script:
- dotnet nuget push -k $BAGET_APIKEY -s https://nuget.home.am-wd.de/v3/index.json --skip-duplicate artifacts/*.nupkg || true
build-release:
stage: build
tags:
- docker
- lnx
- 64bit
rules:
- if: $CI_COMMIT_TAG != null
script:
- dotnet build -c Release --nologo
- mkdir ./artifacts
- shopt -s globstar
- mv ./**/*.nupkg ./artifacts/
- mv ./**/*.snupkg ./artifacts/
artifacts:
paths:
- artifacts/*.nupkg
- artifacts/*.snupkg
expire_in: 7 days
test-release:
stage: test
dependencies:
- build-release
tags:
- docker
- lnx
- 64bit
rules:
- if: $CI_COMMIT_TAG != null
before_script:
- dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools
script:
- dotnet test -c Release --nologo /p:CoverletOutputFormat=Cobertura
- /dotnet-tools/reportgenerator "-reports:${CI_PROJECT_DIR}/**/coverage.cobertura.xml" "-targetdir:/reports" -reportType:TextSummary
- cat /reports/Summary.txt
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: ./**/coverage.cobertura.xml
deploy-release:
stage: deploy
dependencies:
- build-release
- test-release
tags:
- docker
- lnx
- server
rules:
- if: $CI_COMMIT_TAG != null
script:
- dotnet nuget push -k $NUGET_APIKEY -s https://api.nuget.org/v3/index.json --skip-duplicate artifacts/*.nupkg

24
LinkMobility.slnx Normal file
View File

@@ -0,0 +1,24 @@
<Solution>
<Folder Name="/Solution Items/" />
<Folder Name="/Solution Items/build/">
<File Path=".gitlab-ci.yml" />
<File Path="Directory.Build.props" />
</Folder>
<Folder Name="/Solution Items/config/">
<File Path=".editorconfig" />
<File Path=".gitignore" />
<File Path="CodeMaid.config" />
<File Path="nuget.config" />
</Folder>
<Folder Name="/Solution Items/docs/">
<File Path="CHANGELOG.md" />
<File Path="LICENSE.txt" />
<File Path="README.md" />
</Folder>
<Folder Name="/src/">
<Project Path="src/LinkMobility/LinkMobility.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/LinkMobility.Tests/LinkMobility.Tests.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,31 @@
using System.Net.Http;
using System.Net.Http.Headers;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Implements the <see cref="IAuthentication"/> interface for BEARER authentication.
/// </summary>
public class AccessTokenAuthentication : IAuthentication
{
private readonly string _token;
/// <summary>
/// Initializes a new instance of the <see cref="AccessTokenAuthentication"/> class.
/// </summary>
/// <param name="token">The bearer token.</param>
public AccessTokenAuthentication(string token)
{
if (string.IsNullOrWhiteSpace(token))
throw new ArgumentNullException(nameof(token), "The token cannot be null or whitespace.");
_token = token;
}
/// <inheritdoc/>
public void AddHeader(HttpClient httpClient)
{
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token);
}
}
}

View File

@@ -0,0 +1,42 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Implements the <see cref="IAuthentication"/> interface for BASIC authentication.
/// </summary>
public class BasicAuthentication : IAuthentication
{
private readonly string _username;
private readonly string _password;
/// <summary>
/// Initializes a new instance of the <see cref="BasicAuthentication"/> class.
/// </summary>
/// <param name="username">The username.</param>
/// <param name="password">The password.</param>
public BasicAuthentication(string username, string password)
{
if (string.IsNullOrWhiteSpace(username))
throw new ArgumentNullException(nameof(username), "The username cannot be null or whitespace.");
if (string.IsNullOrWhiteSpace(password))
throw new ArgumentNullException(nameof(password), "The password cannot be null or whitespace.");
_username = username;
_password = password;
}
/// <inheritdoc/>
public void AddHeader(HttpClient httpClient)
{
string plainText = $"{_username}:{_password}";
byte[] plainBytes = Encoding.ASCII.GetBytes(plainText);
string base64 = Convert.ToBase64String(plainBytes);
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64);
}
}
}

View File

@@ -0,0 +1,16 @@
using System.Net.Http;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Defines the interface to add authentication information.
/// </summary>
public interface IAuthentication
{
/// <summary>
/// Adds the required authentication header to the provided <see cref="HttpClient"/> instance.
/// </summary>
/// <param name="httpClient"></param>
void AddHeader(HttpClient httpClient);
}
}

View File

@@ -0,0 +1,45 @@
using System.Net;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Options for the LinkMobility API.
/// </summary>
public class ClientOptions
{
/// <summary>
/// Gets or sets the default base url for the API.
/// </summary>
public virtual string BaseUrl { get; set; } = "https://api.linkmobility.eu/rest/";
/// <summary>
/// Gets or sets the default timeout for the API.
/// </summary>
public virtual TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100);
/// <summary>
/// Gets or sets additional default headers to every request.
/// </summary>
public virtual IDictionary<string, string> DefaultHeaders { get; set; } = new Dictionary<string, string>();
/// <summary>
/// Gets or sets additional default query parameters to every request.
/// </summary>
public virtual IDictionary<string, string> DefaultQueryParams { get; set; } = new Dictionary<string, string>();
/// <summary>
/// Gets or sets a value indicating whether to allow redirects.
/// </summary>
public virtual bool AllowRedirects { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to use a proxy.
/// </summary>
public virtual bool UseProxy { get; set; }
/// <summary>
/// Gets or sets the proxy information.
/// </summary>
public virtual IWebProxy Proxy { get; set; }
}
}

View File

@@ -0,0 +1,24 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Content categories as defined by <see href="https://developer.linkmobility.eu/sms-api/rest-api#operation/sendUsingPOST">Link Mobility</see>.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum ContentCategory
{
/// <summary>
/// Represents content that is classified as informational.
/// </summary>
[EnumMember(Value = "informational")]
Informational = 1,
/// <summary>
/// Represents content that is classified as an advertisement.
/// </summary>
[EnumMember(Value = "advertisement")]
Advertisement = 2,
}
}

View File

@@ -0,0 +1,18 @@
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Specifies the message type.
/// </summary>
public enum MessageType
{
/// <summary>
/// The message is sent as defined in the account settings.
/// </summary>
Default = 1,
/// <summary>
/// The message is sent as voice call.
/// </summary>
Voice = 2,
}
}

View File

@@ -0,0 +1,36 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Specifies the type of sender address.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum SenderAddressType
{
/// <summary>
/// National number.
/// </summary>
[EnumMember(Value = "national")]
National = 1,
/// <summary>
/// International number.
/// </summary>
[EnumMember(Value = "international")]
International = 2,
/// <summary>
/// Alphanumeric sender ID.
/// </summary>
[EnumMember(Value = "alphanumeric")]
Alphanumeric = 3,
/// <summary>
/// Shortcode.
/// </summary>
[EnumMember(Value = "shortcode")]
Shortcode = 4,
}
}

View File

@@ -0,0 +1,149 @@
namespace AMWD.Net.Api.LinkMobility
{
/// <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
{
/// <summary>
/// Request accepted, Message(s) sent.
/// </summary>
Ok = 2000,
/// <summary>
/// Request accepted, Message(s) queued.
/// </summary>
OkQueued = 2001,
/// <summary>
/// Invalid Credentials. Inactive account or customer.
/// </summary>
InvalidCredentials = 4001,
/// <summary>
/// One or more recipients are not in the correct format or are containing invalid MSISDNs.
/// </summary>
InvalidRecipient = 4002,
/// <summary>
/// Invalid Sender. Sender address or type is invalid.
/// </summary>
InvalidSender = 4003,
/// <summary>
/// Invalid messageType.
/// </summary>
InvalidMessageType = 4004,
/// <summary>
/// Invalid clientMessageId.
/// </summary>
InvalidMessageId = 4008,
/// <summary>
/// Message text (messageContent) is invalid.
/// </summary>
InvalidText = 4009,
/// <summary>
/// Message limit is reached.
/// </summary>
MessageLimitExceeded = 4013,
/// <summary>
/// Sender IP address is not authorized.
/// </summary>
UnauthorizedIp = 4014,
/// <summary>
/// Invalid Message Priority.
/// </summary>
InvalidMessagePriority = 4015,
/// <summary>
/// Invalid notification callback url.
/// </summary>
InvalidCallbackAddress = 4016,
/// <summary>
/// A required parameter was not given. The parameter name is shown in the status message.
/// </summary>
ParameterMissing = 4019,
/// <summary>
/// Account is invalid.
/// </summary>
InvalidAccount = 4021,
/// <summary>
/// Access to the API was denied.
/// </summary>
AccessDenied = 4022,
/// <summary>
/// Request limit exceeded for this IP address.
/// </summary>
ThrottlingSpammingIp = 4023,
/// <summary>
/// Transfer rate for immediate transmissions exceeded.
/// Too many recipients in this request (1000).
/// </summary>
ThrottlingTooManyRecipients = 4025,
/// <summary>
/// The message content results in too many (automatically generated) sms segments.
/// </summary>
MaxSmsPerMessageExceeded = 4026,
/// <summary>
/// A message content segment is invalid
/// </summary>
InvalidMessageSegment = 4027,
/// <summary>
/// Recipients not allowed.
/// </summary>
RecipientsNotAllowed = 4029,
/// <summary>
/// All recipients blacklisted.
/// </summary>
RecipientBlacklisted = 4030,
/// <summary>
/// Not allowed to send sms messages.
/// </summary>
SmsDisabled = 4035,
/// <summary>
/// Invalid content category.
/// </summary>
InvalidContentCategory = 4040,
/// <summary>
/// Invalid validity periode.
/// </summary>
InvalidValidityPeriode = 4041,
/// <summary>
/// All of the recipients are blocked by quality rating.
/// </summary>
RecipientsBlockedByQualityRating = 4042,
/// <summary>
/// All of the recipients are blocked by spamcheck.
/// </summary>
RecipientsBlockedBySpamcheck = 4043,
/// <summary>
/// Internal error.
/// </summary>
InternalError = 5000,
/// <summary>
/// Service unavailable.
/// </summary>
ServiceUnavailable = 5003
}
}

View File

@@ -0,0 +1,19 @@
using System.Threading;
using System.Threading.Tasks;
using AMWD.Net.Api.LinkMobility.Requests;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Defines the interface for a Link Mobility API client.
/// </summary>
public interface ILinkMobilityClient
{
/// <summary>
/// Sends a text 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<SendTextMessageResponse> SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default);
}
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net6.0</TargetFrameworks>
<Nullable>enable</Nullable>
<PackageId>AMWD.Net.Api.LinkMobility</PackageId>
<PackageTags>link mobility api</PackageTags>
<AssemblyName>amwd-linkmobility</AssemblyName>
<RootNamespace>AMWD.Net.Api.LinkMobility</RootNamespace>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<EmbedUntrackedSources>false</EmbedUntrackedSources>
<PackageIcon>package-icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Product>LinkMobility API</Product>
<Description>Implementation of the Link Mobility REST API</Description>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Include="../../package-icon.png" Pack="true" PackagePath="/" />
<None Include="../../LICENSE.txt" Pack="true" PackagePath="/" />
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,43 @@
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,223 @@
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Security.Authentication;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Provides a client for interacting with the Link Mobility API.
/// </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;
private bool _isDisposed;
/// <summary>
/// Initializes a new instance of the <see cref="LinkMobilityClient" /> class using basic authentication.
/// </summary>
/// <param name="username">The username used for basic authentication.</param>
/// <param name="password">The password used for basic authentication.</param>
/// <param name="clientOptions">Optional configuration settings for the client.</param>
public LinkMobilityClient(string username, string password, ClientOptions? clientOptions = null)
: this(new BasicAuthentication(username, password), clientOptions)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="LinkMobilityClient"/> class using access token authentication.
/// </summary>
/// <param name="token">The bearer token used for authentication.</param>
/// <param name="clientOptions">Optional configuration settings for the client.</param>
public LinkMobilityClient(string token, ClientOptions? clientOptions = null)
: this(new AccessTokenAuthentication(token), clientOptions)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="LinkMobilityClient"/> class using authentication and optional client
/// configuration.
/// </summary>
/// <param name="authentication">The authentication mechanism used to authorize requests.</param>
/// <param name="clientOptions">Optional client configuration settings.</param>
public LinkMobilityClient(IAuthentication authentication, ClientOptions? clientOptions = null)
{
if (authentication == null)
throw new ArgumentNullException(nameof(authentication));
_clientOptions = clientOptions ?? new ClientOptions();
ValidateClientOptions();
_httpClient = CreateHttpClient();
authentication.AddHeader(_httpClient);
}
/// <summary>
/// Disposes of the resources used by the <see cref="LinkMobilityClient"/> object.
/// </summary>
public void Dispose()
{
if (_isDisposed)
return;
_isDisposed = true;
_httpClient.Dispose();
GC.SuppressFinalize(this);
}
private void ValidateClientOptions()
{
if (string.IsNullOrWhiteSpace(_clientOptions.BaseUrl))
throw new ArgumentNullException(nameof(_clientOptions.BaseUrl), "BaseUrl cannot be null or whitespace.");
if (_clientOptions.Timeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(_clientOptions.Timeout), "Timeout must be greater than zero.");
if (_clientOptions.UseProxy && _clientOptions.Proxy == null)
throw new ArgumentNullException(nameof(_clientOptions.Proxy), "Proxy cannot be null when UseProxy is true.");
}
private static void ValidateRequestPath(string requestPath)
{
if (string.IsNullOrWhiteSpace(requestPath))
throw new ArgumentNullException(nameof(requestPath), "The request path is required");
if (requestPath.Contains('?'))
throw new ArgumentException("Query parameters are not allowed in the request path", nameof(requestPath));
}
private HttpClient CreateHttpClient()
{
string version = GetType().Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? "unknown";
HttpMessageHandler handler;
try
{
handler = new HttpClientHandler
{
AllowAutoRedirect = _clientOptions.AllowRedirects,
UseProxy = _clientOptions.UseProxy,
Proxy = _clientOptions.Proxy
};
}
catch (PlatformNotSupportedException)
{
handler = new HttpClientHandler
{
AllowAutoRedirect = _clientOptions.AllowRedirects
};
}
string baseUrl = _clientOptions.BaseUrl.Trim().TrimEnd('/');
var client = new HttpClient(handler, disposeHandler: true)
{
BaseAddress = new Uri($"{baseUrl}/"),
Timeout = _clientOptions.Timeout
};
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(nameof(LinkMobilityClient), version));
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (_clientOptions.DefaultHeaders.Count > 0)
{
foreach (var headerKvp in _clientOptions.DefaultHeaders)
client.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value);
}
return client;
}
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 response = await _httpClient.PostAsync(requestUrl, httpContent, cancellationToken).ConfigureAwait(false);
return await GetResponse<TResponse>(response, cancellationToken).ConfigureAwait(false);
}
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)
{
if (request == null)
return null;
if (request is HttpContent httpContent)
return httpContent;
string json = JsonConvert.SerializeObject(request, _jsonSerializerSettings);
return new StringContent(json, Encoding.UTF8, "application/json");
}
private static async Task<TResponse> GetResponse<TResponse>(HttpResponseMessage httpResponse, CancellationToken cancellationToken)
{
#if NET6_0_OR_GREATER
string content = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
string content = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
return httpResponse.StatusCode switch
{
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)
?? throw new ApplicationException("Response could not be deserialized"),
_ => throw new ApplicationException($"Unknown HTTP response: {httpResponse.StatusCode}"),
};
}
private void ThrowIfDisposed()
{
if (_isDisposed)
throw new ObjectDisposedException(GetType().FullName);
}
}
}

View File

@@ -0,0 +1,20 @@
namespace AMWD.Net.Api.LinkMobility
{
public class LinkMobilityResponse
{
[JsonProperty("clientMessageId")]
public string ClientMessageId { get; set; }
[JsonProperty("smsCount")]
public int SmsCount { get; set; }
[JsonProperty("statusCode")]
public StatusCodes StatusCode { get; set; }
[JsonProperty("statusMessage")]
public string StatusMessage { get; set; }
[JsonProperty("transferId")]
public string TransferId { get; set; }
}
}

View File

@@ -0,0 +1,12 @@
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Represents options defined via query parameters.
/// </summary>
public interface IQueryParameter
{
/// <summary>
/// Retrieves the query parameters.
IReadOnlyDictionary<string, string> GetQueryParameters();
}
}

View File

@@ -0,0 +1,18 @@
# LinkMobility API
This project aims to implement the [LinkMobility REST API].
## Overview
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.
- [SMS API](https://developer.linkmobility.eu/sms-api/rest-api)
---
Published under MIT License (see [**tl;dr**Legal])
[LinkMobility REST API]: https://developer.linkmobility.eu/
[**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license

View File

@@ -0,0 +1,139 @@
namespace AMWD.Net.Api.LinkMobility.Requests
{
/// <summary>
/// Request to send a text message to a list of recipients.
/// </summary>
public class SendTextMessageRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="SendTextMessageRequest"/> class.
/// </summary>
/// <param name="messageContent">The message.</param>
/// <param name="recipientAddressList">The recipient list.</param>
public SendTextMessageRequest(string messageContent, IReadOnlyCollection<string> recipientAddressList)
{
MessageContent = messageContent;
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>.
/// Specifies the maximum number of SMS to be generated.
/// </summary>
/// <remarks>
/// If the system generates more than this number of SMS, the status code <see cref="StatusCodes.MaxSmsPerMessageExceeded"/> is returned.
/// The default value of this parameter is <c>0</c>.
/// If set to <c>0</c>, no limitation is applied.
/// </remarks>
[JsonProperty("maxSmsPerMessage")]
public int? MaxSmsPerMessage { get; set; }
/// <summary>
/// <em>UTF-8</em> encoded message content.
/// </summary>
[JsonProperty("messageContent")]
public string MessageContent { get; set; }
/// <summary>
/// <em>Optional</em>.
/// Specifies the message type.
/// </summary>
/// <remarks>
/// Allowed values are <see cref="MessageType.Default"/> and <see cref="MessageType.Voice"/>.
/// When using the message type <see cref="MessageType.Default"/>, the outgoing message type is determined based on account settings.
/// Using the message type <see cref="MessageType.Voice"/> triggers a voice call.
/// </remarks>
[JsonProperty("messageType")]
public MessageType? MessageType { 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 SenderAddressType? 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>.
/// 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

@@ -0,0 +1,38 @@
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Response of a text message sent to a list of recipients.
/// </summary>
public class SendTextMessageResponse
{
/// <summary>
/// Contains the message id defined in the request.
/// </summary>
[JsonProperty("clientMessageId")]
public string? ClientMessageId { get; set; }
/// <summary>
/// The actual number of generated SMS.
/// </summary>
[JsonProperty("smsCount")]
public int? SmsCount { get; set; }
/// <summary>
/// Status code.
/// </summary>
[JsonProperty("statusCode")]
public StatusCodes? StatusCode { get; set; }
/// <summary>
/// Description of the response status code.
/// </summary>
[JsonProperty("statusMessage")]
public string? StatusMessage { get; set; }
/// <summary>
/// Unique identifier that is set after successful processing of the request.
/// </summary>
[JsonProperty("transferId")]
public string? TransferId { get; set; }
}
}

View File

@@ -0,0 +1,40 @@
using System.Net.Http;
using AMWD.Net.Api.LinkMobility;
namespace LinkMobility.Tests.Auth
{
[TestClass]
public class AccessTokenAuthenticationTest
{
[TestMethod]
public void ShouldAddHeader()
{
// Arrange
string token = "test_token";
var auth = new AccessTokenAuthentication(token);
using var httpClient = new HttpClient();
// Act
auth.AddHeader(httpClient);
// Assert
Assert.IsTrue(httpClient.DefaultRequestHeaders.Contains("Authorization"));
Assert.AreEqual("Bearer", httpClient.DefaultRequestHeaders.Authorization.Scheme);
Assert.AreEqual(token, httpClient.DefaultRequestHeaders.Authorization.Parameter);
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
public void ShouldThrowArgumentNullExceptionForToken(string token)
{
// Arrange
// Act & Assert
Assert.ThrowsExactly<ArgumentNullException>(() => new AccessTokenAuthentication(token));
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Net.Http;
using System.Text;
using AMWD.Net.Api.LinkMobility;
namespace LinkMobility.Tests.Auth
{
[TestClass]
public class BasicAuthenticationTest
{
[TestMethod]
public void ShouldAddHeader()
{
// Arrange
string username = "user";
string password = "pass";
var auth = new BasicAuthentication(username, password);
using var httpClient = new HttpClient();
// Act
auth.AddHeader(httpClient);
// Assert
Assert.IsTrue(httpClient.DefaultRequestHeaders.Contains("Authorization"));
Assert.AreEqual("Basic", httpClient.DefaultRequestHeaders.Authorization.Scheme);
Assert.AreEqual(Base64(username, password), httpClient.DefaultRequestHeaders.Authorization.Parameter);
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
public void ShouldThrowArgumentNullExceptionForUsername(string username)
{
// Arrange
// Act & Assert
Assert.ThrowsExactly<ArgumentNullException>(() => new BasicAuthentication(username, "pass"));
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
public void ShouldThrowArgumentNullExceptionForPassword(string password)
{
// Arrange
// Act & Assert
Assert.ThrowsExactly<ArgumentNullException>(() => new BasicAuthentication("user", password));
}
private static string Base64(string user, string pass)
{
string plainText = $"{user}:{pass}";
return Convert.ToBase64String(Encoding.ASCII.GetBytes(plainText));
}
}
}

View File

@@ -0,0 +1,56 @@
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Moq.Protected;
namespace LinkMobility.Tests.Helpers
{
internal class HttpMessageHandlerMock
{
public HttpMessageHandlerMock()
{
Mock = new();
Mock.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(async (request, cancellationToken) =>
{
var callback = new HttpRequestMessageCallback
{
HttpMethod = request.Method,
Url = request.RequestUri.ToString(),
Headers = request.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value))
};
if (request.Content != null)
{
callback.ContentRaw = await request.Content.ReadAsByteArrayAsync(cancellationToken);
callback.Content = await request.Content.ReadAsStringAsync(cancellationToken);
}
RequestCallbacks.Add(callback);
})
.ReturnsAsync(Responses.Dequeue);
}
public List<HttpRequestMessageCallback> RequestCallbacks { get; } = [];
public Queue<HttpResponseMessage> Responses { get; } = new();
public Mock<HttpClientHandler> Mock { get; }
}
internal class HttpRequestMessageCallback
{
public HttpMethod HttpMethod { get; set; }
public string Url { get; set; }
public Dictionary<string, string> Headers { get; set; }
public byte[] ContentRaw { get; set; }
public string Content { get; set; }
}
}

View File

@@ -0,0 +1,64 @@
using System.Reflection;
using System.Threading.Tasks;
namespace LinkMobility.Tests.Helpers
{
internal static class ReflectionHelper
{
public static T GetPrivateField<T>(object obj, string fieldName)
{
var field = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
if (field == null)
throw new ArgumentException($"Field '{fieldName}' not found in type '{obj.GetType().FullName}'.");
return (T)field.GetValue(obj);
}
public static void SetPrivateField<T>(object obj, string fieldName, T value)
{
var field = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
if (field == null)
throw new ArgumentException($"Field '{fieldName}' not found in type '{obj.GetType().FullName}'.");
field.SetValue(obj, value);
}
public static async Task<TResult> InvokePrivateMethodAsync<TResult>(object obj, string methodName, params object[] parameters)
{
var method = obj.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance);
if (method == null)
throw new ArgumentException($"Method '{methodName}' not found in type '{obj.GetType().FullName}'.");
// If the method is a generic method definition, construct it with concrete type arguments.
if (method.IsGenericMethodDefinition)
{
var genericArgs = method.GetGenericArguments();
var typeArgs = new Type[genericArgs.Length];
// First generic argument is the return type (TResult)
if (typeArgs.Length > 0)
typeArgs[0] = typeof(TResult);
// For additional generic arguments (e.g., TRequest) try to infer from provided parameters
if (typeArgs.Length > 1)
{
// Common pattern: second generic parameter corresponds to the second method parameter (index 1)
Type inferred = typeof(object);
if (parameters.Length > 1 && parameters[1] != null)
inferred = parameters[1].GetType();
for (int i = 1; i < typeArgs.Length; i++)
typeArgs[i] = inferred;
}
method = method.MakeGenericMethod(typeArgs);
}
var task = (Task)method.Invoke(obj, parameters);
await task.ConfigureAwait(false);
var resultProperty = task.GetType().GetProperty("Result");
return (TResult)resultProperty.GetValue(task);
}
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>Cobertura</CoverletOutputFormat>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.msbuild" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="MSTest.TestAdapter" Version="4.0.2" />
<PackageReference Include="MSTest.TestFramework" Version="4.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\LinkMobility\LinkMobility.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
<Using Include="Moq" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,653 @@
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Authentication;
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
{
[TestClass]
public class LinkMobilityClientTest
{
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 TestClass _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()
{
Str = "Happy Testing",
Int = 54321
};
}
[TestMethod]
public void ShouldInitializeWithBasicAuth()
{
// Arrange
string username = "user";
string password = "pass";
string expectedParameter = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}"));
// Act
using var client = new LinkMobilityClient(username, password);
// Assert
var httpClient = ReflectionHelper.GetPrivateField<HttpClient>(client, "_httpClient");
Assert.IsNotNull(httpClient);
Assert.IsNotNull(httpClient.DefaultRequestHeaders.Authorization);
Assert.AreEqual("Basic", httpClient.DefaultRequestHeaders.Authorization.Scheme);
Assert.AreEqual(expectedParameter, httpClient.DefaultRequestHeaders.Authorization.Parameter);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldInitializeWithBearerAuth()
{
// Arrange
string token = "test_token";
// Act
using var client = new LinkMobilityClient(token);
// Assert
var httpClient = ReflectionHelper.GetPrivateField<HttpClient>(client, "_httpClient");
Assert.IsNotNull(httpClient);
Assert.IsNotNull(httpClient.DefaultRequestHeaders.Authorization);
Assert.AreEqual("Bearer", httpClient.DefaultRequestHeaders.Authorization.Scheme);
Assert.AreEqual(token, httpClient.DefaultRequestHeaders.Authorization.Parameter);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnNullAuthentication()
{
// Arrange
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentNullException>(() => new LinkMobilityClient((IAuthentication)null));
Assert.AreEqual("authentication", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldAddCustomDefaultHeaders()
{
// Arrange
var clientOptions = new ClientOptions();
clientOptions.DefaultHeaders.Add("SomeKey", "SomeValue");
// Act
using var client = new LinkMobilityClient("token", clientOptions);
// Assert
var httpClient = ReflectionHelper.GetPrivateField<HttpClient>(client, "_httpClient");
Assert.IsNotNull(httpClient);
Assert.IsTrue(httpClient.DefaultRequestHeaders.Contains("SomeKey"));
Assert.AreEqual("SomeValue", httpClient.DefaultRequestHeaders.GetValues("SomeKey").First());
VerifyNoOtherCalls();
}
[TestMethod]
public async Task ShouldAddDefaultQueryParameters()
{
// Arrange
_clientOptionsMock
.Setup(o => o.DefaultQueryParams)
.Returns(new Dictionary<string, string>
{
{ "SomeKey", "Some Value" },
{ "key2", "param2" }
});
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{ ""string"": ""some-string"", ""integer"": 123 }", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "test", _request, null, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual("https://localhost/rest/test?SomeKey=Some+Value&key2=param2", callback.Url);
Assert.AreEqual(@"{""string"":""Happy Testing"",""integer"":54321}", 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.Exactly(2));
VerifyNoOtherCalls();
}
[TestMethod]
public async Task ShouldAddCustomQueryParameters()
{
// Arrange
var queryParams = new TestParams();
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{ ""string"": ""some-string"", ""integer"": 123 }", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "params/path", _request, queryParams, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual("https://localhost/rest/params/path?test=query+text", callback.Url);
Assert.AreEqual(@"{""string"":""Happy Testing"",""integer"":54321}", 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 ShouldDisposeHttpClient()
{
// Arrange
var client = GetClient();
// Act
client.Dispose();
// Assert
_httpMessageHandlerMock.Mock
.Protected()
.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldAllowMultipleDispose()
{
// Arrange
var client = GetClient();
// Act
client.Dispose();
client.Dispose();
// Assert
_httpMessageHandlerMock.Mock
.Protected()
.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldAssertClientOptions()
{
// Arrange + Act
var client = GetClient();
// Assert
VerifyNoOtherCalls();
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
public void ShouldThrowArgumentNullForBaseUrlOnAssertClientOptions(string baseUrl)
{
// Arrange
_clientOptionsMock
.Setup(o => o.BaseUrl)
.Returns(baseUrl);
// Act & Assert
Assert.ThrowsExactly<ArgumentNullException>(() =>
{
var client = GetClient();
});
}
[TestMethod]
public void ShouldThrowArgumentOutOfRangeForTimeoutOnAssertClientOptions()
{
// Arrange
_clientOptionsMock
.Setup(o => o.Timeout)
.Returns(TimeSpan.Zero);
// Act & Assert
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() =>
{
var client = GetClient();
});
}
[TestMethod]
public void ShouldThrowArgumentNullForUseProxyOnAssertClientOptions()
{
// Arrange
_clientOptionsMock
.Setup(o => o.UseProxy)
.Returns(true);
// Act & Assert
Assert.ThrowsExactly<ArgumentNullException>(() =>
{
var client = GetClient();
});
}
[TestMethod]
public async Task ShouldThrowDisposed()
{
// Arrange
var client = GetClient();
client.Dispose();
// Act & Assert
await Assert.ThrowsExactlyAsync<ObjectDisposedException>(async () =>
{
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "/request/path", _request, null, TestContext.CancellationToken);
});
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
public async Task ShouldThrowArgumentNullOnRequestPath(string path)
{
// Arrange
var client = GetClient();
// Act & Assert
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () =>
{
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", path, _request, null, TestContext.CancellationToken);
});
}
[TestMethod]
public async Task ShouldThrowArgumentOnRequestPath()
{
// Arrange
var client = GetClient();
// Act & Assert
await Assert.ThrowsExactlyAsync<ArgumentException>(async () =>
{
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo?bar=baz", _request, null, TestContext.CancellationToken);
});
}
[TestMethod]
public async Task ShouldPost()
{
// Arrange
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{ ""string"": ""some-string"", ""integer"": 123 }", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "/request/path", _request, null, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
Assert.AreEqual("some-string", response.Str);
Assert.AreEqual(123, response.Int);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual("https://localhost/rest/request/path", callback.Url);
Assert.AreEqual(@"{""string"":""Happy Testing"",""integer"":54321}", 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 ShouldPostHttpContentDirectly()
{
// Arrange
var stringContent = new StringContent(@"{""test"":""HERE ?""}", Encoding.UTF8, "application/json");
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{ ""string"": ""some-string"", ""integer"": 123 }", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "/request/path", stringContent, null, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
Assert.AreEqual("some-string", response.Str);
Assert.AreEqual(123, response.Int);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual("https://localhost/rest/request/path", callback.Url);
Assert.AreEqual(@"{""test"":""HERE ?""}", 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 ShouldPostWithoutContent()
{
// Arrange
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{ ""string"": ""some-string"", ""integer"": 123 }", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "posting", null, null, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
Assert.AreEqual("some-string", response.Str);
Assert.AreEqual(123, response.Int);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual("https://localhost/rest/posting", callback.Url);
Assert.IsNull(callback.Content);
Assert.IsNull(callback.ContentRaw);
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>());
}
[TestMethod]
[DataRow(HttpStatusCode.Unauthorized)]
[DataRow(HttpStatusCode.Forbidden)]
public async Task ShouldThrowAuthenticationExceptionOnStatusCode(HttpStatusCode statusCode)
{
// Arrange
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(@"", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act & Assert
var ex = await Assert.ThrowsExactlyAsync<AuthenticationException>(async () =>
{
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo", _request, null, TestContext.CancellationToken);
});
Assert.IsNull(ex.InnerException);
Assert.AreEqual($"HTTP auth missing: {statusCode}", ex.Message);
}
[TestMethod]
[DataRow(HttpStatusCode.NotFound)]
[DataRow(HttpStatusCode.InternalServerError)]
public async Task ShouldThrowApplicationExceptionOnStatusCode(HttpStatusCode statusCode)
{
// Arrange
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(@"", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act & Assert
var ex = await Assert.ThrowsExactlyAsync<ApplicationException>(async () =>
{
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo", _request, null, TestContext.CancellationToken);
});
Assert.IsNull(ex.InnerException);
Assert.AreEqual($"Unknown HTTP response: {statusCode}", ex.Message);
}
[TestMethod]
public async Task ShouldThrowExceptionOnInvalidResponse()
{
// Arrange
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("This is a bad text :p", Encoding.UTF8, "text/plain"),
});
var client = GetClient();
// Act & Assert
await Assert.ThrowsExactlyAsync<JsonReaderException>(async () =>
{
await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "some-path", _request, null, TestContext.CancellationToken);
});
}
[TestMethod]
public async Task ShouldOnlySerializeNonNullValues()
{
// Arrange
_request.Str = null;
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("\"This is an awesome text ;-)\"", Encoding.UTF8, "text/plain"),
});
var client = GetClient();
// Act
string response = await ReflectionHelper.InvokePrivateMethodAsync<string>(client, "PostAsync", "path", _request, null, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
Assert.AreEqual("This is an awesome text ;-)", response);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual("https://localhost/rest/path", callback.Url);
Assert.AreEqual(@"{""integer"":54321}", 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();
}
private void VerifyNoOtherCalls()
{
_authenticationMock.VerifyNoOtherCalls();
_clientOptionsMock.VerifyNoOtherCalls();
_httpMessageHandlerMock.Mock.VerifyNoOtherCalls();
}
private LinkMobilityClient 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"));
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();
_clientOptionsMock.Invocations.Clear();
ReflectionHelper.GetPrivateField<HttpClient>(client, "_httpClient")?.Dispose();
ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient);
return client;
}
private class TestClass
{
[JsonProperty("string")]
public string Str { get; set; }
[JsonProperty("integer")]
public int Int { get; set; }
}
private class TestParams : IQueryParameter
{
public IReadOnlyDictionary<string, string> GetQueryParameters()
{
return new Dictionary<string, string>
{
{ "test", "query text" }
};
}
}
}
}

View File

@@ -0,0 +1 @@
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]

View File

@@ -0,0 +1,191 @@
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 AMWD.Net.Api.LinkMobility.Requests;
using LinkMobility.Tests.Helpers;
using Moq.Protected;
namespace LinkMobility.Tests
{
[TestClass]
public class SendTextMessageTest
{
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 SendTextMessageRequest _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 SendTextMessageRequest("Happy Testing", ["4791234567"]);
}
[TestMethod]
public async Task ShouldSendTextMessage()
{
// Arrange
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act
var response = await client.SendTextMessage(_request, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
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.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.SendTextMessage(null, TestContext.CancellationToken));
Assert.AreEqual("request", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
public void ShouldThrowOnMissingMessage(string message)
{
// Arrange
var req = new SendTextMessageRequest(message, ["4791234567"]);
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
Assert.AreEqual("MessageContent", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnNoRecipients()
{
// Arrange
var req = new SendTextMessageRequest("Hello", []);
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("invalid-recipient")]
public void ShouldThrowOnInvalidRecipient(string recipient)
{
// Arrange
var client = GetClient();
var req = new SendTextMessageRequest("Hello", ["4791234567", recipient]);
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, 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"));
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();
_clientOptionsMock.Invocations.Clear();
ReflectionHelper.GetPrivateField<HttpClient>(client, "_httpClient")?.Dispose();
ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient);
return client;
}
}
}