From 17ff8f73718fb0883dcba00325ab236684442349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 2 Dec 2025 22:49:51 +0100 Subject: [PATCH] Send Text Message --- .gitlab-ci.yml | 130 ++++ LinkMobility.slnx | 24 + .../Auth/AccessTokenAuthentication.cs | 31 + src/LinkMobility/Auth/BasicAuthentication.cs | 42 ++ src/LinkMobility/Auth/IAuthentication.cs | 16 + src/LinkMobility/ClientOptions.cs | 45 ++ src/LinkMobility/Enums/ContentCategory.cs | 24 + src/LinkMobility/Enums/MessageType.cs | 18 + src/LinkMobility/Enums/SenderAddressType.cs | 36 + src/LinkMobility/Enums/StatusCodes.cs | 149 ++++ src/LinkMobility/ILinkMobilityClient.cs | 19 + src/LinkMobility/LinkMobility.csproj | 33 + .../LinkMobilityClient.Messaging.cs | 43 ++ src/LinkMobility/LinkMobilityClient.cs | 223 ++++++ src/LinkMobility/LinkMobilityResponse.cs | 20 + .../QueryParameters/IQueryParameter.cs | 12 + src/LinkMobility/README.md | 18 + .../Requests/SendTextMessageRequest.cs | 139 ++++ .../Responses/SendTextMessageResponse.cs | 38 + .../Auth/AccessTokenAuthenticationTest.cs | 40 ++ .../Auth/BasicAuthenticationTest.cs | 60 ++ .../Helpers/HttpMessageHandlerMock.cs | 56 ++ .../Helpers/ReflectionHelper.cs | 64 ++ .../LinkMobility.Tests.csproj | 32 + .../LinkMobilityClientTest.cs | 653 ++++++++++++++++++ test/LinkMobility.Tests/MSTestSettings.cs | 1 + .../LinkMobility.Tests/SendTextMessageTest.cs | 191 +++++ 27 files changed, 2157 insertions(+) create mode 100644 .gitlab-ci.yml create mode 100644 LinkMobility.slnx create mode 100644 src/LinkMobility/Auth/AccessTokenAuthentication.cs create mode 100644 src/LinkMobility/Auth/BasicAuthentication.cs create mode 100644 src/LinkMobility/Auth/IAuthentication.cs create mode 100644 src/LinkMobility/ClientOptions.cs create mode 100644 src/LinkMobility/Enums/ContentCategory.cs create mode 100644 src/LinkMobility/Enums/MessageType.cs create mode 100644 src/LinkMobility/Enums/SenderAddressType.cs create mode 100644 src/LinkMobility/Enums/StatusCodes.cs create mode 100644 src/LinkMobility/ILinkMobilityClient.cs create mode 100644 src/LinkMobility/LinkMobility.csproj create mode 100644 src/LinkMobility/LinkMobilityClient.Messaging.cs create mode 100644 src/LinkMobility/LinkMobilityClient.cs create mode 100644 src/LinkMobility/LinkMobilityResponse.cs create mode 100644 src/LinkMobility/QueryParameters/IQueryParameter.cs create mode 100644 src/LinkMobility/README.md create mode 100644 src/LinkMobility/Requests/SendTextMessageRequest.cs create mode 100644 src/LinkMobility/Responses/SendTextMessageResponse.cs create mode 100644 test/LinkMobility.Tests/Auth/AccessTokenAuthenticationTest.cs create mode 100644 test/LinkMobility.Tests/Auth/BasicAuthenticationTest.cs create mode 100644 test/LinkMobility.Tests/Helpers/HttpMessageHandlerMock.cs create mode 100644 test/LinkMobility.Tests/Helpers/ReflectionHelper.cs create mode 100644 test/LinkMobility.Tests/LinkMobility.Tests.csproj create mode 100644 test/LinkMobility.Tests/LinkMobilityClientTest.cs create mode 100644 test/LinkMobility.Tests/MSTestSettings.cs create mode 100644 test/LinkMobility.Tests/SendTextMessageTest.cs 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(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(async () => + { + await ReflectionHelper.InvokePrivateMethodAsync(client, "PostAsync", path, _request, null, TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ShouldThrowArgumentOnRequestPath() + { + // Arrange + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await ReflectionHelper.InvokePrivateMethodAsync(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(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(), ItExpr.IsAny()); + + _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(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(), ItExpr.IsAny()); + + _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(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(), ItExpr.IsAny()); + } + + [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(async () => + { + await ReflectionHelper.InvokePrivateMethodAsync(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(async () => + { + await ReflectionHelper.InvokePrivateMethodAsync(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(async () => + { + await ReflectionHelper.InvokePrivateMethodAsync(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(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(), ItExpr.IsAny()); + + _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(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 GetQueryParameters() + { + return new Dictionary + { + { "test", "query text" } + }; + } + } + } +} diff --git a/test/LinkMobility.Tests/MSTestSettings.cs b/test/LinkMobility.Tests/MSTestSettings.cs new file mode 100644 index 0000000..0f29e5d --- /dev/null +++ b/test/LinkMobility.Tests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/test/LinkMobility.Tests/SendTextMessageTest.cs b/test/LinkMobility.Tests/SendTextMessageTest.cs new file mode 100644 index 0000000..6abdcb5 --- /dev/null +++ b/test/LinkMobility.Tests/SendTextMessageTest.cs @@ -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 _authenticationMock; + private Mock _clientOptionsMock; + private HttpMessageHandlerMock _httpMessageHandlerMock; + + private SendTextMessageRequest _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 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(), ItExpr.IsAny()); + + _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); + VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldThrowOnNullRequest() + { + // Arrange + var client = GetClient(); + + // Act & Assert + var ex = Assert.ThrowsExactly(() => client.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(() => 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(() => 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(() => 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(client, "_httpClient")?.Dispose(); + ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient); + + return client; + } + } +}