1
0

Compare commits

1 Commits

Author SHA1 Message Date
0a511726a2 Migrated to Gitea
All checks were successful
Branch Build / build-test-deploy (push) Successful in 3m27s
2026-03-13 18:49:46 +01:00
77 changed files with 1622 additions and 3615 deletions

View File

@@ -30,9 +30,10 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Prepare environment - name: Restore tools
run: | run: |
set -ex set -ex
dotnet tool restore -v q
dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools
echo "CI_SERVER_HOST=${GITEA_SERVER_URL#https://}" >> "$GITEA_ENV" echo "CI_SERVER_HOST=${GITEA_SERVER_URL#https://}" >> "$GITEA_ENV"

View File

@@ -30,9 +30,10 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Prepare environment - name: Restore tools
run: | run: |
set -ex set -ex
dotnet tool restore -v q
dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools
dotnet tool install docfx --tool-path /dotnet-tools dotnet tool install docfx --tool-path /dotnet-tools
echo "CI_SERVER_HOST=${GITEA_SERVER_URL#https://}" >> "$GITEA_ENV" echo "CI_SERVER_HOST=${GITEA_SERVER_URL#https://}" >> "$GITEA_ENV"

130
.gitlab-ci.yml Normal file
View File

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

View File

@@ -7,39 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
_no changes yet_
## [v0.2.0] - 2026-03-24
### Added
- `Validation` utility class for specifications as [MSISDN](https://en.wikipedia.org/wiki/MSISDN)
- Docs rendering using DocFX
- `Personal` as enum value for `ContentCategory`
- WhatsApp support for
- `Audio`
- `Document`
- `Image`
- `Text`
- `Video`
### Changed
- Channel implementations (SMS, WhatsApp, ...) are extensions to the `ILinkMobilityClient` interface.
- Reorganized namespaces to reflect parts of the API
### Removed
- `IQueryParameter` as no usage on API docs found (for now)
## [v0.1.1] - 2026-03-13
### Added
- Added optional parameter to inject your own `HttpClient`
### Changed ### Changed
- Migrated main repository from Gitlab to Gitea - Migrated main repository from Gitlab to Gitea
@@ -60,8 +27,6 @@ _Initial release, SMS only._
[Unreleased]: https://github.com/AM-WD/LinkMobility/compare/v0.2.0...HEAD [Unreleased]: https://github.com/AM-WD/LinkMobility/compare/v0.1.0...HEAD
[v0.2.0]: https://github.com/AM-WD/LinkMobility/compare/v0.1.1...v0.2.0
[v0.1.1]: https://github.com/AM-WD/LinkMobility/compare/v0.1.0...v0.1.1
[v0.1.0]: https://github.com/AM-WD/LinkMobility/commits/v0.1.0 [v0.1.0]: https://github.com/AM-WD/LinkMobility/commits/v0.1.0

View File

@@ -10,11 +10,11 @@ So I decided to implement the current available API myself with a more modern (a
--- ---
Published under [MIT License] (see [choose a license]) Published under [MIT License] (see [**tl;dr**Legal])
[LINK Mobility REST API]: https://developer.linkmobility.eu/ [LINK Mobility REST API]: https://developer.linkmobility.eu/
[outdated repository]: https://github.com/websms-com/websmscom-csharp [outdated repository]: https://github.com/websms-com/websmscom-csharp
[.NET Standard 2.0]: https://learn.microsoft.com/en-us/dotnet/standard/net-standard?tabs=net-standard-2-0 [.NET Standard 2.0]: https://learn.microsoft.com/en-us/dotnet/standard/net-standard?tabs=net-standard-2-0
[MIT License]: LICENSE.txt [MIT License]: LICENSE.txt
[choose a license]: https://choosealicense.com/licenses/mit/ [**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license

View File

@@ -1,61 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json",
"metadata": [
{
"src": [
{
"src": "../",
"files": [
"src/LinkMobility/bin/Release/netstandard2.0/amwd-linkmobility.dll"
]
}
],
"dest": "api",
"outputFormat": "apiPage"
}
],
"build": {
"content": [
{
"files": [ "**/*.{md,yml}" ],
"exclude": [ "_site/**", "obj/**" ]
}
],
"resource": [
{
"files": [ "images/**" ],
"exclude": [ "_site/**", "obj/**" ]
}
],
"output": "_site",
"template": [ "default", "modern", "templates/amwd" ],
"postProcessors": ["ExtractSearchIndex"],
"globalMetadata": {
"_appName": "LINK Mobility REST API for .NET",
"_appTitle": "LINK Mobility REST API for .NET",
"_appFooter": "<span>&copy; AM.WD &mdash; Docs generated using <a href=\"https://dotnet.github.io/docfx\" target=\"_blank\">docfx</a>.</span>",
"_appLogoPath": "images/icon.png",
"_appFaviconPath": "images/favicon.ico",
"_disableBreadcrumb": true,
"_disableContribution": true,
"_enableSearch": true,
"_enableNewTab": true,
"pdf": false
},
"markdownEngineName": "markdig",
"markdownEngineProperties": {
"alerts": {
"TODO": "alert alert-secondary"
}
},
"sitemap": {
"baseUrl": "https://docs.am-wd.de/linkmobility",
"priority": 0.5,
"changefreq": "weekly"
},
"noLangKeyword": false,
"keepFileLink": false,
"cleanupCacheHistory": false,
"disableGitFeatures": true
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 B

View File

@@ -1,24 +0,0 @@
---
_layout: landing
---
# LINK Mobility API
[LINK Mobility] is a company specialized in customer communication.
The available channels are SMS, RCS and WhatsApp Business.
Here you can find the API documentation: https://developer.linkmobility.eu/
## NuGet packages
Here is an overview of the latest package.
| Package URL | Version | Short Description |
|-------------|---------|-------------------|
| [AMWD.Net.Api.LinkMobility] | ![NuGet Version](https://img.shields.io/nuget/v/AMWD.Net.Api.LinkMobility?style=flat-square&logo=nuget) | Package contains the interfaces to handle the API. |
[LINK Mobility]: https://linkmobility.eu
[AMWD.Net.Api.LinkMobility]: https://www.nuget.org/packages/AMWD.Net.Api.LinkMobility

View File

@@ -1,3 +0,0 @@
#logo {
margin-right: 8px;
}

View File

@@ -1,4 +0,0 @@
- name: API
href: api/
- name: GitHub
href: https://github.com/AM-WD/LinkMobility

View File

@@ -8,19 +8,13 @@ namespace AMWD.Net.Api.LinkMobility
public class ClientOptions public class ClientOptions
{ {
/// <summary> /// <summary>
/// Gets or sets the base url for the API. /// Gets or sets the default base url for the API.
/// </summary> /// </summary>
/// <remarks>
/// The default base url is <c>https://api.linkmobility.eu/rest/</c>.
/// </remarks>
public virtual string BaseUrl { get; set; } = "https://api.linkmobility.eu/rest/"; public virtual string BaseUrl { get; set; } = "https://api.linkmobility.eu/rest/";
/// <summary> /// <summary>
/// Gets or sets the default timeout for the API. /// Gets or sets the default timeout for the API.
/// </summary> /// </summary>
/// <remarks>
/// The default timeout is <c>100</c> seconds.
/// </remarks>
public virtual TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100); public virtual TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100);
/// <summary> /// <summary>
@@ -34,7 +28,7 @@ namespace AMWD.Net.Api.LinkMobility
public virtual IDictionary<string, string> DefaultQueryParams { get; set; } = new Dictionary<string, string>(); public virtual IDictionary<string, string> DefaultQueryParams { get; set; } = new Dictionary<string, string>();
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to follow redirects from the server. /// Gets or sets a value indicating whether to allow redirects.
/// </summary> /// </summary>
public virtual bool AllowRedirects { get; set; } public virtual bool AllowRedirects { get; set; }
@@ -46,6 +40,6 @@ namespace AMWD.Net.Api.LinkMobility
/// <summary> /// <summary>
/// Gets or sets the proxy information. /// Gets or sets the proxy information.
/// </summary> /// </summary>
public virtual IWebProxy? Proxy { get; set; } public virtual IWebProxy Proxy { get; set; }
} }
} }

View File

@@ -1,7 +1,7 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Text namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Specifies the type of sender address. /// Specifies the type of sender address.

View File

@@ -20,11 +20,5 @@ namespace AMWD.Net.Api.LinkMobility
/// </summary> /// </summary>
[EnumMember(Value = "advertisement")] [EnumMember(Value = "advertisement")]
Advertisement = 2, Advertisement = 2,
/// <summary>
/// Represents content that is classified as a personal message.
/// </summary>
[EnumMember(Value = "personal")]
Personal = 3,
} }
} }

View File

@@ -1,7 +1,7 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Webhook.Text namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Defines the delivery status of a message on a report. /// Defines the delivery status of a message on a report.

View File

@@ -1,7 +1,7 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Text namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Defines the types of delivery methods on a report. /// Defines the types of delivery methods on a report.

View File

@@ -1,7 +1,7 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Text namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Specifies the message type. /// Specifies the message type.

View File

@@ -1,8 +1,7 @@
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Custom status codes as defined by /// Custom status codes as defined by <see href="https://developer.linkmobility.eu/sms-api/rest-api#section/Status-codes">Link Mobility</see>.
/// <see href="https://developer.linkmobility.eu/sms-api/rest-api#section/Status-codes">LINK Mobility</see>.
/// </summary> /// </summary>
public enum StatusCodes : int public enum StatusCodes : int
{ {

View File

@@ -9,13 +9,17 @@ namespace AMWD.Net.Api.LinkMobility
public interface ILinkMobilityClient public interface ILinkMobilityClient
{ {
/// <summary> /// <summary>
/// Performs a POST request to the LINK Mobility API. /// Sends a text message to a list of recipients.
/// </summary> /// </summary>
/// <typeparam name="TResponse">The type of the response.</typeparam>
/// <typeparam name="TRequest">The type of the request.</typeparam>
/// <param name="requestPath">The path of the API endpoint.</param>
/// <param name="request">The request data.</param> /// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param> /// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
Task<TResponse> PostAsync<TResponse, TRequest>(string requestPath, TRequest? request, CancellationToken cancellationToken = default); Task<SendMessageResponse> SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a binary message to a list of recipients.
/// </summary>
/// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
Task<SendMessageResponse> SendBinaryMessage(SendBinaryMessageRequest request, CancellationToken cancellationToken = default);
} }
} }

View File

@@ -0,0 +1,67 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Implementation of text messaging (SMS). <see href="https://developer.linkmobility.eu/sms-api/rest-api">API</see>
/// </summary>
public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable
{
/// <inheritdoc/>
public Task<SendMessageResponse> SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(request.MessageContent))
throw new ArgumentException("A message must be provided.", nameof(request.MessageContent));
if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0)
throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList));
foreach (string recipient in request.RecipientAddressList)
{
if (!IsValidMSISDN(recipient))
throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
}
return PostAsync<SendMessageResponse, SendTextMessageRequest>("/smsmessaging/text", request, cancellationToken: cancellationToken);
}
/// <inheritdoc/>
public Task<SendMessageResponse> SendBinaryMessage(SendBinaryMessageRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.MessageContent?.Count > 0)
{
// Validate that the string is a valid Base64 string
// Might throw a ArgumentNullException or FormatException
foreach (string str in request.MessageContent)
Convert.FromBase64String(str);
}
if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0)
throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList));
foreach (string recipient in request.RecipientAddressList)
{
if (!IsValidMSISDN(recipient))
throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
}
return PostAsync<SendMessageResponse, SendBinaryMessageRequest>("/smsmessaging/binary", request, cancellationToken: cancellationToken);
}
private static bool IsValidMSISDN(string msisdn)
{
if (string.IsNullOrWhiteSpace(msisdn))
return false;
return Regex.IsMatch(msisdn, @"^[1-9][0-9]{7,14}$", RegexOptions.Compiled);
}
}
}

View File

@@ -7,14 +7,13 @@ using System.Security.Authentication;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Provides a client for interacting with the Link Mobility API. /// Provides a client for interacting with the Link Mobility API.
/// </summary> /// </summary>
public class LinkMobilityClient : ILinkMobilityClient, IDisposable public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable
{ {
private readonly ClientOptions _clientOptions; private readonly ClientOptions _clientOptions;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -27,9 +26,8 @@ namespace AMWD.Net.Api.LinkMobility
/// <param name="username">The username used for basic authentication.</param> /// <param name="username">The username used for basic authentication.</param>
/// <param name="password">The password used for basic authentication.</param> /// <param name="password">The password used for basic authentication.</param>
/// <param name="clientOptions">Optional configuration settings for the client.</param> /// <param name="clientOptions">Optional configuration settings for the client.</param>
/// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom implementation.</param> public LinkMobilityClient(string username, string password, ClientOptions? clientOptions = null)
public LinkMobilityClient(string username, string password, ClientOptions? clientOptions = null, HttpClient? httpClient = null) : this(new BasicAuthentication(username, password), clientOptions)
: this(new BasicAuthentication(username, password), clientOptions, httpClient)
{ {
} }
@@ -38,9 +36,8 @@ namespace AMWD.Net.Api.LinkMobility
/// </summary> /// </summary>
/// <param name="token">The bearer token used for authentication.</param> /// <param name="token">The bearer token used for authentication.</param>
/// <param name="clientOptions">Optional configuration settings for the client.</param> /// <param name="clientOptions">Optional configuration settings for the client.</param>
/// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom implementation.</param> public LinkMobilityClient(string token, ClientOptions? clientOptions = null)
public LinkMobilityClient(string token, ClientOptions? clientOptions = null, HttpClient? httpClient = null) : this(new AccessTokenAuthentication(token), clientOptions)
: this(new AccessTokenAuthentication(token), clientOptions, httpClient)
{ {
} }
@@ -50,8 +47,7 @@ namespace AMWD.Net.Api.LinkMobility
/// </summary> /// </summary>
/// <param name="authentication">The authentication mechanism used to authorize requests.</param> /// <param name="authentication">The authentication mechanism used to authorize requests.</param>
/// <param name="clientOptions">Optional client configuration settings.</param> /// <param name="clientOptions">Optional client configuration settings.</param>
/// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom implementation.</param> public LinkMobilityClient(IAuthentication authentication, ClientOptions? clientOptions = null)
public LinkMobilityClient(IAuthentication authentication, ClientOptions? clientOptions = null, HttpClient? httpClient = null)
{ {
if (authentication == null) if (authentication == null)
throw new ArgumentNullException(nameof(authentication)); throw new ArgumentNullException(nameof(authentication));
@@ -59,15 +55,12 @@ namespace AMWD.Net.Api.LinkMobility
_clientOptions = clientOptions ?? new ClientOptions(); _clientOptions = clientOptions ?? new ClientOptions();
ValidateClientOptions(); ValidateClientOptions();
_httpClient = httpClient ?? CreateHttpClient(); _httpClient = CreateHttpClient();
ConfigureHttpClient(_httpClient);
authentication.AddHeader(_httpClient); authentication.AddHeader(_httpClient);
} }
/// <summary> /// <summary>
/// Disposes all resources used by the <see cref="LinkMobilityClient"/> object. /// Disposes of the resources used by the <see cref="LinkMobilityClient"/> object.
/// This includes the <see cref="HttpClient"/> whether it was injected or created internally.
/// </summary> /// </summary>
public void Dispose() public void Dispose()
{ {
@@ -80,45 +73,6 @@ namespace AMWD.Net.Api.LinkMobility
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
/// <inheritdoc/>
public async Task<TResponse> PostAsync<TResponse, TRequest>(string requestPath, TRequest? request, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ValidateRequestPath(requestPath);
string requestUrl = BuildRequestUrl(requestPath);
var httpContent = ConvertRequest(request);
var httpRequest = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri(requestUrl, UriKind.Relative),
Content = httpContent,
};
var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
var response = await GetResponse<TResponse>(httpResponse, cancellationToken).ConfigureAwait(false);
return response;
}
private string BuildRequestUrl(string requestPath)
{
string path = requestPath.Trim().TrimStart('/');
var param = new Dictionary<string, string>();
if (_clientOptions.DefaultQueryParams.Count > 0)
{
foreach (var kvp in _clientOptions.DefaultQueryParams)
param[kvp.Key] = kvp.Value;
}
if (param.Count == 0)
return path;
string queryString = string.Join("&", param.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
return $"{path}?{queryString}";
}
private void ValidateClientOptions() private void ValidateClientOptions()
{ {
if (string.IsNullOrWhiteSpace(_clientOptions.BaseUrl)) if (string.IsNullOrWhiteSpace(_clientOptions.BaseUrl))
@@ -164,29 +118,61 @@ namespace AMWD.Net.Api.LinkMobility
}; };
} }
var httpClient = new HttpClient(handler, disposeHandler: true);
httpClient.DefaultRequestHeaders.UserAgent.Clear();
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(nameof(LinkMobilityClient), version));
return httpClient;
}
private void ConfigureHttpClient(HttpClient httpClient)
{
string baseUrl = _clientOptions.BaseUrl.Trim().TrimEnd('/'); string baseUrl = _clientOptions.BaseUrl.Trim().TrimEnd('/');
httpClient.BaseAddress = new Uri($"{baseUrl}/"); var client = new HttpClient(handler, disposeHandler: true)
httpClient.Timeout = _clientOptions.Timeout; {
BaseAddress = new Uri($"{baseUrl}/"),
Timeout = _clientOptions.Timeout
};
httpClient.DefaultRequestHeaders.Accept.Clear(); client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(nameof(LinkMobilityClient), version));
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (_clientOptions.DefaultHeaders.Count > 0) if (_clientOptions.DefaultHeaders.Count > 0)
{ {
foreach (var headerKvp in _clientOptions.DefaultHeaders) foreach (var headerKvp in _clientOptions.DefaultHeaders)
httpClient.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value); client.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value);
} }
return client;
}
private async Task<TResponse> PostAsync<TResponse, TRequest>(string requestPath, TRequest? request, IQueryParameter? queryParams = null, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ValidateRequestPath(requestPath);
string requestUrl = BuildRequestUrl(requestPath, queryParams);
var httpContent = ConvertRequest(request);
var response = await _httpClient.PostAsync(requestUrl, httpContent, cancellationToken).ConfigureAwait(false);
return await GetResponse<TResponse>(response, cancellationToken).ConfigureAwait(false);
}
private string BuildRequestUrl(string requestPath, IQueryParameter? queryParams = null)
{
string path = requestPath.Trim().TrimStart('/');
var param = new Dictionary<string, string>();
if (_clientOptions.DefaultQueryParams.Count > 0)
{
foreach (var kvp in _clientOptions.DefaultQueryParams)
param[kvp.Key] = kvp.Value;
}
var customQueryParams = queryParams?.GetQueryParameters();
if (customQueryParams?.Count > 0)
{
foreach (var kvp in customQueryParams)
param[kvp.Key] = kvp.Value;
}
if (param.Count == 0)
return path;
string queryString = string.Join("&", param.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
return $"{path}?{queryString}";
} }
private static HttpContent? ConvertRequest<T>(T request) private static HttpContent? ConvertRequest<T>(T request)

View File

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

View File

@@ -1,20 +1,19 @@
using AMWD.Net.Api.LinkMobility.Text; using System.Runtime.Serialization;
using AMWD.Net.Api.LinkMobility.Utils; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Webhook.Text namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Represents a notification for an incoming text message or delivery report. /// Represents a notification for an incoming message or delivery report. (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>)
/// (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>)
/// </summary> /// </summary>
public class TextNotification public class IncomingMessageNotification
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TextNotification"/> class. /// Initializes a new instance of the <see cref="IncomingMessageNotification"/> class.
/// </summary> /// </summary>
/// <param name="notificationId">The notification id.</param> /// <param name="notificationId">The notification id.</param>
/// <param name="transferId">The transfer id.</param> /// <param name="transferId">The transfer id.</param>
public TextNotification(string notificationId, string transferId) public IncomingMessageNotification(string notificationId, string transferId)
{ {
NotificationId = notificationId; NotificationId = notificationId;
TransferId = transferId; TransferId = transferId;
@@ -24,7 +23,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
/// Defines the content type of your notification. /// Defines the content type of your notification.
/// </summary> /// </summary>
[JsonProperty("messageType")] [JsonProperty("messageType")]
public TextMessageType MessageType { get; set; } public Type MessageType { get; set; }
/// <summary> /// <summary>
/// 20 digit long identification of your notification. /// 20 digit long identification of your notification.
@@ -33,7 +32,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public string NotificationId { get; set; } public string NotificationId { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.DeliveryReport"/>: /// <see cref="Type.DeliveryReport"/>:
/// <br/> /// <br/>
/// Unique transfer-id to connect the deliveryReport to the initial message. /// Unique transfer-id to connect the deliveryReport to the initial message.
/// </summary> /// </summary>
@@ -41,7 +40,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public string TransferId { get; set; } public string TransferId { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.Text"/>, <see cref="Text.TextMessageType.Binary"/>: /// <see cref="Type.Text"/>, <see cref="Type.Binary"/>:
/// <br/> /// <br/>
/// Indicates whether you received message is a SMS or a flash-SMS. /// Indicates whether you received message is a SMS or a flash-SMS.
/// </summary> /// </summary>
@@ -55,9 +54,9 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public string? SenderAddress { get; set; } public string? SenderAddress { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.Text"/>, <see cref="Text.TextMessageType.Binary"/>: /// <see cref="Type.Text"/>, <see cref="Type.Binary"/>:
/// <br/> /// <br/>
/// <see cref="AddressType.International"/> - defines the number format of the mobile originated <see cref="SenderAddress"/>. /// <see cref="AddressType.International"/> defines the number format of the mobile originated <see cref="SenderAddress"/>.
/// International numbers always includes the country prefix. /// International numbers always includes the country prefix.
/// </summary> /// </summary>
[JsonProperty("senderAddressType")] [JsonProperty("senderAddressType")]
@@ -73,7 +72,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public string? RecipientAddress { get; set; } public string? RecipientAddress { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.Text"/>, <see cref="Text.TextMessageType.Binary"/>: /// <see cref="Type.Text"/>, <see cref="Type.Binary"/>:
/// <br/> /// <br/>
/// Defines the number format of the mobile originated message. /// Defines the number format of the mobile originated message.
/// </summary> /// </summary>
@@ -81,7 +80,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public AddressType? RecipientAddressType { get; set; } public AddressType? RecipientAddressType { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.Text"/>: /// <see cref="Type.Text"/>:
/// <br/> /// <br/>
/// Text body of the message encoded in <c>UTF-8</c>. /// Text body of the message encoded in <c>UTF-8</c>.
/// In the case of concatenated SMS it will contain the complete content of all segments. /// In the case of concatenated SMS it will contain the complete content of all segments.
@@ -90,7 +89,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public string? TextMessageContent { get; set; } public string? TextMessageContent { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.Binary"/>: /// <see cref="Type.Binary"/>:
/// <br/> /// <br/>
/// Indicates whether a user-data-header is included within a <c>Base64</c> encoded byte segment. /// Indicates whether a user-data-header is included within a <c>Base64</c> encoded byte segment.
/// </summary> /// </summary>
@@ -98,7 +97,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public bool? UserDataHeaderPresent { get; set; } public bool? UserDataHeaderPresent { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.Binary"/>: /// <see cref="Type.Binary"/>:
/// <br/> /// <br/>
/// Content of a binary SMS in an array of <c>Base64</c> strings (URL safe). /// Content of a binary SMS in an array of <c>Base64</c> strings (URL safe).
/// </summary> /// </summary>
@@ -106,7 +105,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public IReadOnlyCollection<string>? BinaryMessageContent { get; set; } public IReadOnlyCollection<string>? BinaryMessageContent { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.DeliveryReport"/>: /// <see cref="Type.DeliveryReport"/>:
/// <br/> /// <br/>
/// Status of the message. /// Status of the message.
/// </summary> /// </summary>
@@ -114,7 +113,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public DeliveryStatus? DeliveryReportMessageStatus { get; set; } public DeliveryStatus? DeliveryReportMessageStatus { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.DeliveryReport"/>: /// <see cref="Type.DeliveryReport"/>:
/// <br/> /// <br/>
/// ISO 8601 timestamp. Point of time sending the message to recipients address. /// ISO 8601 timestamp. Point of time sending the message to recipients address.
/// </summary> /// </summary>
@@ -122,7 +121,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public DateTime? SentOn { get; set; } public DateTime? SentOn { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.DeliveryReport"/>: /// <see cref="Type.DeliveryReport"/>:
/// <br/> /// <br/>
/// ISO 8601 timestamp. Point of time of submitting the message to the mobile operators network. /// ISO 8601 timestamp. Point of time of submitting the message to the mobile operators network.
/// </summary> /// </summary>
@@ -130,7 +129,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public DateTime? DeliveredOn { get; set; } public DateTime? DeliveredOn { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.DeliveryReport"/>: /// <see cref="Type.DeliveryReport"/>:
/// <br/> /// <br/>
/// Type of delivery used to send the message. /// Type of delivery used to send the message.
/// </summary> /// </summary>
@@ -138,7 +137,7 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public DeliveryType? DeliveredAs { get; set; } public DeliveryType? DeliveredAs { get; set; }
/// <summary> /// <summary>
/// <see cref="Text.TextMessageType.DeliveryReport"/>: /// <see cref="Type.DeliveryReport"/>:
/// <br/> /// <br/>
/// In the case of a delivery report, the <see cref="ClientMessageId"/> contains the optional submitted message id. /// In the case of a delivery report, the <see cref="ClientMessageId"/> contains the optional submitted message id.
/// </summary> /// </summary>
@@ -146,18 +145,43 @@ namespace AMWD.Net.Api.LinkMobility.Webhook.Text
public string? ClientMessageId { get; set; } public string? ClientMessageId { get; set; }
/// <summary> /// <summary>
/// Tries to parse the given content as <see cref="TextNotification"/>. /// Defines the type of notification.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum Type
{
/// <summary>
/// Notification of an incoming text message.
/// </summary>
[EnumMember(Value = "text")]
Text = 1,
/// <summary>
/// Notification of an incoming binary message.
/// </summary>
[EnumMember(Value = "binary")]
Binary = 2,
/// <summary>
/// Notification of a delivery report.
/// </summary>
[EnumMember(Value = "deliveryReport")]
DeliveryReport = 3
}
/// <summary>
/// Tries to parse the given content as <see cref="IncomingMessageNotification"/>.
/// </summary> /// </summary>
/// <param name="json">The given content (should be the notification json).</param> /// <param name="json">The given content (should be the notification json).</param>
/// <param name="notification">The deserialized notification.</param> /// <param name="notification">The deserialized notification.</param>
/// <returns> /// <returns>
/// <see langword="true"/> if the content could be parsed; otherwise, <see langword="false"/>. /// <see langword="true"/> if the content could be parsed; otherwise, <see langword="false"/>.
/// </returns> /// </returns>
public static bool TryParse(string json, out TextNotification? notification) public static bool TryParse(string json, out IncomingMessageNotification? notification)
{ {
try try
{ {
notification = json.DeserializeObject<TextNotification>(); notification = json.DeserializeObject<IncomingMessageNotification>();
return notification != null; return notification != null;
} }
catch catch

View File

@@ -1,15 +1,9 @@
using AMWD.Net.Api.LinkMobility.Utils; namespace AMWD.Net.Api.LinkMobility
namespace AMWD.Net.Api.LinkMobility.Webhook
{ {
/// <summary> /// <summary>
/// Representes the response to an incoming message notification. /// Representes the response to an incoming message notification. (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>)
/// (See <see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>)
/// </summary> /// </summary>
/// <remarks> public class IncomingMessageNotificationResponse
/// This notification acknowlegement is the same for all webhooks of LINK Mobility.
/// </remarks>
public class NotificationResponse
{ {
/// <summary> /// <summary>
/// Gets or sets the status code of the response. /// Gets or sets the status code of the response.

View File

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

View File

@@ -9,11 +9,10 @@ LINK Mobility is a provider for communication with customers via SMS, RCS or Wha
In this project the REST API of LINK Mobility will be implemented. In this project the REST API of LINK Mobility will be implemented.
- [SMS API](https://developer.linkmobility.eu/sms-api/rest-api) - [SMS API](https://developer.linkmobility.eu/sms-api/rest-api)
- [WhatsApp API](https://developer.linkmobility.eu/whatsapp-api/rest-api) (partial, see Changelog)
--- ---
Published under MIT License (see [choose a license]) Published under MIT License (see [**tl;dr**Legal])
[LINK Mobility REST API]: https://developer.linkmobility.eu/ [LINK Mobility REST API]: https://developer.linkmobility.eu/
[choose a license]: https://choosealicense.com/licenses/mit/ [**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license

View File

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

View File

@@ -1,4 +1,4 @@
namespace AMWD.Net.Api.LinkMobility.Text namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Request to send a text message to a list of recipients. /// Request to send a text message to a list of recipients.
@@ -8,8 +8,8 @@
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SendTextMessageRequest"/> class. /// Initializes a new instance of the <see cref="SendTextMessageRequest"/> class.
/// </summary> /// </summary>
/// <param name="messageContent">A text message.</param> /// <param name="messageContent">The message.</param>
/// <param name="recipientAddressList">A list of recipient numbers.</param> /// <param name="recipientAddressList">The recipient list.</param>
public SendTextMessageRequest(string messageContent, IReadOnlyCollection<string> recipientAddressList) public SendTextMessageRequest(string messageContent, IReadOnlyCollection<string> recipientAddressList)
{ {
MessageContent = messageContent; MessageContent = messageContent;

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
using System.Globalization; using System.Globalization;
namespace AMWD.Net.Api.LinkMobility.Utils namespace AMWD.Net.Api.LinkMobility
{ {
internal static class SerializerExtensions internal static class SerializerExtensions
{ {

View File

@@ -1,40 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Utils
{
internal class UnixTimestampJsonConverter : JsonConverter
{
private static readonly DateTime _unixEpoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
public override bool CanConvert(Type objectType)
{
return typeof(DateTime?).IsAssignableFrom(objectType);
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
long? ts = serializer.Deserialize<long?>(reader);
if (ts.HasValue)
return _unixEpoch.AddSeconds(ts.Value);
return null;
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
if (value == null)
{
writer.WriteNull();
return;
}
if (value is DateTime dt)
{
long unixTimestamp = (long)(dt.ToUniversalTime() - _unixEpoch).TotalSeconds;
writer.WriteValue(unixTimestamp);
return;
}
throw new JsonSerializationException("Expected date object value.");
}
}
}

View File

@@ -1,32 +0,0 @@
using System.Text.RegularExpressions;
namespace AMWD.Net.Api.LinkMobility.Utils
{
/// <summary>
/// Validation helper for LINK Mobility API requirements.
/// </summary>
public static class Validation
{
/// <summary>
/// Validates whether the provided string is a valid MSISDN (E.164 formatted).
/// <br/>
/// See <see href="https://en.wikipedia.org/wiki/MSISDN">Wikipedia: MSISDN</see> for more information.
/// </summary>
/// <remarks>
/// It comes down to a string of digits with a length between 8 and 15, starting with a non-zero digit.
/// This is a common format for international phone numbers, where the first few digits represent the country code, followed by the national number.
/// A leading <c>+</c> is has to be removed (not part of the <see href="https://en.wikipedia.org/wiki/E.164">E.164</see>).
/// <br/>
/// Regex (inside): <c>^[1-9][0-9]{7,14}$</c>
/// </remarks>
/// <param name="msisdn">The string to validate.</param>
/// <returns><see langword="true"/> for a valid MSISDN number, <see langword="false"/> otherwise.</returns>
public static bool IsValidMSISDN(string msisdn)
{
if (string.IsNullOrWhiteSpace(msisdn))
return false;
return Regex.IsMatch(msisdn, @"^[1-9][0-9]{7,14}$", RegexOptions.Compiled);
}
}
}

View File

@@ -1,30 +0,0 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Webhook.Text
{
/// <summary>
/// Defines the type of notification.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum TextMessageType
{
/// <summary>
/// Notification of an incoming text message.
/// </summary>
[EnumMember(Value = "text")]
Text = 1,
/// <summary>
/// Notification of an incoming binary message.
/// </summary>
[EnumMember(Value = "binary")]
Binary = 2,
/// <summary>
/// Notification of a delivery report.
/// </summary>
[EnumMember(Value = "deliveryReport")]
DeliveryReport = 3
}
}

View File

@@ -1,32 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a WhatsApp contact.
/// </summary>
public class Contact
{
/// <summary>
/// The user profile information.
/// </summary>
[JsonProperty("profile")]
public Profile? Profile { get; set; }
/// <summary>
/// WhatsApp user ID.
/// </summary>
/// <remarks>
/// Note that a WhatsApp user's ID and phone number may not always match.
/// </remarks>
[JsonProperty("wa_id")]
public string? WhatsAppId { get; set; }
/// <summary>
/// Identity key hash.
/// </summary>
/// <remarks>
/// Only included if you have enabled the <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/phone-numbers">identity change check</see> feature.
/// </remarks>
[JsonProperty("identity_key_hash")]
public string? IdentityKeyHash { get; set; }
}
}

View File

@@ -1,45 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received audio file.
/// </summary>
public class AudioContent
{
/// <summary>
/// Media asset MIME type.
/// </summary>
[JsonProperty("mime_type")]
public string? MimeType { get; set; }
/// <summary>
/// Media asset SHA-256 hash.
/// </summary>
[JsonProperty("sha256")]
public string? Sha256 { get; set; }
/// <summary>
/// Media asset ID.
/// </summary>
/// <remarks>
/// You can <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media">perform a GET on this ID</see> to get the asset URL,
/// then perform a GET on the returned URL (using your access token) to get the underlying asset.
/// </remarks>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Media URL.
/// </summary>
/// <remarks>
/// You can query this URL directly with your access token to <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media#download-media">download the media asset</see>.
/// </remarks>
[JsonProperty("url")]
public string? Url { get; set; }
/// <summary>
/// indicating if audio is a recording made with the WhatsApp client voice recording feature.
/// </summary>
[JsonProperty("voice")]
public bool? IsVoiceRecord { get; set; }
}
}

View File

@@ -1,51 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received document file.
/// </summary>
public class DocumentContent
{
/// <summary>
/// Media asset caption text.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
/// <summary>
/// Media asset filename.
/// </summary>
[JsonProperty("filename")]
public string? Filename { get; set; }
/// <summary>
/// Media asset MIME type.
/// </summary>
[JsonProperty("mime_type")]
public string? MimeType { get; set; }
/// <summary>
/// Media asset SHA-256 hash.
/// </summary>
[JsonProperty("sha256")]
public string? Sha256 { get; set; }
/// <summary>
/// Media asset ID.
/// </summary>
/// <remarks>
/// You can <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media">perform a GET on this ID</see> to get the asset URL,
/// then perform a GET on the returned URL (using your access token) to get the underlying asset.
/// </remarks>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Media URL.
/// </summary>
/// <remarks>
/// You can query this URL directly with your access token to <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media#download-media">download the media asset</see>.
/// </remarks>
[JsonProperty("url")]
public string? Url { get; set; }
}
}

View File

@@ -1,45 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received image file.
/// </summary>
public class ImageContent
{
/// <summary>
/// Media asset caption text.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
/// <summary>
/// Media asset MIME type.
/// </summary>
[JsonProperty("mime_type")]
public string? MimeType { get; set; }
/// <summary>
/// Media asset SHA-256 hash.
/// </summary>
[JsonProperty("sha256")]
public string? Sha256 { get; set; }
/// <summary>
/// Media asset ID.
/// </summary>
/// <remarks>
/// You can <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media">perform a GET on this ID</see> to get the asset URL,
/// then perform a GET on the returned URL (using your access token) to get the underlying asset.
/// </remarks>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Media URL.
/// </summary>
/// <remarks>
/// You can query this URL directly with your access token to <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media#download-media">download the media asset</see>.
/// </remarks>
[JsonProperty("url")]
public string? Url { get; set; }
}
}

View File

@@ -1,14 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received text.
/// </summary>
public class TextContent
{
/// <summary>
/// The text content of the message.
/// </summary>
[JsonProperty("body")]
public string? Body { get; set; }
}
}

View File

@@ -1,45 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received video file.
/// </summary>
public class VideoContent
{
/// <summary>
/// Media asset caption text.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
/// <summary>
/// Media asset MIME type.
/// </summary>
[JsonProperty("mime_type")]
public string? MimeType { get; set; }
/// <summary>
/// Media asset SHA-256 hash.
/// </summary>
[JsonProperty("sha256")]
public string? Sha256 { get; set; }
/// <summary>
/// Media asset ID.
/// </summary>
/// <remarks>
/// You can <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media">perform a GET on this ID</see> to get the asset URL,
/// then perform a GET on the returned URL (using your access token) to get the underlying asset.
/// </remarks>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Media URL.
/// </summary>
/// <remarks>
/// You can query this URL directly with your access token to <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/business-phone-numbers/media#download-media">download the media asset</see>.
/// </remarks>
[JsonProperty("url")]
public string? Url { get; set; }
}
}

View File

@@ -1,66 +0,0 @@
using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// The conversation information.
/// </summary>
/// <remarks>
/// <list type="number">
/// <item>Only included with sent status, and one of either delivered or read status.</item>
/// <item>Omitted entirely for v24.0+ unless webhook is for a free entry point conversation.</item>
/// </list>
/// </remarks>
public class Conversation
{
/// <summary>
/// Unique identifier for the conversation.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Version 24.0 and higher:</strong>
/// <br/>
/// The <see cref="Conversation"/> object will be omitted entirely,
/// unless the webhook is for a message sent within an open free entry point window,
/// in which case the value will be unique per window.
/// </para>
/// <para>
/// <strong>Version 23.0 and lower:</strong>
/// <br/>
/// Value will now be set to a unique ID per-message,
/// unless the webhook is for a message sent with an open free entry point window,
/// in which case the value will be unique per window.
/// </para>
/// </remarks>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Timestamp indicating when the conversation will expire.
/// </summary>
/// <remarks>
/// The expiration_timestamp property is only included for <see cref="DeliveryStatus.Sent"/> status.
/// </remarks>
[JsonProperty("expiration_timestamp")]
[JsonConverter(typeof(UnixTimestampJsonConverter))]
public DateTime? ExpirationTimestamp { get; set; }
/// <summary>
/// The conversation origin.
/// </summary>
[JsonProperty("origin")]
public ConversationOrigin? Origin { get; set; }
}
/// <summary>
/// The conversation origin.
/// </summary>
public class ConversationOrigin
{
/// <summary>
/// The conversation category.
/// </summary>
[JsonProperty("type")]
public BillingCategory? Type { get; set; }
}
}

View File

@@ -1,54 +0,0 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Defines the available pricing category (<see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing#rates">rates</see>).
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum BillingCategory
{
/// <summary>
/// Indicates an authentication conversation.
/// </summary>
[EnumMember(Value = "authentication")]
Authentication = 1,
/// <summary>
/// Indicates an <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing/authentication-international-rates">authentication-international</see> conversation.
/// </summary>
[EnumMember(Value = "authentication_international")]
AuthenticationInternational = 2,
/// <summary>
/// Indicates a <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/marketing-messages/overview">marketing</see> conversation.
/// </summary>
[EnumMember(Value = "marketing")]
Marketing = 3,
/// <summary>
/// Indicates a <see href="https://developers.facebook.com/docs/whatsapp/marketing-messages-lite-api">Marketing Messages Lite API</see> conversation.
/// </summary>
[EnumMember(Value = "marketing_lite")]
MarketingLite = 4,
/// <summary>
/// Indicates a free entry point conversation.
/// </summary>
[EnumMember(Value = "referral_conversion")]
ReferralConversion = 5,
/// <summary>
/// Indicates a service conversation.
/// </summary>
[EnumMember(Value = "service")]
Service = 6,
/// <summary>
/// Indicates a utility conversation.
/// </summary>
[EnumMember(Value = "utility")]
Utility = 7,
}
}

View File

@@ -1,33 +0,0 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Defines the billing/pricing type.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum BillingType
{
/// <summary>
/// Indicates the message is billable.
/// </summary>
[EnumMember(Value = "regular")]
Regular = 1,
/// <summary>
/// Indicates the message is free because it was
/// either a utility template message
/// or non-template message sent within a customer service window.
/// </summary>
[EnumMember(Value = "free_customer_service")]
FreeCustomerService = 2,
/// <summary>
/// Indicates the message is free because
/// it was sent within an open free entry point window.
/// </summary>
[EnumMember(Value = "free_entry_point")]
FreeEntryPoint = 3,
}
}

View File

@@ -1,52 +0,0 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// WhatsApp message status.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum DeliveryStatus
{
/// <summary>
/// Indicates the message was successfully sent from our servers.
/// <br/>
/// WhatsApp UI equivalent: One checkmark.
/// </summary>
[EnumMember(Value = "sent")]
Sent = 1,
/// <summary>
/// Indicates message was successfully delivered to the WhatsApp user's device.
/// <br/>
/// WhatsApp UI equivalent: Two checkmarks.
/// </summary>
[EnumMember(Value = "delivered")]
Delivered = 2,
/// <summary>
/// Indicates failure to send or deliver the message to the WhatsApp user's device.
/// <br/>
/// WhatsApp UI equivalent: Red error triangle.
/// </summary>
[EnumMember(Value = "failed")]
Failed = 3,
/// <summary>
/// Indicates the message was displayed in an open chat thread in the WhatsApp user's device.
/// <br/>
/// WhatsApp UI equivalent: Two blue checkmarks.
/// </summary>
[EnumMember(Value = "read")]
Read = 4,
/// <summary>
/// Indicates the first time a voice message is played by the WhatsApp user's device.
/// <br/>
/// WhatsApp UI equivalent: Blue microphone.
/// </summary>
[EnumMember(Value = "played")]
Played = 5,
}
}

View File

@@ -1,74 +0,0 @@
using AMWD.Net.Api.LinkMobility.Utils;
using AMWD.Net.Api.LinkMobility.WhatsApp;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a received WhatsApp message.
/// </summary>
public class Message
{
/// <summary>
/// WhatsApp user phone number.
/// </summary>
/// <remarks>
/// This is the same value returned by the API as the input value when sending a message to a WhatsApp user.
/// Note that a WhatsApp user's phone number and ID may not always match.
/// </remarks>
[JsonProperty("from")]
public string? From { get; set; }
/// <summary>
/// WhatsApp message ID.
/// </summary>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Unix timestamp indicating when the webhook was triggered.
/// </summary>
[JsonProperty("timestamp")]
[JsonConverter(typeof(UnixTimestampJsonConverter))]
public DateTime? Timestamp { get; set; }
/// <summary>
/// The type of message received.
/// </summary>
[JsonProperty("type")]
public MessageType? Type { get; set; }
#region Content depending on the message type
/// <summary>
/// Audio file content.
/// </summary>
[JsonProperty("audio")]
public AudioContent? Audio { get; set; }
/// <summary>
/// Document file content.
/// </summary>
[JsonProperty("document")]
public DocumentContent? Document { get; set; }
/// <summary>
/// Image file content.
/// </summary>
[JsonProperty("image")]
public ImageContent? Image { get; set; }
/// <summary>
/// Content of a text message.
/// </summary>
[JsonProperty("text")]
public TextContent? Text { get; set; }
/// <summary>
/// Video file content.
/// </summary>
[JsonProperty("video")]
public VideoContent? Video { get; set; }
#endregion Content depending on the message type
}
}

View File

@@ -1,21 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a Meta WhatsApp Notification payload.
/// </summary>
public class Notification
{
/// <summary>
/// The object.
/// In this case, it is specified as <c>whatsapp_business_account</c>.
/// </summary>
[JsonProperty("object")]
public string? Object { get; set; }
/// <summary>
/// Entries of the notification object.
/// </summary>
[JsonProperty("entry")]
public IReadOnlyCollection<NotificationEntry>? Entries { get; set; }
}
}

View File

@@ -1,20 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// A change of a WhatsApp notification entry.
/// </summary>
public class NotificationChange
{
/// <summary>
/// The field category.
/// </summary>
[JsonProperty("field")]
public string? Field { get; set; }
/// <summary>
/// The change value.
/// </summary>
[JsonProperty("value")]
public NotificationValue? Value { get; set; }
}
}

View File

@@ -1,20 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// A WhatsApp notification entry.
/// </summary>
public class NotificationEntry
{
/// <summary>
/// The WhatsApp business account ID.
/// </summary>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Changes of that entry.
/// </summary>
[JsonProperty("changes")]
public IReadOnlyCollection<NotificationChange>? Changes { get; set; }
}
}

View File

@@ -1,20 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents metadata for a notification.
/// </summary>
public class NotificationMetadata
{
/// <summary>
/// Business display phone number.
/// </summary>
[JsonProperty("display_phone_number")]
public string? DisplayPhoneNumber { get; set; }
/// <summary>
/// Business phone number ID.
/// </summary>
[JsonProperty("phone_number_id")]
public string? PhoneNumberId { get; set; }
}
}

View File

@@ -1,45 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// A value of the notification change.
/// </summary>
public class NotificationValue
{
/// <summary>
/// The type of messaging product that triggered the webhook.
/// Will be <c>whatsapp</c>.
/// </summary>
[JsonProperty("messaging_product")]
public string? MessagingProduct { get; set; }
/// <summary>
/// Metadata about the notification change.
/// </summary>
[JsonProperty("metadata")]
public NotificationMetadata? Metadata { get; set; }
/// <summary>
/// Contacts of the WhatsApp users involved in the notification change.
/// </summary>
[JsonProperty("contacts")]
public IReadOnlyCollection<Contact>? Contacts { get; set; }
/// <summary>
/// The messages involved in the notification change.
/// </summary>
/// <remarks>
/// LINK Mobility API docs: <see href="https://developer.linkmobility.eu/whatsapp-api/rest-api#/paths/receiveIncomingWhatsappmessaging/post"/>
/// </remarks>
[JsonProperty("messages")]
public IReadOnlyCollection<Message>? Messages { get; set; }
/// <summary>
/// Status changes of the messages involved in the notification change.
/// </summary>
/// <remarks>
/// LINK Mobility API docs: <see href="https://developer.linkmobility.eu/whatsapp-api/rest-api#/paths/receiveStatusWhatsappmessaging/post"/>
/// </remarks>
[JsonProperty("statuses")]
public IReadOnlyCollection<Status>? Statuses { get; set; }
}
}

View File

@@ -1,50 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// The pricing information for a WhatsApp message.
/// </summary>
public class Pricing
{
/// <summary>
/// Indicates if the message is billable (<see langword="true"/>) or not (<see langword="false"/>).
/// </summary>
/// <remarks>
/// Note that the <see cref="Billable"/> property will be deprecated in a future versioned release,
/// so we recommend that you start using <see cref="Type"/> and <see cref="Category"/> together to determine if a message is billable,
/// and if so, its billing rate.
/// </remarks>
[JsonProperty("billable")]
public bool? Billable { get; set; }
/// <summary>
/// Pricing model.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>
/// <c>CBP</c>:
/// Indicates conversation-based pricing applies.
/// Will only be set to this value if the webhook was sent before <em>2025-07-01</em>.
/// </item>
/// <item>
/// <c>PMP</c>:
/// Indicates <see href="https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing">per-message pricing</see> applies.
/// </item>
/// </list>
/// </remarks>
[JsonProperty("pricing_model")]
public string? PricingModel { get; set; }
/// <summary>
/// Pricing type.
/// </summary>
[JsonProperty("type")]
public BillingType? Type { get; set; }
/// <summary>
/// Pricing category (rate) applied if billable.
/// </summary>
[JsonProperty("category")]
public BillingCategory? Category { get; set; }
}
}

View File

@@ -1,26 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a WhatsApp user profile.
/// </summary>
public class Profile
{
/// <summary>
/// WhatsApp user's name as it appears in their profile in the WhatsApp client.
/// </summary>
[JsonProperty("name")]
public string? Name { get; set; }
/// <summary>
/// The username.
/// </summary>
[JsonProperty("username")]
public string? Username { get; set; }
/// <summary>
/// The country code.
/// </summary>
[JsonProperty("country_code")]
public string? CountryCode { get; set; }
}
}

View File

@@ -1,66 +0,0 @@
using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a status change of a WhatsApp message.
/// </summary>
public class Status
{
/// <summary>
/// WhatsApp message ID.
/// </summary>
[JsonProperty("id")]
public string? Id { get; set; }
/// <summary>
/// Message status.
/// </summary>
[JsonProperty("status")]
public DeliveryStatus? DeliveryStatus { get; set; }
/// <summary>
/// Timestamp indicating when the webhook was triggered.
/// </summary>
[JsonProperty("timestamp")]
[JsonConverter(typeof(UnixTimestampJsonConverter))]
public DateTime? Timestamp { get; set; }
/// <summary>
/// WhatsApp user phone number or group ID.
/// </summary>
/// <remarks>
/// Value set to the WhatsApp user's phone number if the message was sent to their phone number, or set to a group ID if sent to a group ID.
/// If sent to a group ID, the WhatsApp user's phone number is instead assigned to the <see cref="RecipientParticipantId"/> property.
/// </remarks>
[JsonProperty("recipient_id")]
public string? RecipientId { get; set; }
/// <summary>
/// WhatsApp user phone number. Property only included if message was sent to a group.
/// </summary>
[JsonProperty("recipient_participant_id")]
public string? RecipientParticipantId { get; set; }
/// <summary>
/// The conversation information.
/// </summary>
/// <remarks>
/// <list type="number">
/// <item>Only included with sent status, and one of either delivered or read status.</item>
/// <item>Omitted entirely for v24.0+ unless webhook is for a free entry point conversation.</item>
/// </list>
/// </remarks>
[JsonProperty("conversation")]
public Conversation? Conversation { get; set; }
/// <summary>
/// The pricing information.
/// </summary>
/// <remarks>
/// Only included with <see cref="DeliveryStatus.Sent"/> status, and one of either <see cref="DeliveryStatus.Delivered"/> or <see cref="DeliveryStatus.Read"/> status.
/// </remarks>
[JsonProperty("pricing")]
public Pricing? Pricing { get; set; }
}
}

View File

@@ -1,45 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.Webhook.WhatsApp
{
/// <summary>
/// Represents a notification for an incoming WhatsApp message or delivery report.
/// (<see href="https://developer.linkmobility.eu/whatsapp-api/receive-whatsapp-messages">API</see>)
/// </summary>
public class WhatsAppNotification
{
/// <summary>
/// A unique identifier for the customer channel. It is typically in UUID format.
/// </summary>
[JsonProperty("customerChannelUuid")]
public Guid CustomerChannelUuid { get; set; }
/// <summary>
/// The sender's information in E164 formatted MSISDN (see Wikipedia <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>).
/// In this case is the customer phone number.
/// </summary>
[JsonProperty("sender")]
public string? Sender { get; set; }
/// <summary>
/// The recipient's information in E164 formatted MSISDN (see Wikipedia <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>).
/// In this case is the Customer Channel identifier.
/// </summary>
[JsonProperty("recipient")]
public string? Recipient { get; set; }
/// <summary>
/// The type of the communication channel.
/// In this case, it is specified as <c>whatsapp</c>.
/// </summary>
[JsonProperty("type")]
public string? Type { get; set; }
/// <summary>
/// Meta WhatsApp Notification payload.
/// </summary>
/// <remarks>
/// See specification on <see href="https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/components">Meta documentation</see>.
/// </remarks>
[JsonProperty("whatsappNotification")]
public Notification? Body { get; set; }
}
}

View File

@@ -1,52 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// A WhatsApp audio message content.
/// </summary>
public class AudioMessageContent : IMessageContent
{
/// <summary>
/// Initializes a new instance of the <see cref="AudioMessageContent"/> class with the provided audio link.
/// </summary>
/// <param name="mediaLink">The link to an audio file (http/https only).</param>
public AudioMessageContent(string mediaLink)
{
Body = new Content { Link = mediaLink };
}
/// <inheritdoc/>
[JsonProperty("type")]
public MessageType Type => MessageType.Audio;
/// <summary>
/// The content container.
/// </summary>
[JsonProperty("audio")]
public Content Body { get; set; }
/// <inheritdoc/>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Body?.Link)
&& (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
}
/// <summary>
/// Container for the audio message content.
/// </summary>
public class Content
{
/// <summary>
/// The media link.
/// </summary>
[JsonProperty("link")]
public string? Link { get; set; }
/// <summary>
/// A caption for the audio.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
}
}
}

View File

@@ -1,58 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// A WhatsApp document message content.
/// </summary>
public class DocumentMessageContent : IMessageContent
{
/// <summary>
/// Initializes a new instance of the <see cref="DocumentMessageContent"/> class with the provided document link.
/// </summary>
/// <param name="mediaLink">The link to a document (http/https only).</param>
public DocumentMessageContent(string mediaLink)
{
Body = new Content { Link = mediaLink };
}
/// <inheritdoc/>
[JsonProperty("type")]
public MessageType Type => MessageType.Document;
/// <summary>
/// The content container.
/// </summary>
[JsonProperty("document")]
public Content Body { get; set; }
/// <inheritdoc/>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Body?.Link)
&& (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
}
/// <summary>
/// Container for the document message content.
/// </summary>
public class Content
{
/// <summary>
/// The media link.
/// </summary>
[JsonProperty("link")]
public string? Link { get; set; }
/// <summary>
/// A caption for the document.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
/// <summary>
/// A filename for the document (e.g. "file.pdf").
/// </summary>
[JsonProperty("filename")]
public string? Filename { get; set; }
}
}
}

View File

@@ -1,52 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// A WhatsApp image message content.
/// </summary>
public class ImageMessageContent : IMessageContent
{
/// <summary>
/// Initializes a new instance of the <see cref="ImageMessageContent"/> class with the provided message text.
/// </summary>
/// <param name="mediaLink">The link to an image (http/https only).</param>
public ImageMessageContent(string mediaLink)
{
Body = new Content { Link = mediaLink };
}
/// <inheritdoc/>
[JsonProperty("type")]
public MessageType Type => MessageType.Image;
/// <summary>
/// The content container.
/// </summary>
[JsonProperty("image")]
public Content Body { get; set; }
/// <inheritdoc/>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Body?.Link)
&& (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
}
/// <summary>
/// Container for the text message content.
/// </summary>
public class Content
{
/// <summary>
/// The message text.
/// </summary>
[JsonProperty("link")]
public string? Link { get; set; }
/// <summary>
/// A caption for the image.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
}
}
}

View File

@@ -1,51 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// A WhatsApp text message content.
/// </summary>
public class TextMessageContent : IMessageContent
{
/// <summary>
/// Initializes a new instance of the <see cref="TextMessageContent"/> class with the provided message text.
/// </summary>
/// <param name="message">The message text.</param>
public TextMessageContent(string message)
{
Body = new Content { Text = message };
}
/// <inheritdoc/>
[JsonProperty("type")]
public MessageType Type => MessageType.Text;
/// <summary>
/// The content container.
/// </summary>
[JsonProperty("text")]
public Content Body { get; set; }
/// <inheritdoc/>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Body?.Text);
}
/// <summary>
/// Container for the text message content.
/// </summary>
public class Content
{
/// <summary>
/// The message text.
/// </summary>
[JsonProperty("body")]
public string? Text { get; set; }
/// <summary>
/// Indicates whether urls should try to be previewed.
/// </summary>
[JsonProperty("preview_url")]
public bool PreviewUrl { get; set; } = false;
}
}
}

View File

@@ -1,52 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// A WhatsApp video message content.
/// </summary>
public class VideoMessageContent : IMessageContent
{
/// <summary>
/// Initializes a new instance of the <see cref="VideoMessageContent"/> class with the provided video link.
/// </summary>
/// <param name="mediaLink">The link to a video (http/https only).</param>
public VideoMessageContent(string mediaLink)
{
Body = new Content { Link = mediaLink };
}
/// <inheritdoc/>
[JsonProperty("type")]
public MessageType Type => MessageType.Video;
/// <summary>
/// The content container.
/// </summary>
[JsonProperty("video")]
public Content Body { get; set; }
/// <inheritdoc/>
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(Body?.Link)
&& (Body!.Link!.StartsWith("http://") || Body!.Link!.StartsWith("https://"));
}
/// <summary>
/// Container for the text message content.
/// </summary>
public class Content
{
/// <summary>
/// The message text.
/// </summary>
[JsonProperty("link")]
public string? Link { get; set; }
/// <summary>
/// A caption for the image.
/// </summary>
[JsonProperty("caption")]
public string? Caption { get; set; }
}
}
}

View File

@@ -1,19 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// The message content of a WhatsApp message.
/// </summary>
public interface IMessageContent
{
/// <summary>
/// The type of the message content.
/// </summary>
[JsonProperty("type")]
MessageType Type { get; }
/// <summary>
/// Determines whether the content message is valid.
/// </summary>
bool IsValid();
}
}

View File

@@ -1,42 +0,0 @@
using System.Runtime.Serialization;
using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// Represents the list of supported message types for WhatsApp messages.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum MessageType
{
/// <summary>
/// Send a simple text message.
/// </summary>
[EnumMember(Value = "text")]
Text = 1,
/// <summary>
/// Sends a media message, which contains the link to an image.
/// </summary>
[EnumMember(Value = "image")]
Image = 2,
/// <summary>
/// Sends a media message, which contains the link to a video.
/// </summary>
[EnumMember(Value = "video")]
Video = 3,
/// <summary>
/// Sends a media message, which contains the link to an audio file.
/// </summary>
[EnumMember(Value = "audio")]
Audio = 4,
/// <summary>
/// Sends a media message, which contains the link to a document (e.g. PDF).
/// </summary>
[EnumMember(Value = "document")]
Document = 5,
}
}

View File

@@ -1,83 +0,0 @@
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// Request to send a WhatsApp message to a list of recipients.
/// </summary>
public class SendWhatsAppMessageRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="SendWhatsAppMessageRequest"/> class.
/// </summary>
/// <param name="messageContent">The content of a WhatsApp message.</param>
/// <param name="recipientAddressList">A list of recipient numbers.</param>
public SendWhatsAppMessageRequest(IMessageContent messageContent, IReadOnlyCollection<string> recipientAddressList)
{
MessageContent = messageContent;
RecipientAddressList = recipientAddressList;
}
/// <summary>
/// <em>Optional</em>.
/// May contain a freely definable message id.
/// </summary>
[JsonProperty("clientMessageId")]
public string? ClientMessageId { get; set; }
/// <summary>
/// <em>Optional</em>.
/// The content category that is used to categorize the message (used for blacklisting).
/// </summary>
/// <remarks>
/// The following content categories are supported: <see cref="ContentCategory.Informational"/>, <see cref="ContentCategory.Advertisement"/> or <see cref="ContentCategory.Personal"/>.
/// If no content category is provided, the default setting is used (may be changed inside the web interface).
/// </remarks>
[JsonProperty("contentCategory")]
public ContentCategory? ContentCategory { get; set; }
/// <summary>
/// <em>UTF-8</em> encoded message content.
/// </summary>
[JsonProperty("messageContent")]
public IMessageContent MessageContent { get; set; }
/// <summary>
/// <em>Optional</em>.
/// Priority of the message.
/// </summary>
/// <remarks>
/// Must not exceed the value configured for the channel used to send the message.
/// </remarks>
[JsonProperty("priority")]
public int? Priority { get; set; }
/// <summary>
/// List of recipients (E.164 formatted <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>s)
/// to whom the message should be sent.
/// <br/>
/// The list of recipients may contain a maximum of <em>1000</em> entries.
/// </summary>
[JsonProperty("recipientAddressList")]
public IReadOnlyCollection<string> RecipientAddressList { get; set; }
/// <summary>
/// <em>Optional</em>.
/// <br/>
/// <see langword="true"/>: The transmission is only simulated, no whatsapp message is sent.
/// Depending on the number of recipients the status code <see cref="StatusCodes.Ok"/> or <see cref="StatusCodes.OkQueued"/> is returned.
/// <br/>
/// <see langword="false"/>: No simulation is done. The whatsapp message is sent. (default)
/// </summary>
[JsonProperty("test")]
public bool? Test { get; set; }
/// <summary>
/// <em>Optional</em>.
/// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient.
/// </summary>
/// <remarks>
/// A minimum of 1 minute (<c>60</c> seconds) and a maximum of 3 days (<c>259200</c> seconds) are allowed.
/// </remarks>
[JsonProperty("validityPeriode")]
public int? ValidityPeriode { get; set; }
}
}

View File

@@ -1,42 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility.WhatsApp
{
/// <summary>
/// Implementation of WhatsApp messaging. <see href="https://developer.linkmobility.eu/whatsapp-api/rest-api">API</see>
/// </summary>
public static class WhatsAppExtensions
{
/// <summary>
/// Sends a WhatsApp message to a list of recipients.
/// </summary>
/// <param name="client">The <see cref="ILinkMobilityClient"/> instance.</param>
/// <param name="uuid">The unique identifier of the WhatsApp channel.</param>
/// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
public static Task<SendMessageResponse> SendWhatsAppMessage(this LinkMobilityClient client, Guid uuid, SendWhatsAppMessageRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.MessageContent?.IsValid() != true)
throw new ArgumentException("A valid message must be provided.", nameof(request.MessageContent));
if (request.ContentCategory.HasValue && request.ContentCategory.Value != ContentCategory.Informational && request.ContentCategory.Value != ContentCategory.Advertisement && request.ContentCategory.Value != ContentCategory.Personal)
throw new ArgumentException($"Content category '{request.ContentCategory.Value}' is not valid.", nameof(request.ContentCategory));
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 (!Validation.IsValidMSISDN(recipient))
throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
}
return client.PostAsync<SendMessageResponse, SendWhatsAppMessageRequest>($"/channels/{uuid}/send/whatsapp", request, cancellationToken: cancellationToken);
}
}
}

View File

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

View File

@@ -145,7 +145,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await client.PostAsync<TestClass, TestClass>("test", _request, TestContext.CancellationToken); var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "test", _request, null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -166,12 +166,57 @@ namespace LinkMobility.Tests
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()); _httpMessageHandlerMock.Mock
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Exactly(2)); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Exactly(2));
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
[TestMethod]
public async Task ShouldAddCustomQueryParameters()
{
// Arrange
var queryParams = new TestParams();
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{ ""string"": ""some-string"", ""integer"": 123 }", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "params/path", _request, queryParams, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual("https://localhost/rest/params/path?test=query+text", callback.Url);
Assert.AreEqual(@"{""string"":""Happy Testing"",""integer"":54321}", callback.Content);
Assert.HasCount(3, callback.Headers);
Assert.IsTrue(callback.Headers.ContainsKey("Accept"));
Assert.IsTrue(callback.Headers.ContainsKey("Authorization"));
Assert.IsTrue(callback.Headers.ContainsKey("User-Agent"));
Assert.AreEqual("application/json", callback.Headers["Accept"]);
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls();
}
[TestMethod] [TestMethod]
public void ShouldDisposeHttpClient() public void ShouldDisposeHttpClient()
{ {
@@ -182,7 +227,9 @@ namespace LinkMobility.Tests
client.Dispose(); client.Dispose();
// Assert // Assert
_httpMessageHandlerMock.Protected.Verify("Dispose", Times.Once(), exactParameterMatch: true, true); _httpMessageHandlerMock.Mock
.Protected()
.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
@@ -198,7 +245,9 @@ namespace LinkMobility.Tests
client.Dispose(); client.Dispose();
// Assert // Assert
_httpMessageHandlerMock.Protected.Verify("Dispose", Times.Once(), exactParameterMatch: true, true); _httpMessageHandlerMock.Mock
.Protected()
.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
@@ -207,7 +256,7 @@ namespace LinkMobility.Tests
public void ShouldAssertClientOptions() public void ShouldAssertClientOptions()
{ {
// Arrange + Act // Arrange + Act
_ = GetClient(); var client = GetClient();
// Assert // Assert
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -271,7 +320,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ObjectDisposedException>(async () => await Assert.ThrowsExactlyAsync<ObjectDisposedException>(async () =>
{ {
await client.PostAsync<object, TestClass>("/request/path", _request, TestContext.CancellationToken); await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "/request/path", _request, null, TestContext.CancellationToken);
}); });
} }
@@ -287,7 +336,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () =>
{ {
await client.PostAsync<object, TestClass>(path, _request, TestContext.CancellationToken); await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", path, _request, null, TestContext.CancellationToken);
}); });
} }
@@ -300,7 +349,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ArgumentException>(async () => await Assert.ThrowsExactlyAsync<ArgumentException>(async () =>
{ {
await client.PostAsync<object, TestClass>("foo?bar=baz", _request, TestContext.CancellationToken); await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo?bar=baz", _request, null, TestContext.CancellationToken);
}); });
} }
@@ -317,7 +366,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await client.PostAsync<TestClass, TestClass>("/request/path", _request, TestContext.CancellationToken); var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "/request/path", _request, null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -340,7 +389,9 @@ namespace LinkMobility.Tests
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()); _httpMessageHandlerMock.Mock
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -360,7 +411,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await client.PostAsync<TestClass, HttpContent>("/request/path", stringContent, TestContext.CancellationToken); var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "/request/path", stringContent, null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -383,7 +434,9 @@ namespace LinkMobility.Tests
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()); _httpMessageHandlerMock.Mock
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -402,7 +455,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await client.PostAsync<TestClass, object>("posting", null, TestContext.CancellationToken); var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "posting", null, null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -426,7 +479,9 @@ namespace LinkMobility.Tests
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()); _httpMessageHandlerMock.Mock
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
} }
[TestMethod] [TestMethod]
@@ -446,7 +501,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
var ex = await Assert.ThrowsExactlyAsync<AuthenticationException>(async () => var ex = await Assert.ThrowsExactlyAsync<AuthenticationException>(async () =>
{ {
await client.PostAsync<object, TestClass>("foo", _request, TestContext.CancellationToken); await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo", _request, null, TestContext.CancellationToken);
}); });
Assert.IsNull(ex.InnerException); Assert.IsNull(ex.InnerException);
Assert.AreEqual($"HTTP auth missing: {statusCode}", ex.Message); Assert.AreEqual($"HTTP auth missing: {statusCode}", ex.Message);
@@ -469,7 +524,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
var ex = await Assert.ThrowsExactlyAsync<ApplicationException>(async () => var ex = await Assert.ThrowsExactlyAsync<ApplicationException>(async () =>
{ {
await client.PostAsync<object, TestClass>("foo", _request, TestContext.CancellationToken); await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo", _request, null, TestContext.CancellationToken);
}); });
Assert.IsNull(ex.InnerException); Assert.IsNull(ex.InnerException);
Assert.AreEqual($"Unknown HTTP response: {statusCode}", ex.Message); Assert.AreEqual($"Unknown HTTP response: {statusCode}", ex.Message);
@@ -490,7 +545,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<JsonReaderException>(async () => await Assert.ThrowsExactlyAsync<JsonReaderException>(async () =>
{ {
await client.PostAsync<TestClass, TestClass>("some-path", _request, TestContext.CancellationToken); await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "some-path", _request, null, TestContext.CancellationToken);
}); });
} }
@@ -508,7 +563,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
string response = await client.PostAsync<string, TestClass>("path", _request, TestContext.CancellationToken); string response = await ReflectionHelper.InvokePrivateMethodAsync<string>(client, "PostAsync", "path", _request, null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -531,7 +586,9 @@ namespace LinkMobility.Tests
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()); _httpMessageHandlerMock.Mock
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -581,5 +638,16 @@ namespace LinkMobility.Tests
[JsonProperty("integer")] [JsonProperty("integer")]
public int Int { get; set; } public int Int { get; set; }
} }
private class TestParams : IQueryParameter
{
public IReadOnlyDictionary<string, string> GetQueryParameters()
{
return new Dictionary<string, string>
{
{ "test", "query text" }
};
}
}
} }
} }

View File

@@ -1,10 +1,9 @@
using AMWD.Net.Api.LinkMobility.Text; using AMWD.Net.Api.LinkMobility;
using AMWD.Net.Api.LinkMobility.Webhook.Text;
namespace LinkMobility.Tests.Webhook.Text namespace LinkMobility.Tests.Models
{ {
[TestClass] [TestClass]
public class TextNotificationTest public class IncomingMessageNotificationTest
{ {
[TestMethod] [TestMethod]
public void ShouldParseAllPropertiesForTextNotification() public void ShouldParseAllPropertiesForTextNotification()
@@ -30,13 +29,13 @@ namespace LinkMobility.Tests.Webhook.Text
}"; }";
// Act // Act
bool successful = TextNotification.TryParse(json, out var notification); bool successful = IncomingMessageNotification.TryParse(json, out var notification);
// Assert // Assert
Assert.IsTrue(successful, "TryParse should return true for valid json"); Assert.IsTrue(successful, "TryParse should return true for valid json");
Assert.IsNotNull(notification); Assert.IsNotNull(notification);
Assert.AreEqual(TextMessageType.Text, notification.MessageType); Assert.AreEqual(IncomingMessageNotification.Type.Text, notification.MessageType);
Assert.AreEqual("notif-123", notification.NotificationId); Assert.AreEqual("notif-123", notification.NotificationId);
Assert.AreEqual("trans-456", notification.TransferId); Assert.AreEqual("trans-456", notification.TransferId);
@@ -81,7 +80,7 @@ namespace LinkMobility.Tests.Webhook.Text
string invalid = "this is not json"; string invalid = "this is not json";
// Act // Act
bool successful = TextNotification.TryParse(invalid, out var notification); bool successful = IncomingMessageNotification.TryParse(invalid, out var notification);
// Assert // Assert
Assert.IsFalse(successful); Assert.IsFalse(successful);

View File

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

View File

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

View File

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

View File

@@ -1,155 +0,0 @@
using System.Linq;
using AMWD.Net.Api.LinkMobility.Webhook.WhatsApp;
using AMWD.Net.Api.LinkMobility.WhatsApp;
namespace LinkMobility.Tests.Webhook.WhatsApp
{
[TestClass]
public class WhatsAppNotificationTest
{
[TestMethod]
public void ShouldDeserializeWhatsAppNotificationWithMessageAndStatus()
{
// Arrange
string json = @"{
""customerChannelUuid"": ""11111111-2222-3333-4444-555555555555"",
""sender"": ""46701234567"",
""recipient"": ""123e4567-e89b-12d3-a456-426614174000"",
""type"": ""whatsapp"",
""whatsappNotification"": {
""object"": ""whatsapp_business_account"",
""entry"": [
{
""id"": ""123456789"",
""changes"": [
{
""field"": ""messages"",
""value"": {
""messaging_product"": ""whatsapp"",
""metadata"": {
""display_phone_number"": ""+46701234567"",
""phone_number_id"": ""111222333""
},
""contacts"": [
{
""profile"": {
""name"": ""John Doe""
},
""wa_id"": ""46701234567""
}
],
""messages"": [
{
""from"": ""46701234567"",
""id"": ""wamid.123"",
""timestamp"": 1672531200,
""type"": ""text"",
""text"": {
""body"": ""Hello world""
}
}
],
""statuses"": [
{
""id"": ""wamid.123"",
""status"": ""delivered"",
""timestamp"": 1672531200,
""recipient_id"": ""16505551234"",
""recipient_participant_id"": ""16505550000"",
""conversation"": {
""id"": ""conv-1"",
""expiration_timestamp"": 1672617600,
""origin"": {
""type"": ""service""
}
},
""pricing"": {
""billable"": true,
""pricing_model"": ""PMP"",
""type"": ""regular"",
""category"": ""service""
}
}
]
}
}
]
}
]
}
}";
// Act
var notification = JsonConvert.DeserializeObject<WhatsAppNotification>(json);
// Assert
Assert.IsNotNull(notification);
Assert.AreEqual(Guid.Parse("11111111-2222-3333-4444-555555555555"), notification.CustomerChannelUuid);
Assert.AreEqual("46701234567", notification.Sender);
Assert.AreEqual("123e4567-e89b-12d3-a456-426614174000", notification.Recipient);
Assert.AreEqual("whatsapp", notification.Type);
Assert.IsNotNull(notification.Body);
Assert.AreEqual("whatsapp_business_account", notification.Body.Object);
Assert.IsNotNull(notification.Body.Entries);
Assert.HasCount(1, notification.Body.Entries);
var entry = notification.Body.Entries.First();
Assert.AreEqual("123456789", entry.Id);
Assert.IsNotNull(entry.Changes);
Assert.HasCount(1, entry.Changes);
var change = entry.Changes.First();
Assert.AreEqual("messages", change.Field);
Assert.IsNotNull(change.Value);
Assert.AreEqual("whatsapp", change.Value.MessagingProduct);
Assert.IsNotNull(change.Value.Metadata);
Assert.AreEqual("+46701234567", change.Value.Metadata.DisplayPhoneNumber);
Assert.AreEqual("111222333", change.Value.Metadata.PhoneNumberId);
Assert.IsNotNull(change.Value.Contacts);
Assert.HasCount(1, change.Value.Contacts);
var contact = change.Value.Contacts.First();
Assert.IsNotNull(contact.Profile);
Assert.AreEqual("John Doe", contact.Profile.Name);
Assert.AreEqual("46701234567", contact.WhatsAppId);
Assert.IsNotNull(change.Value.Messages);
Assert.HasCount(1, change.Value.Messages);
var message = change.Value.Messages.First();
Assert.AreEqual("46701234567", message.From);
Assert.AreEqual("wamid.123", message.Id);
Assert.IsNotNull(message.Timestamp);
// 1672531200 -> 2023-01-01T00:00:00Z
var expected = new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc);
Assert.AreEqual(expected, message.Timestamp.Value.ToUniversalTime());
Assert.IsTrue(message.Type.HasValue);
Assert.AreEqual(MessageType.Text, message.Type.Value);
Assert.IsNotNull(message.Text);
Assert.IsNotNull(message.Text.Body);
Assert.AreEqual("Hello world", message.Text.Body);
Assert.IsNotNull(change.Value.Statuses);
Assert.HasCount(1, change.Value.Statuses);
var status = change.Value.Statuses.First();
Assert.AreEqual("wamid.123", status.Id);
Assert.IsTrue(status.DeliveryStatus.HasValue);
Assert.AreEqual(DeliveryStatus.Delivered, status.DeliveryStatus.Value);
}
[TestMethod]
public void DeserializeShouldThrowOnInvalidJson()
{
// Arrange
string invalid = "this is not json";
// Act & Assert
Assert.ThrowsExactly<JsonReaderException>(() => JsonConvert.DeserializeObject<WhatsAppNotification>(invalid));
}
}
}

View File

@@ -1,43 +0,0 @@
using AMWD.Net.Api.LinkMobility.WhatsApp;
namespace LinkMobility.Tests.WhatsApp.Contents
{
[TestClass]
public class AudioMessageContentTest
{
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow("Caption")]
public void ShouldValidateSuccessful(string caption)
{
// Arrange
var content = new AudioMessageContent("https://example.com/audio.mp3");
content.Body.Caption = caption;
// Act
bool isValid = content.IsValid();
// Assert
Assert.IsTrue(isValid);
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("ftp://example.com/audio.mp3")]
[DataRow("www.example.org/audio.mp3")]
public void ShouldValidateNotSuccessful(string url)
{
// Arrange
var content = new AudioMessageContent(url);
// Act
bool isValid = content.IsValid();
// Assert
Assert.IsFalse(isValid);
}
}
}

View File

@@ -1,43 +0,0 @@
using AMWD.Net.Api.LinkMobility.WhatsApp;
namespace LinkMobility.Tests.WhatsApp.Contents
{
[TestClass]
public class DocumentMessageContentTest
{
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow("Caption")]
public void ShouldValidateSuccessful(string caption)
{
// Arrange
var content = new DocumentMessageContent("https://example.com/doc.pdf");
content.Body.Caption = caption;
// Act
bool isValid = content.IsValid();
// Assert
Assert.IsTrue(isValid);
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("ftp://example.com/doc.pdf")]
[DataRow("www.example.org/doc.pdf")]
public void ShouldValidateNotSuccessful(string url)
{
// Arrange
var content = new DocumentMessageContent(url);
// Act
bool isValid = content.IsValid();
// Assert
Assert.IsFalse(isValid);
}
}
}

View File

@@ -1,43 +0,0 @@
using AMWD.Net.Api.LinkMobility.WhatsApp;
namespace LinkMobility.Tests.WhatsApp.Contents
{
[TestClass]
public class ImageMessageContentTest
{
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow("Caption")]
public void ShouldValidateSuccessful(string caption)
{
// Arrange
var content = new ImageMessageContent("https://example.com/image.jpg");
content.Body.Caption = caption;
// Act
bool isValid = content.IsValid();
// Assert
Assert.IsTrue(isValid);
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("ftp://example.com/image.jpg")]
[DataRow("www.example.org/image.jpg")]
public void ShouldValidateNotSuccessful(string url)
{
// Arrange
var content = new ImageMessageContent(url);
// Act
bool isValid = content.IsValid();
// Assert
Assert.IsFalse(isValid);
}
}
}

View File

@@ -1,40 +0,0 @@
using AMWD.Net.Api.LinkMobility.WhatsApp;
namespace LinkMobility.Tests.WhatsApp.Contents
{
[TestClass]
public class TextMessageContentTest
{
[TestMethod]
[DataRow(true)]
[DataRow(false)]
public void ShouldValidateSuccessful(bool previewUrl)
{
// Arrange
var content = new TextMessageContent("Hello, World!");
content.Body.PreviewUrl = previewUrl;
// Act
bool isValid = content.IsValid();
// Assert
Assert.IsTrue(isValid);
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
public void ShouldValidateNotSuccessful(string text)
{
// Arrange
var content = new TextMessageContent(text);
// Act
bool isValid = content.IsValid();
// Assert
Assert.IsFalse(isValid);
}
}
}

View File

@@ -1,43 +0,0 @@
using AMWD.Net.Api.LinkMobility.WhatsApp;
namespace LinkMobility.Tests.WhatsApp.Contents
{
[TestClass]
public class VideoMessageContentTest
{
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow("Caption")]
public void ShouldValidateSuccessful(string caption)
{
// Arrange
var content = new VideoMessageContent("https://example.com/video.mp4");
content.Body.Caption = caption;
// Act
bool isValid = content.IsValid();
// Assert
Assert.IsTrue(isValid);
}
[TestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("ftp://example.com/video.mp4")]
[DataRow("www.example.org/video.mp4")]
public void ShouldValidateNotSuccessful(string url)
{
// Arrange
var content = new VideoMessageContent(url);
// Act
bool isValid = content.IsValid();
// Assert
Assert.IsFalse(isValid);
}
}
}

View File

@@ -1,202 +0,0 @@
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.WhatsApp;
using LinkMobility.Tests.Helpers;
using Moq.Protected;
namespace LinkMobility.Tests.WhatsApp
{
[TestClass]
public class SendWhatsAppMessageTest
{
public TestContext TestContext { get; set; }
private const string BASE_URL = "https://localhost/rest/";
private Mock<IAuthentication> _authenticationMock;
private Mock<ClientOptions> _clientOptionsMock;
private HttpMessageHandlerMock _httpMessageHandlerMock;
private Guid _uuid;
private SendWhatsAppMessageRequest _request;
[TestInitialize]
public void Initialize()
{
_authenticationMock = new Mock<IAuthentication>();
_clientOptionsMock = new Mock<ClientOptions>();
_httpMessageHandlerMock = new HttpMessageHandlerMock();
_authenticationMock
.Setup(a => a.AddHeader(It.IsAny<HttpClient>()))
.Callback<HttpClient>(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Scheme", "Parameter"));
_clientOptionsMock.Setup(c => c.BaseUrl).Returns(BASE_URL);
_clientOptionsMock.Setup(c => c.Timeout).Returns(TimeSpan.FromSeconds(30));
_clientOptionsMock.Setup(c => c.DefaultHeaders).Returns(new Dictionary<string, string>());
_clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary<string, string>());
_clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true);
_clientOptionsMock.Setup(c => c.UseProxy).Returns(false);
_uuid = Guid.NewGuid();
var image = new ImageMessageContent("https://example.com/image.jpg");
image.Body.Caption = "Hello World :)";
_request = new SendWhatsAppMessageRequest(image, ["436991234567"]);
}
[TestMethod]
public async Task ShouldSendWhatsAppMessage()
{
// Arrange
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{ ""clientMessageId"": ""myUniqueId"", ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""0059d0b20100a0a8b803"" }", Encoding.UTF8, "application/json"),
});
var client = GetClient();
// Act
var response = await client.SendWhatsAppMessage(_uuid, _request, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
Assert.AreEqual("myUniqueId", response.ClientMessageId);
Assert.AreEqual(StatusCodes.Ok, response.StatusCode);
Assert.AreEqual("OK", response.StatusMessage);
Assert.AreEqual("0059d0b20100a0a8b803", response.TransferId);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual($"https://localhost/rest/channels/{_uuid}/send/whatsapp", callback.Url);
Assert.AreEqual(@"{""messageContent"":{""type"":""image"",""image"":{""link"":""https://example.com/image.jpg"",""caption"":""Hello World :)""}},""recipientAddressList"":[""436991234567""]}", callback.Content);
Assert.HasCount(3, callback.Headers);
Assert.IsTrue(callback.Headers.ContainsKey("Accept"));
Assert.IsTrue(callback.Headers.ContainsKey("Authorization"));
Assert.IsTrue(callback.Headers.ContainsKey("User-Agent"));
Assert.AreEqual("application/json", callback.Headers["Accept"]);
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnNullRequest()
{
// Arrange
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentNullException>(() => client.SendWhatsAppMessage(_uuid, null, TestContext.CancellationToken));
Assert.AreEqual("request", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnMissingMessage()
{
// Arrange
var req = new SendWhatsAppMessageRequest(null, ["436991234567"]);
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendWhatsAppMessage(_uuid, req, TestContext.CancellationToken));
Assert.AreEqual("MessageContent", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnNoRecipients()
{
// Arrange
var req = new SendWhatsAppMessageRequest(new TextMessageContent("Hello"), []);
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendWhatsAppMessage(_uuid, req, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnInvalidRecipient()
{
// Arrange
var client = GetClient();
var req = new SendWhatsAppMessageRequest(new TextMessageContent("Hello"), ["4791234567", "invalid-recipient"]);
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendWhatsAppMessage(_uuid, req, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName);
Assert.StartsWith($"Recipient address 'invalid-recipient' is not a valid MSISDN format.", ex.Message);
VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldThrowOnInvalidContentCategory()
{
// Arrange
_request.ContentCategory = 0;
var client = GetClient();
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendWhatsAppMessage(_uuid, _request, TestContext.CancellationToken));
Assert.AreEqual("ContentCategory", ex.ParamName);
Assert.StartsWith("Content category '0' is not valid.", ex.Message);
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"));
_authenticationMock.Object.AddHeader(httpClient);
_authenticationMock.Invocations.Clear();
_clientOptionsMock.Invocations.Clear();
ReflectionHelper.GetPrivateField<HttpClient>(client, "_httpClient")?.Dispose();
ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient);
return client;
}
}
}