diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..2d5e8a5
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -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
+
diff --git a/LinkMobility.slnx b/LinkMobility.slnx
new file mode 100644
index 0000000..b11af64
--- /dev/null
+++ b/LinkMobility.slnx
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/LinkMobility/Auth/AccessTokenAuthentication.cs b/src/LinkMobility/Auth/AccessTokenAuthentication.cs
new file mode 100644
index 0000000..278dada
--- /dev/null
+++ b/src/LinkMobility/Auth/AccessTokenAuthentication.cs
@@ -0,0 +1,31 @@
+using System.Net.Http;
+using System.Net.Http.Headers;
+
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Implements the interface for BEARER authentication.
+ ///
+ public class AccessTokenAuthentication : IAuthentication
+ {
+ private readonly string _token;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The bearer token.
+ public AccessTokenAuthentication(string token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentNullException(nameof(token), "The token cannot be null or whitespace.");
+
+ _token = token;
+ }
+
+ ///
+ public void AddHeader(HttpClient httpClient)
+ {
+ httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token);
+ }
+ }
+}
diff --git a/src/LinkMobility/Auth/BasicAuthentication.cs b/src/LinkMobility/Auth/BasicAuthentication.cs
new file mode 100644
index 0000000..937546a
--- /dev/null
+++ b/src/LinkMobility/Auth/BasicAuthentication.cs
@@ -0,0 +1,42 @@
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Implements the interface for BASIC authentication.
+ ///
+ public class BasicAuthentication : IAuthentication
+ {
+ private readonly string _username;
+ private readonly string _password;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The username.
+ /// The password.
+ 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;
+ }
+
+ ///
+ 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);
+ }
+ }
+}
diff --git a/src/LinkMobility/Auth/IAuthentication.cs b/src/LinkMobility/Auth/IAuthentication.cs
new file mode 100644
index 0000000..6344737
--- /dev/null
+++ b/src/LinkMobility/Auth/IAuthentication.cs
@@ -0,0 +1,16 @@
+using System.Net.Http;
+
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Defines the interface to add authentication information.
+ ///
+ public interface IAuthentication
+ {
+ ///
+ /// Adds the required authentication header to the provided instance.
+ ///
+ ///
+ void AddHeader(HttpClient httpClient);
+ }
+}
diff --git a/src/LinkMobility/ClientOptions.cs b/src/LinkMobility/ClientOptions.cs
new file mode 100644
index 0000000..749d64d
--- /dev/null
+++ b/src/LinkMobility/ClientOptions.cs
@@ -0,0 +1,45 @@
+using System.Net;
+
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Options for the LinkMobility API.
+ ///
+ public class ClientOptions
+ {
+ ///
+ /// Gets or sets the default base url for the API.
+ ///
+ public virtual string BaseUrl { get; set; } = "https://api.linkmobility.eu/rest/";
+
+ ///
+ /// Gets or sets the default timeout for the API.
+ ///
+ public virtual TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100);
+
+ ///
+ /// Gets or sets additional default headers to every request.
+ ///
+ public virtual IDictionary DefaultHeaders { get; set; } = new Dictionary();
+
+ ///
+ /// Gets or sets additional default query parameters to every request.
+ ///
+ public virtual IDictionary DefaultQueryParams { get; set; } = new Dictionary();
+
+ ///
+ /// Gets or sets a value indicating whether to allow redirects.
+ ///
+ public virtual bool AllowRedirects { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to use a proxy.
+ ///
+ public virtual bool UseProxy { get; set; }
+
+ ///
+ /// Gets or sets the proxy information.
+ ///
+ public virtual IWebProxy Proxy { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Enums/ContentCategory.cs b/src/LinkMobility/Enums/ContentCategory.cs
new file mode 100644
index 0000000..b6ea67b
--- /dev/null
+++ b/src/LinkMobility/Enums/ContentCategory.cs
@@ -0,0 +1,24 @@
+using System.Runtime.Serialization;
+using Newtonsoft.Json.Converters;
+
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Content categories as defined by Link Mobility.
+ ///
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum ContentCategory
+ {
+ ///
+ /// Represents content that is classified as informational.
+ ///
+ [EnumMember(Value = "informational")]
+ Informational = 1,
+
+ ///
+ /// Represents content that is classified as an advertisement.
+ ///
+ [EnumMember(Value = "advertisement")]
+ Advertisement = 2,
+ }
+}
diff --git a/src/LinkMobility/Enums/MessageType.cs b/src/LinkMobility/Enums/MessageType.cs
new file mode 100644
index 0000000..64af266
--- /dev/null
+++ b/src/LinkMobility/Enums/MessageType.cs
@@ -0,0 +1,18 @@
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Specifies the message type.
+ ///
+ public enum MessageType
+ {
+ ///
+ /// The message is sent as defined in the account settings.
+ ///
+ Default = 1,
+
+ ///
+ /// The message is sent as voice call.
+ ///
+ Voice = 2,
+ }
+}
diff --git a/src/LinkMobility/Enums/SenderAddressType.cs b/src/LinkMobility/Enums/SenderAddressType.cs
new file mode 100644
index 0000000..0b65865
--- /dev/null
+++ b/src/LinkMobility/Enums/SenderAddressType.cs
@@ -0,0 +1,36 @@
+using System.Runtime.Serialization;
+using Newtonsoft.Json.Converters;
+
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Specifies the type of sender address.
+ ///
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum SenderAddressType
+ {
+ ///
+ /// National number.
+ ///
+ [EnumMember(Value = "national")]
+ National = 1,
+
+ ///
+ /// International number.
+ ///
+ [EnumMember(Value = "international")]
+ International = 2,
+
+ ///
+ /// Alphanumeric sender ID.
+ ///
+ [EnumMember(Value = "alphanumeric")]
+ Alphanumeric = 3,
+
+ ///
+ /// Shortcode.
+ ///
+ [EnumMember(Value = "shortcode")]
+ Shortcode = 4,
+ }
+}
diff --git a/src/LinkMobility/Enums/StatusCodes.cs b/src/LinkMobility/Enums/StatusCodes.cs
new file mode 100644
index 0000000..9853904
--- /dev/null
+++ b/src/LinkMobility/Enums/StatusCodes.cs
@@ -0,0 +1,149 @@
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Custom status codes as defined by Link Mobility.
+ ///
+ public enum StatusCodes
+ {
+ ///
+ /// Request accepted, Message(s) sent.
+ ///
+ Ok = 2000,
+
+ ///
+ /// Request accepted, Message(s) queued.
+ ///
+ OkQueued = 2001,
+
+ ///
+ /// Invalid Credentials. Inactive account or customer.
+ ///
+ InvalidCredentials = 4001,
+
+ ///
+ /// One or more recipients are not in the correct format or are containing invalid MSISDNs.
+ ///
+ InvalidRecipient = 4002,
+
+ ///
+ /// Invalid Sender. Sender address or type is invalid.
+ ///
+ InvalidSender = 4003,
+
+ ///
+ /// Invalid messageType.
+ ///
+ InvalidMessageType = 4004,
+
+ ///
+ /// Invalid clientMessageId.
+ ///
+ InvalidMessageId = 4008,
+
+ ///
+ /// Message text (messageContent) is invalid.
+ ///
+ InvalidText = 4009,
+
+ ///
+ /// Message limit is reached.
+ ///
+ MessageLimitExceeded = 4013,
+
+ ///
+ /// Sender IP address is not authorized.
+ ///
+ UnauthorizedIp = 4014,
+
+ ///
+ /// Invalid Message Priority.
+ ///
+ InvalidMessagePriority = 4015,
+
+ ///
+ /// Invalid notification callback url.
+ ///
+ InvalidCallbackAddress = 4016,
+
+ ///
+ /// A required parameter was not given. The parameter name is shown in the status message.
+ ///
+ ParameterMissing = 4019,
+
+ ///
+ /// Account is invalid.
+ ///
+ InvalidAccount = 4021,
+
+ ///
+ /// Access to the API was denied.
+ ///
+ AccessDenied = 4022,
+
+ ///
+ /// Request limit exceeded for this IP address.
+ ///
+ ThrottlingSpammingIp = 4023,
+
+ ///
+ /// Transfer rate for immediate transmissions exceeded.
+ /// Too many recipients in this request (1000).
+ ///
+ ThrottlingTooManyRecipients = 4025,
+
+ ///
+ /// The message content results in too many (automatically generated) sms segments.
+ ///
+ MaxSmsPerMessageExceeded = 4026,
+
+ ///
+ /// A message content segment is invalid
+ ///
+ InvalidMessageSegment = 4027,
+
+ ///
+ /// Recipients not allowed.
+ ///
+ RecipientsNotAllowed = 4029,
+
+ ///
+ /// All recipients blacklisted.
+ ///
+ RecipientBlacklisted = 4030,
+
+ ///
+ /// Not allowed to send sms messages.
+ ///
+ SmsDisabled = 4035,
+
+ ///
+ /// Invalid content category.
+ ///
+ InvalidContentCategory = 4040,
+
+ ///
+ /// Invalid validity periode.
+ ///
+ InvalidValidityPeriode = 4041,
+
+ ///
+ /// All of the recipients are blocked by quality rating.
+ ///
+ RecipientsBlockedByQualityRating = 4042,
+
+ ///
+ /// All of the recipients are blocked by spamcheck.
+ ///
+ RecipientsBlockedBySpamcheck = 4043,
+
+ ///
+ /// Internal error.
+ ///
+ InternalError = 5000,
+
+ ///
+ /// Service unavailable.
+ ///
+ ServiceUnavailable = 5003
+ }
+}
diff --git a/src/LinkMobility/ILinkMobilityClient.cs b/src/LinkMobility/ILinkMobilityClient.cs
new file mode 100644
index 0000000..304848f
--- /dev/null
+++ b/src/LinkMobility/ILinkMobilityClient.cs
@@ -0,0 +1,19 @@
+using System.Threading;
+using System.Threading.Tasks;
+using AMWD.Net.Api.LinkMobility.Requests;
+
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Defines the interface for a Link Mobility API client.
+ ///
+ public interface ILinkMobilityClient
+ {
+ ///
+ /// Sends a text message to a list of recipients.
+ ///
+ /// The request data.
+ /// A cancellation token to propagate notification that operations should be canceled.
+ Task SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/LinkMobility/LinkMobility.csproj b/src/LinkMobility/LinkMobility.csproj
new file mode 100644
index 0000000..f839917
--- /dev/null
+++ b/src/LinkMobility/LinkMobility.csproj
@@ -0,0 +1,33 @@
+
+
+
+ netstandard2.0;net6.0
+ enable
+
+ AMWD.Net.Api.LinkMobility
+ link mobility api
+
+ amwd-linkmobility
+ AMWD.Net.Api.LinkMobility
+
+ true
+ true
+ snupkg
+ false
+
+ package-icon.png
+ README.md
+
+ LinkMobility API
+ Implementation of the Link Mobility REST API
+
+ true
+
+
+
+
+
+
+
+
+
diff --git a/src/LinkMobility/LinkMobilityClient.Messaging.cs b/src/LinkMobility/LinkMobilityClient.Messaging.cs
new file mode 100644
index 0000000..6586ed2
--- /dev/null
+++ b/src/LinkMobility/LinkMobilityClient.Messaging.cs
@@ -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
+ {
+ ///
+ /// Sends a text message to a list of recipients.
+ ///
+ /// The request.
+ /// A cancellation token to propagate notification that operations should be canceled.
+ public Task SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default)
+ {
+ if (request == null)
+ throw new ArgumentNullException(nameof(request));
+
+ if (string.IsNullOrWhiteSpace(request.MessageContent))
+ throw new ArgumentException("A message must be provided.", nameof(request.MessageContent));
+
+ if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0)
+ throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList));
+
+ foreach (string recipient in request.RecipientAddressList)
+ {
+ if (!IsValidMSISDN(recipient))
+ throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
+ }
+
+ return PostAsync("/smsmessaging/text", request, cancellationToken: cancellationToken);
+ }
+
+ private static bool IsValidMSISDN(string msisdn)
+ {
+ if (string.IsNullOrWhiteSpace(msisdn))
+ return false;
+
+ return Regex.IsMatch(msisdn, @"^[1-9][0-9]{7,14}$", RegexOptions.Compiled);
+ }
+ }
+}
diff --git a/src/LinkMobility/LinkMobilityClient.cs b/src/LinkMobility/LinkMobilityClient.cs
new file mode 100644
index 0000000..a6642e2
--- /dev/null
+++ b/src/LinkMobility/LinkMobilityClient.cs
@@ -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
+{
+ ///
+ /// Provides a client for interacting with the Link Mobility API.
+ ///
+ 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;
+
+ ///
+ /// Initializes a new instance of the class using basic authentication.
+ ///
+ /// The username used for basic authentication.
+ /// The password used for basic authentication.
+ /// Optional configuration settings for the client.
+ public LinkMobilityClient(string username, string password, ClientOptions? clientOptions = null)
+ : this(new BasicAuthentication(username, password), clientOptions)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class using access token authentication.
+ ///
+ /// The bearer token used for authentication.
+ /// Optional configuration settings for the client.
+ public LinkMobilityClient(string token, ClientOptions? clientOptions = null)
+ : this(new AccessTokenAuthentication(token), clientOptions)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class using authentication and optional client
+ /// configuration.
+ ///
+ /// The authentication mechanism used to authorize requests.
+ /// Optional client configuration settings.
+ 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);
+ }
+
+ ///
+ /// Disposes of the resources used by the object.
+ ///
+ 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()
+ ?.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 PostAsync(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(response, cancellationToken).ConfigureAwait(false);
+ }
+
+ private string BuildRequestUrl(string requestPath, IQueryParameter? queryParams = null)
+ {
+ string path = requestPath.Trim().TrimStart('/');
+ var param = new Dictionary();
+
+ 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 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 GetResponse(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(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);
+ }
+ }
+}
diff --git a/src/LinkMobility/LinkMobilityResponse.cs b/src/LinkMobility/LinkMobilityResponse.cs
new file mode 100644
index 0000000..cd9f526
--- /dev/null
+++ b/src/LinkMobility/LinkMobilityResponse.cs
@@ -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; }
+ }
+}
diff --git a/src/LinkMobility/QueryParameters/IQueryParameter.cs b/src/LinkMobility/QueryParameters/IQueryParameter.cs
new file mode 100644
index 0000000..444948d
--- /dev/null
+++ b/src/LinkMobility/QueryParameters/IQueryParameter.cs
@@ -0,0 +1,12 @@
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Represents options defined via query parameters.
+ ///
+ public interface IQueryParameter
+ {
+ ///
+ /// Retrieves the query parameters.
+ IReadOnlyDictionary GetQueryParameters();
+ }
+}
diff --git a/src/LinkMobility/README.md b/src/LinkMobility/README.md
new file mode 100644
index 0000000..e526b30
--- /dev/null
+++ b/src/LinkMobility/README.md
@@ -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
diff --git a/src/LinkMobility/Requests/SendTextMessageRequest.cs b/src/LinkMobility/Requests/SendTextMessageRequest.cs
new file mode 100644
index 0000000..8215641
--- /dev/null
+++ b/src/LinkMobility/Requests/SendTextMessageRequest.cs
@@ -0,0 +1,139 @@
+namespace AMWD.Net.Api.LinkMobility.Requests
+{
+ ///
+ /// Request to send a text message to a list of recipients.
+ ///
+ public class SendTextMessageRequest
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The message.
+ /// The recipient list.
+ public SendTextMessageRequest(string messageContent, IReadOnlyCollection recipientAddressList)
+ {
+ MessageContent = messageContent;
+ RecipientAddressList = recipientAddressList;
+ }
+
+ ///
+ /// Optional.
+ /// May contain a freely definable message id.
+ ///
+ [JsonProperty("clientMessageId")]
+ public string? ClientMessageId { get; set; }
+
+ ///
+ /// Optional.
+ /// The content category that is used to categorize the message (used for blacklisting).
+ ///
+ ///
+ /// The following content categories are supported: or .
+ /// If no content category is provided, the default setting is used (may be changed inside the web interface).
+ ///
+ [JsonProperty("contentCategory")]
+ public ContentCategory? ContentCategory { get; set; }
+
+ ///
+ /// Optional.
+ /// Specifies the maximum number of SMS to be generated.
+ ///
+ ///
+ /// If the system generates more than this number of SMS, the status code is returned.
+ /// The default value of this parameter is 0.
+ /// If set to 0, no limitation is applied.
+ ///
+ [JsonProperty("maxSmsPerMessage")]
+ public int? MaxSmsPerMessage { get; set; }
+
+ ///
+ /// UTF-8 encoded message content.
+ ///
+ [JsonProperty("messageContent")]
+ public string MessageContent { get; set; }
+
+ ///
+ /// Optional.
+ /// Specifies the message type.
+ ///
+ ///
+ /// Allowed values are and .
+ /// When using the message type , the outgoing message type is determined based on account settings.
+ /// Using the message type triggers a voice call.
+ ///
+ [JsonProperty("messageType")]
+ public MessageType? MessageType { get; set; }
+
+ ///
+ /// Optional.
+ /// When setting a NotificationCallbackUrl all delivery reports are forwarded to this URL.
+ ///
+ [JsonProperty("notificationCallbackUrl")]
+ public string? NotificationCallbackUrl { get; set; }
+
+ ///
+ /// Optional.
+ /// Priority of the message.
+ ///
+ ///
+ /// Must not exceed the value configured for the account used to send the message.
+ /// For more information please contact our customer service.
+ ///
+ [JsonProperty("priority")]
+ public int? Priority { get; set; }
+
+ ///
+ /// List of recipients (E.164 formatted MSISDNs)
+ /// to whom the message should be sent.
+ ///
+ /// The list of recipients may contain a maximum of 1000 entries.
+ ///
+ [JsonProperty("recipientAddressList")]
+ public IReadOnlyCollection RecipientAddressList { get; set; }
+
+ ///
+ /// Optional.
+ ///
+ /// : The message is sent as flash SMS (displayed directly on the screen of the mobile phone).
+ ///
+ /// : The message is sent as standard text SMS (default).
+ ///
+ [JsonProperty("sendAsFlashSms")]
+ public bool? SendAsFlashSms { get; set; }
+
+ ///
+ /// Optional.
+ /// Address of the sender (assigned to the account) from which the message is sent.
+ ///
+ [JsonProperty("senderAddress")]
+ public string? SenderAddress { get; set; }
+
+ ///
+ /// Optional.
+ /// The sender address type.
+ ///
+ [JsonProperty("senderAddressType")]
+ public SenderAddressType? SenderAddressType { get; set; }
+
+ ///
+ /// Optional.
+ ///
+ /// : The transmission is only simulated, no SMS is sent.
+ /// Depending on the number of recipients the status code or is returned.
+ ///
+ /// : No simulation is done. The SMS is sent via the SMS Gateway. (default)
+ ///
+ [JsonProperty("test")]
+ public bool? Test { get; set; }
+
+ ///
+ /// Optional.
+ /// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient.
+ ///
+ ///
+ /// A minimum of 1 minute (60 seconds) and a maximum of 3 days (259200 seconds) are allowed.
+ ///
+ [JsonProperty("validityPeriode")]
+ public int? ValidityPeriode { get; set; }
+ }
+}
diff --git a/src/LinkMobility/Responses/SendTextMessageResponse.cs b/src/LinkMobility/Responses/SendTextMessageResponse.cs
new file mode 100644
index 0000000..a4860ba
--- /dev/null
+++ b/src/LinkMobility/Responses/SendTextMessageResponse.cs
@@ -0,0 +1,38 @@
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Response of a text message sent to a list of recipients.
+ ///
+ public class SendTextMessageResponse
+ {
+ ///
+ /// Contains the message id defined in the request.
+ ///
+ [JsonProperty("clientMessageId")]
+ public string? ClientMessageId { get; set; }
+
+ ///
+ /// The actual number of generated SMS.
+ ///
+ [JsonProperty("smsCount")]
+ public int? SmsCount { get; set; }
+
+ ///
+ /// Status code.
+ ///
+ [JsonProperty("statusCode")]
+ public StatusCodes? StatusCode { get; set; }
+
+ ///
+ /// Description of the response status code.
+ ///
+ [JsonProperty("statusMessage")]
+ public string? StatusMessage { get; set; }
+
+ ///
+ /// Unique identifier that is set after successful processing of the request.
+ ///
+ [JsonProperty("transferId")]
+ public string? TransferId { get; set; }
+ }
+}
diff --git a/test/LinkMobility.Tests/Auth/AccessTokenAuthenticationTest.cs b/test/LinkMobility.Tests/Auth/AccessTokenAuthenticationTest.cs
new file mode 100644
index 0000000..ca20e79
--- /dev/null
+++ b/test/LinkMobility.Tests/Auth/AccessTokenAuthenticationTest.cs
@@ -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(() => new AccessTokenAuthentication(token));
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/Auth/BasicAuthenticationTest.cs b/test/LinkMobility.Tests/Auth/BasicAuthenticationTest.cs
new file mode 100644
index 0000000..46e182d
--- /dev/null
+++ b/test/LinkMobility.Tests/Auth/BasicAuthenticationTest.cs
@@ -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(() => new BasicAuthentication(username, "pass"));
+ }
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ public void ShouldThrowArgumentNullExceptionForPassword(string password)
+ {
+ // Arrange
+
+ // Act & Assert
+ Assert.ThrowsExactly(() => new BasicAuthentication("user", password));
+ }
+
+ private static string Base64(string user, string pass)
+ {
+ string plainText = $"{user}:{pass}";
+ return Convert.ToBase64String(Encoding.ASCII.GetBytes(plainText));
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/Helpers/HttpMessageHandlerMock.cs b/test/LinkMobility.Tests/Helpers/HttpMessageHandlerMock.cs
new file mode 100644
index 0000000..2060b67
--- /dev/null
+++ b/test/LinkMobility.Tests/Helpers/HttpMessageHandlerMock.cs
@@ -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>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny())
+ .Callback(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 RequestCallbacks { get; } = [];
+
+ public Queue Responses { get; } = new();
+
+ public Mock Mock { get; }
+ }
+
+ internal class HttpRequestMessageCallback
+ {
+ public HttpMethod HttpMethod { get; set; }
+
+ public string Url { get; set; }
+
+ public Dictionary Headers { get; set; }
+
+ public byte[] ContentRaw { get; set; }
+
+ public string Content { get; set; }
+ }
+}
diff --git a/test/LinkMobility.Tests/Helpers/ReflectionHelper.cs b/test/LinkMobility.Tests/Helpers/ReflectionHelper.cs
new file mode 100644
index 0000000..e92d14d
--- /dev/null
+++ b/test/LinkMobility.Tests/Helpers/ReflectionHelper.cs
@@ -0,0 +1,64 @@
+using System.Reflection;
+using System.Threading.Tasks;
+
+namespace LinkMobility.Tests.Helpers
+{
+ internal static class ReflectionHelper
+ {
+ public static T GetPrivateField(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(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 InvokePrivateMethodAsync(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);
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/LinkMobility.Tests.csproj b/test/LinkMobility.Tests/LinkMobility.Tests.csproj
new file mode 100644
index 0000000..37c3457
--- /dev/null
+++ b/test/LinkMobility.Tests/LinkMobility.Tests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net10.0
+
+ false
+ true
+ true
+ Cobertura
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/LinkMobility.Tests/LinkMobilityClientTest.cs b/test/LinkMobility.Tests/LinkMobilityClientTest.cs
new file mode 100644
index 0000000..ed5a887
--- /dev/null
+++ b/test/LinkMobility.Tests/LinkMobilityClientTest.cs
@@ -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 _authenticationMock;
+ private Mock _clientOptionsMock;
+ private HttpMessageHandlerMock _httpMessageHandlerMock;
+
+ private TestClass _request;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ _authenticationMock = new Mock();
+ _clientOptionsMock = new Mock();
+ _httpMessageHandlerMock = new HttpMessageHandlerMock();
+
+ _authenticationMock
+ .Setup(a => a.AddHeader(It.IsAny()))
+ .Callback(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Scheme", "Parameter"));
+
+ _clientOptionsMock.Setup(c => c.BaseUrl).Returns(BASE_URL);
+ _clientOptionsMock.Setup(c => c.Timeout).Returns(TimeSpan.FromSeconds(30));
+ _clientOptionsMock.Setup(c => c.DefaultHeaders).Returns(new Dictionary());
+ _clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary());
+ _clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true);
+ _clientOptionsMock.Setup(c => c.UseProxy).Returns(false);
+
+ _request = new()
+ {
+ 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(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(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(() => 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(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
+ {
+ { "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(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(), ItExpr.IsAny());
+
+ _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(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(), ItExpr.IsAny());
+
+ _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(() =>
+ {
+ var client = GetClient();
+ });
+ }
+
+ [TestMethod]
+ public void ShouldThrowArgumentOutOfRangeForTimeoutOnAssertClientOptions()
+ {
+ // Arrange
+ _clientOptionsMock
+ .Setup(o => o.Timeout)
+ .Returns(TimeSpan.Zero);
+
+ // Act & Assert
+ Assert.ThrowsExactly(() =>
+ {
+ var client = GetClient();
+ });
+ }
+
+ [TestMethod]
+ public void ShouldThrowArgumentNullForUseProxyOnAssertClientOptions()
+ {
+ // Arrange
+ _clientOptionsMock
+ .Setup(o => o.UseProxy)
+ .Returns(true);
+
+ // Act & Assert
+ Assert.ThrowsExactly(() =>
+ {
+ var client = GetClient();
+ });
+ }
+
+ [TestMethod]
+ public async Task ShouldThrowDisposed()
+ {
+ // Arrange
+ var client = GetClient();
+ client.Dispose();
+
+ // Act & Assert
+ await Assert.ThrowsExactlyAsync(async () =>
+ {
+ await ReflectionHelper.InvokePrivateMethodAsync