1
0

10 Commits

Author SHA1 Message Date
101b8eef07 Bump to v0.2.0
All checks were successful
Branch Build / build-test-deploy (push) Successful in 42s
Release Build / build-test-deploy (push) Successful in 1m7s
2026-03-24 20:13:42 +01:00
6b5581c247 Added basic WhatsApp implementation
All checks were successful
Branch Build / build-test-deploy (push) Successful in 1m23s
2026-03-24 20:06:55 +01:00
314e5da9cc Updated docs 2026-03-24 18:33:10 +01:00
7392b0eb98 Added DocFX
All checks were successful
Branch Build / build-test-deploy (push) Successful in 1m22s
2026-03-24 18:01:30 +01:00
3f8cece95c Reorganized namespaces
All checks were successful
Branch Build / build-test-deploy (push) Successful in 1m19s
2026-03-18 22:44:26 +01:00
79567c730c Extensions instead of partial classes
All checks were successful
Branch Build / build-test-deploy (push) Successful in 44s
2026-03-16 20:13:25 +01:00
94706dd82d Remove missing doc generation
All checks were successful
Branch Build / build-test-deploy (push) Successful in 1m13s
2026-03-13 19:12:16 +01:00
15126003a9 Bump to v0.1.1
Some checks failed
Branch Build / build-test-deploy (push) Successful in 1m22s
Release Build / build-test-deploy (push) Failing after 1m21s
2026-03-13 19:03:48 +01:00
762e3df704 Add your own HTTP client 2026-03-13 19:03:08 +01:00
ec959f1500 Migrated to Gitea 2026-03-13 18:51:03 +01:00
80 changed files with 3756 additions and 1629 deletions

View File

@@ -0,0 +1,58 @@
name: Branch Build
on:
push:
branches:
- '**'
env:
TZ: 'Europe/Berlin'
LANG: 'de'
CONFIGURATION: 'Debug'
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_COMMIT_TAG: ${{ gitea.ref_name }}
jobs:
build-test-deploy:
runs-on: ubuntu
defaults:
run:
shell: bash
steps:
- name: Setup dotnet
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
cache: false
- name: Checkout repository code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Prepare environment
run: |
set -ex
dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools
echo "CI_SERVER_HOST=${GITEA_SERVER_URL#https://}" >> "$GITEA_ENV"
- name: Build solution
run: |
set -ex
shopt -s globstar
mkdir /artifacts
dotnet build -c ${CONFIGURATION} --nologo --restore --force --no-cache
mv ./**/*.nupkg /artifacts/ || true
mv ./**/*.snupkg /artifacts/ || true
- name: Test solution
run: |
set -ex
dotnet test -c ${CONFIGURATION} --no-build --nologo /p:CoverletOutputFormat=Cobertura
/dotnet-tools/reportgenerator "-reports:${{ gitea.workspace }}/**/coverage.cobertura.xml" "-targetdir:/reports" "-reportType:TextSummary"
cat /reports/Summary.txt
- name: Publish packages
run: |
set -ex
dotnet nuget push -k "${{ secrets.BAGET_APIKEY }}" -s "https://nuget.am-wd.de/v3/index.json" --skip-duplicate /artifacts/*.nupkg

View File

@@ -0,0 +1,69 @@
name: Release Build
on:
push:
tags:
- 'v*'
env:
TZ: 'Europe/Berlin'
LANG: 'de'
CONFIGURATION: 'Release'
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_COMMIT_TAG: ${{ gitea.ref_name }}
jobs:
build-test-deploy:
runs-on: ubuntu
defaults:
run:
shell: bash
steps:
- name: Setup dotnet
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.x
cache: false
- name: Checkout repository code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Prepare environment
run: |
set -ex
dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools
dotnet tool install docfx --tool-path /dotnet-tools
echo "CI_SERVER_HOST=${GITEA_SERVER_URL#https://}" >> "$GITEA_ENV"
- name: Build solution
run: |
set -ex
shopt -s globstar
mkdir /artifacts
dotnet build -c ${CONFIGURATION} --nologo --restore --force --no-cache
mv ./**/*.nupkg /artifacts/ || true
mv ./**/*.snupkg /artifacts/ || true
- name: Test solution
run: |
set -ex
dotnet test -c ${CONFIGURATION} --no-build --nologo /p:CoverletOutputFormat=Cobertura
/dotnet-tools/reportgenerator "-reports:${{ gitea.workspace }}/**/coverage.cobertura.xml" "-targetdir:/reports" "-reportType:TextSummary"
cat /reports/Summary.txt
- name: Publish packages
run: |
set -ex
dotnet nuget push -k "${{ secrets.NUGET_APIKEY }}" -s "https://api.nuget.org/v3/index.json" --skip-duplicate /artifacts/*.nupkg
- name: Publish documentation
env:
DOCFX_SOURCE_REPOSITORY_URL: 'https://github.com/AM-WD/LinkMobility'
run: |
set -ex
/dotnet-tools/docfx metadata docs/docfx.json
/dotnet-tools/docfx build docs/docfx.json
tar -C "${{ gitea.workspace }}/docs/_site" -czf "/artifacts/docs.tar.gz" .
curl -sSL --no-progress-meter --user "${{ secrets.DOCS_DEPLOY_USER }}:${{ secrets.DOCS_DEPLOY_PASS }}" -F docs=linkmobility -F dump=@/artifacts/docs.tar.gz "${{ vars.DOCS_DEPLOY_URL }}"

View File

@@ -1,130 +0,0 @@
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,7 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
_No changes yet_ _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
- Migrated main repository from Gitlab to Gitea
## [v0.1.0] - 2025-12-03 ## [v0.1.0] - 2025-12-03
@@ -25,6 +60,8 @@ _Initial release, SMS only._
[Unreleased]: https://github.com/AM-WD/LinkMobility/compare/v0.1.0...HEAD [Unreleased]: https://github.com/AM-WD/LinkMobility/compare/v0.2.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

@@ -2,6 +2,7 @@
<PropertyGroup> <PropertyGroup>
<LangVersion>14.0</LangVersion> <LangVersion>14.0</LangVersion>
<NrtRevisionFormat>{semvertag:main}{!:-dev}</NrtRevisionFormat> <NrtRevisionFormat>{semvertag:main}{!:-dev}</NrtRevisionFormat>
<NrtContinuousIntegrationBuild>$(ContinuousIntegrationBuild)</NrtContinuousIntegrationBuild>
<AppendTargetFrameworkToOutputPath>true</AppendTargetFrameworkToOutputPath> <AppendTargetFrameworkToOutputPath>true</AppendTargetFrameworkToOutputPath>
<CopyRefAssembliesToPublishDirectory>false</CopyRefAssembliesToPublishDirectory> <CopyRefAssembliesToPublishDirectory>false</CopyRefAssembliesToPublishDirectory>
@@ -16,20 +17,21 @@
<Copyright>© {copyright:2025-} AM.WD</Copyright> <Copyright>© {copyright:2025-} AM.WD</Copyright>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(GITLAB_CI)' == 'true'"> <PropertyGroup Condition="'$(CI)' == 'true'">
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild> <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup> </PropertyGroup>
<ItemGroup Condition="'$(GITLAB_CI)' == 'true'"> <ItemGroup Condition="'$(CI)' == 'true'">
<SourceLinkGitLabHost Include="$(CI_SERVER_HOST)" Version="$(CI_SERVER_VERSION)" /> <SourceLinkGiteaHost Include="$(CI_SERVER_HOST)" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0"> <PackageReference Include="Microsoft.SourceLink.Gitea" Version="8.0.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AMWD.NetRevisionTask" Version="1.3.0"> <PackageReference Include="AMWD.NetRevisionTask" Version="1.4.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@@ -1,9 +1,12 @@
<Solution> <Solution>
<Folder Name="/Solution Items/" /> <Folder Name="/Solution Items/" />
<Folder Name="/Solution Items/build/"> <Folder Name="/Solution Items/build/">
<File Path=".gitlab-ci.yml" />
<File Path="Directory.Build.props" /> <File Path="Directory.Build.props" />
</Folder> </Folder>
<Folder Name="/Solution Items/build/workflows/">
<File Path=".gitea/workflows/branch-build.yml" />
<File Path=".gitea/workflows/release-build.yml" />
</Folder>
<Folder Name="/Solution Items/config/"> <Folder Name="/Solution Items/config/">
<File Path=".editorconfig" /> <File Path=".editorconfig" />
<File Path=".gitignore" /> <File Path=".gitignore" />

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 [**tl;dr**Legal]) Published under [MIT License] (see [choose a license])
[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
[**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license [choose a license]: https://choosealicense.com/licenses/mit/

61
docs/docfx.json Normal file
View File

@@ -0,0 +1,61 @@
{
"$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
}
}

BIN
docs/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
docs/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

24
docs/index.md Normal file
View File

@@ -0,0 +1,24 @@
---
_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

3
docs/templates/amwd/public/main.css vendored Normal file
View File

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

4
docs/toc.yml Normal file
View File

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

View File

@@ -8,13 +8,19 @@ namespace AMWD.Net.Api.LinkMobility
public class ClientOptions public class ClientOptions
{ {
/// <summary> /// <summary>
/// Gets or sets the default base url for the API. /// Gets or sets the 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>
@@ -28,7 +34,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 allow redirects. /// Gets or sets a value indicating whether to follow redirects from the server.
/// </summary> /// </summary>
public virtual bool AllowRedirects { get; set; } public virtual bool AllowRedirects { get; set; }
@@ -40,6 +46,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

@@ -20,5 +20,11 @@ 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,8 @@
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility
{ {
/// <summary> /// <summary>
/// Custom status codes as defined by <see href="https://developer.linkmobility.eu/sms-api/rest-api#section/Status-codes">Link Mobility</see>. /// Custom status codes as defined by
/// <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,17 +9,13 @@ namespace AMWD.Net.Api.LinkMobility
public interface ILinkMobilityClient public interface ILinkMobilityClient
{ {
/// <summary> /// <summary>
/// Sends a text message to a list of recipients. /// Performs a POST request to the LINK Mobility API.
/// </summary> /// </summary>
/// <typeparam name="TResponse">The type of the response.</typeparam>
/// <typeparam name="TRequest">The type of the request.</typeparam>
/// <param name="requestPath">The path of the API endpoint.</param>
/// <param name="request">The request data.</param> /// <param name="request">The request data.</param>
/// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param> /// <param name="cancellationToken">A cancellation token to propagate notification that operations should be canceled.</param>
Task<SendMessageResponse> SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default); Task<TResponse> PostAsync<TResponse, TRequest>(string requestPath, TRequest? request, 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

@@ -10,10 +10,10 @@
<AssemblyName>amwd-linkmobility</AssemblyName> <AssemblyName>amwd-linkmobility</AssemblyName>
<RootNamespace>AMWD.Net.Api.LinkMobility</RootNamespace> <RootNamespace>AMWD.Net.Api.LinkMobility</RootNamespace>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<IncludeSymbols>true</IncludeSymbols> <IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat> <SymbolPackageFormat>snupkg</SymbolPackageFormat>
<EmbedUntrackedSources>false</EmbedUntrackedSources> <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageIcon>package-icon.png</PackageIcon> <PackageIcon>package-icon.png</PackageIcon>
<PackageProjectUrl>https://developer.linkmobility.eu/</PackageProjectUrl> <PackageProjectUrl>https://developer.linkmobility.eu/</PackageProjectUrl>
@@ -22,8 +22,6 @@
<Product>LINK Mobility REST API</Product> <Product>LINK Mobility REST API</Product>
<Description>Implementation of the LINK Mobility REST API using .NET</Description> <Description>Implementation of the LINK Mobility REST API using .NET</Description>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,67 +0,0 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AMWD.Net.Api.LinkMobility
{
/// <summary>
/// Implementation of text messaging (SMS). <see href="https://developer.linkmobility.eu/sms-api/rest-api">API</see>
/// </summary>
public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable
{
/// <inheritdoc/>
public Task<SendMessageResponse> SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(request.MessageContent))
throw new ArgumentException("A message must be provided.", nameof(request.MessageContent));
if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0)
throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList));
foreach (string recipient in request.RecipientAddressList)
{
if (!IsValidMSISDN(recipient))
throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
}
return PostAsync<SendMessageResponse, SendTextMessageRequest>("/smsmessaging/text", request, cancellationToken: cancellationToken);
}
/// <inheritdoc/>
public Task<SendMessageResponse> SendBinaryMessage(SendBinaryMessageRequest request, CancellationToken cancellationToken = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.MessageContent?.Count > 0)
{
// Validate that the string is a valid Base64 string
// Might throw a ArgumentNullException or FormatException
foreach (string str in request.MessageContent)
Convert.FromBase64String(str);
}
if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0)
throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList));
foreach (string recipient in request.RecipientAddressList)
{
if (!IsValidMSISDN(recipient))
throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
}
return PostAsync<SendMessageResponse, SendBinaryMessageRequest>("/smsmessaging/binary", request, cancellationToken: cancellationToken);
}
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,13 +7,14 @@ 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 partial class LinkMobilityClient : ILinkMobilityClient, IDisposable public class LinkMobilityClient : ILinkMobilityClient, IDisposable
{ {
private readonly ClientOptions _clientOptions; private readonly ClientOptions _clientOptions;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -26,8 +27,9 @@ 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>
public LinkMobilityClient(string username, string password, ClientOptions? clientOptions = null) /// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom implementation.</param>
: this(new BasicAuthentication(username, password), clientOptions) public LinkMobilityClient(string username, string password, ClientOptions? clientOptions = null, HttpClient? httpClient = null)
: this(new BasicAuthentication(username, password), clientOptions, httpClient)
{ {
} }
@@ -36,8 +38,9 @@ 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>
public LinkMobilityClient(string token, ClientOptions? clientOptions = null) /// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom implementation.</param>
: this(new AccessTokenAuthentication(token), clientOptions) public LinkMobilityClient(string token, ClientOptions? clientOptions = null, HttpClient? httpClient = null)
: this(new AccessTokenAuthentication(token), clientOptions, httpClient)
{ {
} }
@@ -47,7 +50,8 @@ 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>
public LinkMobilityClient(IAuthentication authentication, ClientOptions? clientOptions = null) /// <param name="httpClient">Optional <see cref="HttpClient"/> instance if you want a custom implementation.</param>
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));
@@ -55,12 +59,15 @@ namespace AMWD.Net.Api.LinkMobility
_clientOptions = clientOptions ?? new ClientOptions(); _clientOptions = clientOptions ?? new ClientOptions();
ValidateClientOptions(); ValidateClientOptions();
_httpClient = CreateHttpClient(); _httpClient = httpClient ?? CreateHttpClient();
ConfigureHttpClient(_httpClient);
authentication.AddHeader(_httpClient); authentication.AddHeader(_httpClient);
} }
/// <summary> /// <summary>
/// Disposes of the resources used by the <see cref="LinkMobilityClient"/> object. /// Disposes all 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()
{ {
@@ -73,6 +80,45 @@ 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))
@@ -118,61 +164,29 @@ 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('/');
var client = new HttpClient(handler, disposeHandler: true) httpClient.BaseAddress = new Uri($"{baseUrl}/");
{ httpClient.Timeout = _clientOptions.Timeout;
BaseAddress = new Uri($"{baseUrl}/"),
Timeout = _clientOptions.Timeout
};
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(nameof(LinkMobilityClient), version)); httpClient.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); httpClient.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)
client.DefaultRequestHeaders.Add(headerKvp.Key, headerKvp.Value); httpClient.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,20 +1,38 @@
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,12 +0,0 @@
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,10 +9,11 @@ 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 [**tl;dr**Legal]) Published under MIT License (see [choose a license])
[LINK Mobility REST API]: https://developer.linkmobility.eu/ [LINK Mobility REST API]: https://developer.linkmobility.eu/
[**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license [choose a license]: https://choosealicense.com/licenses/mit/

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,36 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Text
{ {
/// <summary> /// <summary>
/// Defines the types of delivery methods on a report. /// Defines the types of delivery methods on a report.
/// </summary> /// </summary>
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
public enum DeliveryType public enum DeliveryType
{ {
/// <summary> /// <summary>
/// Message sent via SMS. /// Message sent via SMS.
/// </summary> /// </summary>
[EnumMember(Value = "sms")] [EnumMember(Value = "sms")]
Sms = 1, Sms = 1,
/// <summary> /// <summary>
/// Message sent as Push message. /// Message sent as Push message.
/// </summary> /// </summary>
[EnumMember(Value = "push")] [EnumMember(Value = "push")]
Push = 2, Push = 2,
/// <summary> /// <summary>
/// Message sent as failover SMS. /// Message sent as failover SMS.
/// </summary> /// </summary>
[EnumMember(Value = "failover-sms")] [EnumMember(Value = "failover-sms")]
FailoverSms = 3, FailoverSms = 3,
/// <summary> /// <summary>
/// Message sent as voice message. /// Message sent as voice message.
/// </summary> /// </summary>
[EnumMember(Value = "voice")] [EnumMember(Value = "voice")]
Voice = 4 Voice = 4
} }
} }

View File

@@ -1,24 +1,24 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Text
{ {
/// <summary> /// <summary>
/// Specifies the message type. /// Specifies the message type.
/// </summary> /// </summary>
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
public enum MessageType public enum MessageType
{ {
/// <summary> /// <summary>
/// The message is sent as defined in the account settings. /// The message is sent as defined in the account settings.
/// </summary> /// </summary>
[EnumMember(Value = "default")] [EnumMember(Value = "default")]
Default = 1, Default = 1,
/// <summary> /// <summary>
/// The message is sent as voice call. /// The message is sent as voice call.
/// </summary> /// </summary>
[EnumMember(Value = "voice")] [EnumMember(Value = "voice")]
Voice = 2, Voice = 2,
} }
} }

View File

@@ -1,128 +1,130 @@
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Text
{ {
/// <summary> /// <summary>
/// Request to send a text message to a list of recipients. /// Request to send a text message to a list of recipients.
/// </summary> /// </summary>
public class SendBinaryMessageRequest public class SendBinaryMessageRequest
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SendBinaryMessageRequest"/> class. /// Initializes a new instance of the <see cref="SendBinaryMessageRequest"/> class.
/// </summary> /// </summary>
/// <param name="recipientAddressList">The recipient list.</param> /// <param name="messageContent">A binary message as base64 encoded lines.</param>
public SendBinaryMessageRequest(IReadOnlyCollection<string> recipientAddressList) /// <param name="recipientAddressList">A list of recipient numbers.</param>
{ public SendBinaryMessageRequest(IReadOnlyCollection<string> messageContent, IReadOnlyCollection<string> recipientAddressList)
RecipientAddressList = recipientAddressList; {
} MessageContent = messageContent;
RecipientAddressList = recipientAddressList;
/// <summary> }
/// <em>Optional</em>.
/// May contain a freely definable message id. /// <summary>
/// </summary> /// <em>Optional</em>.
[JsonProperty("clientMessageId")] /// May contain a freely definable message id.
public string? ClientMessageId { get; set; } /// </summary>
[JsonProperty("clientMessageId")]
/// <summary> public string? ClientMessageId { get; set; }
/// <em>Optional</em>.
/// The content category that is used to categorize the message (used for blacklisting). /// <summary>
/// </summary> /// <em>Optional</em>.
/// <remarks> /// The content category that is used to categorize the message (used for blacklisting).
/// The following content categories are supported: <see cref="ContentCategory.Informational"/> or <see cref="ContentCategory.Advertisement"/>. /// </summary>
/// If no content category is provided, the default setting is used (may be changed inside the web interface). /// <remarks>
/// </remarks> /// The following content categories are supported: <see cref="ContentCategory.Informational"/> or <see cref="ContentCategory.Advertisement"/>.
[JsonProperty("contentCategory")] /// If no content category is provided, the default setting is used (may be changed inside the web interface).
public ContentCategory? ContentCategory { get; set; } /// </remarks>
[JsonProperty("contentCategory")]
/// <summary> public ContentCategory? ContentCategory { get; set; }
/// <em>Optional</em>.
/// Array of <c>Base64</c> encoded binary data. /// <summary>
/// </summary> /// <em>Optional</em>.
/// <remarks> /// Array of <c>Base64</c> encoded binary data.
/// Every element of the array corresponds to a message segment. /// </summary>
/// The binary data is transmitted without being changed (using 8 bit alphabet). /// <remarks>
/// </remarks> /// Every element of the array corresponds to a message segment.
[JsonProperty("messageContent")] /// The binary data is transmitted without being changed (using 8 bit alphabet).
public IReadOnlyCollection<string>? MessageContent { get; set; } /// </remarks>
[JsonProperty("messageContent")]
/// <summary> public IReadOnlyCollection<string> MessageContent { get; set; }
/// <em>Optional</em>.
/// When setting a <c>NotificationCallbackUrl</c> all delivery reports are forwarded to this URL. /// <summary>
/// </summary> /// <em>Optional</em>.
[JsonProperty("notificationCallbackUrl")] /// When setting a <c>NotificationCallbackUrl</c> all delivery reports are forwarded to this URL.
public string? NotificationCallbackUrl { get; set; } /// </summary>
[JsonProperty("notificationCallbackUrl")]
/// <summary> public string? NotificationCallbackUrl { get; set; }
/// <em>Optional</em>.
/// Priority of the message. /// <summary>
/// </summary> /// <em>Optional</em>.
/// <remarks> /// Priority of the message.
/// Must not exceed the value configured for the account used to send the message. /// </summary>
/// For more information please contact our customer service. /// <remarks>
/// </remarks> /// Must not exceed the value configured for the account used to send the message.
[JsonProperty("priority")] /// For more information please contact our customer service.
public int? Priority { get; set; } /// </remarks>
[JsonProperty("priority")]
/// <summary> public int? Priority { get; set; }
/// List of recipients (E.164 formatted <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>s)
/// to whom the message should be sent. /// <summary>
/// <br/> /// List of recipients (E.164 formatted <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>s)
/// The list of recipients may contain a maximum of <em>1000</em> entries. /// to whom the message should be sent.
/// </summary> /// <br/>
[JsonProperty("recipientAddressList")] /// The list of recipients may contain a maximum of <em>1000</em> entries.
public IReadOnlyCollection<string> RecipientAddressList { get; set; } /// </summary>
[JsonProperty("recipientAddressList")]
/// <summary> public IReadOnlyCollection<string> RecipientAddressList { get; set; }
/// <em>Optional</em>.
/// <br/> /// <summary>
/// <see langword="true"/>: The message is sent as flash SMS (displayed directly on the screen of the mobile phone). /// <em>Optional</em>.
/// <br/> /// <br/>
/// <see langword="false"/>: The message is sent as standard text SMS (default). /// <see langword="true"/>: The message is sent as flash SMS (displayed directly on the screen of the mobile phone).
/// </summary> /// <br/>
[JsonProperty("sendAsFlashSms")] /// <see langword="false"/>: The message is sent as standard text SMS (default).
public bool? SendAsFlashSms { get; set; } /// </summary>
[JsonProperty("sendAsFlashSms")]
/// <summary> public bool? SendAsFlashSms { get; set; }
/// <em>Optional</em>.
/// Address of the sender (assigned to the account) from which the message is sent. /// <summary>
/// </summary> /// <em>Optional</em>.
[JsonProperty("senderAddress")] /// Address of the sender (assigned to the account) from which the message is sent.
public string? SenderAddress { get; set; } /// </summary>
[JsonProperty("senderAddress")]
/// <summary> public string? SenderAddress { get; set; }
/// <em>Optional</em>.
/// The sender address type. /// <summary>
/// </summary> /// <em>Optional</em>.
[JsonProperty("senderAddressType")] /// The sender address type.
public AddressType? SenderAddressType { get; set; } /// </summary>
[JsonProperty("senderAddressType")]
/// <summary> public AddressType? SenderAddressType { get; set; }
/// <em>Optional</em>.
/// <br/> /// <summary>
/// <see langword="true"/>: The transmission is only simulated, no SMS is sent. /// <em>Optional</em>.
/// Depending on the number of recipients the status code <see cref="StatusCodes.Ok"/> or <see cref="StatusCodes.OkQueued"/> is returned. /// <br/>
/// <br/> /// <see langword="true"/>: The transmission is only simulated, no SMS is sent.
/// <see langword="false"/>: No simulation is done. The SMS is sent via the SMS Gateway. (default) /// Depending on the number of recipients the status code <see cref="StatusCodes.Ok"/> or <see cref="StatusCodes.OkQueued"/> is returned.
/// </summary> /// <br/>
[JsonProperty("test")] /// <see langword="false"/>: No simulation is done. The SMS is sent via the SMS Gateway. (default)
public bool? Test { get; set; } /// </summary>
[JsonProperty("test")]
/// <summary> public bool? Test { get; set; }
/// <em>Optional</em>.
/// <br/> /// <summary>
/// <see langword="true"/>: Indicates the presence of a user data header in the <see cref="MessageContent"/> property. /// <em>Optional</em>.
/// <br/> /// <br/>
/// <see langword="false"/>: Indicates the absence of a user data header in the <see cref="MessageContent"/> property. (default) /// <see langword="true"/>: Indicates the presence of a user data header in the <see cref="MessageContent"/> property.
/// </summary> /// <br/>
[JsonProperty("userDataHeaderPresent")] /// <see langword="false"/>: Indicates the absence of a user data header in the <see cref="MessageContent"/> property. (default)
public bool? UserDataHeaderPresent { get; set; } /// </summary>
[JsonProperty("userDataHeaderPresent")]
/// <summary> public bool? UserDataHeaderPresent { get; set; }
/// <em>Optional</em>.
/// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient. /// <summary>
/// </summary> /// <em>Optional</em>.
/// <remarks> /// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient.
/// A minimum of 1 minute (<c>60</c> seconds) and a maximum of 3 days (<c>259200</c> seconds) are allowed. /// </summary>
/// </remarks> /// <remarks>
[JsonProperty("validityPeriode")] /// A minimum of 1 minute (<c>60</c> seconds) and a maximum of 3 days (<c>259200</c> seconds) are allowed.
public int? ValidityPeriode { get; set; } /// </remarks>
} [JsonProperty("validityPeriode")]
} public int? ValidityPeriode { get; set; }
}
}

View File

@@ -1,139 +1,139 @@
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Text
{ {
/// <summary> /// <summary>
/// Request to send a text message to a list of recipients. /// Request to send a text message to a list of recipients.
/// </summary> /// </summary>
public class SendTextMessageRequest public class SendTextMessageRequest
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SendTextMessageRequest"/> class. /// Initializes a new instance of the <see cref="SendTextMessageRequest"/> class.
/// </summary> /// </summary>
/// <param name="messageContent">The message.</param> /// <param name="messageContent">A text message.</param>
/// <param name="recipientAddressList">The recipient list.</param> /// <param name="recipientAddressList">A list of recipient numbers.</param>
public SendTextMessageRequest(string messageContent, IReadOnlyCollection<string> recipientAddressList) public SendTextMessageRequest(string messageContent, IReadOnlyCollection<string> recipientAddressList)
{ {
MessageContent = messageContent; MessageContent = messageContent;
RecipientAddressList = recipientAddressList; RecipientAddressList = recipientAddressList;
} }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// May contain a freely definable message id. /// May contain a freely definable message id.
/// </summary> /// </summary>
[JsonProperty("clientMessageId")] [JsonProperty("clientMessageId")]
public string? ClientMessageId { get; set; } public string? ClientMessageId { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// The content category that is used to categorize the message (used for blacklisting). /// The content category that is used to categorize the message (used for blacklisting).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The following content categories are supported: <see cref="ContentCategory.Informational"/> or <see cref="ContentCategory.Advertisement"/>. /// The following content categories are supported: <see cref="ContentCategory.Informational"/> or <see cref="ContentCategory.Advertisement"/>.
/// If no content category is provided, the default setting is used (may be changed inside the web interface). /// If no content category is provided, the default setting is used (may be changed inside the web interface).
/// </remarks> /// </remarks>
[JsonProperty("contentCategory")] [JsonProperty("contentCategory")]
public ContentCategory? ContentCategory { get; set; } public ContentCategory? ContentCategory { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// Specifies the maximum number of SMS to be generated. /// Specifies the maximum number of SMS to be generated.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// If the system generates more than this number of SMS, the status code <see cref="StatusCodes.MaxSmsPerMessageExceeded"/> is returned. /// If the system generates more than this number of SMS, the status code <see cref="StatusCodes.MaxSmsPerMessageExceeded"/> is returned.
/// The default value of this parameter is <c>0</c>. /// The default value of this parameter is <c>0</c>.
/// If set to <c>0</c>, no limitation is applied. /// If set to <c>0</c>, no limitation is applied.
/// </remarks> /// </remarks>
[JsonProperty("maxSmsPerMessage")] [JsonProperty("maxSmsPerMessage")]
public int? MaxSmsPerMessage { get; set; } public int? MaxSmsPerMessage { get; set; }
/// <summary> /// <summary>
/// <em>UTF-8</em> encoded message content. /// <em>UTF-8</em> encoded message content.
/// </summary> /// </summary>
[JsonProperty("messageContent")] [JsonProperty("messageContent")]
public string MessageContent { get; set; } public string MessageContent { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// Specifies the message type. /// Specifies the message type.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Allowed values are <see cref="MessageType.Default"/> and <see cref="MessageType.Voice"/>. /// Allowed values are <see cref="MessageType.Default"/> and <see cref="MessageType.Voice"/>.
/// When using the message type <see cref="MessageType.Default"/>, the outgoing message type is determined based on account settings. /// When using the message type <see cref="MessageType.Default"/>, the outgoing message type is determined based on account settings.
/// Using the message type <see cref="MessageType.Voice"/> triggers a voice call. /// Using the message type <see cref="MessageType.Voice"/> triggers a voice call.
/// </remarks> /// </remarks>
[JsonProperty("messageType")] [JsonProperty("messageType")]
public MessageType? MessageType { get; set; } public MessageType? MessageType { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// When setting a <c>NotificationCallbackUrl</c> all delivery reports are forwarded to this URL. /// When setting a <c>NotificationCallbackUrl</c> all delivery reports are forwarded to this URL.
/// </summary> /// </summary>
[JsonProperty("notificationCallbackUrl")] [JsonProperty("notificationCallbackUrl")]
public string? NotificationCallbackUrl { get; set; } public string? NotificationCallbackUrl { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// Priority of the message. /// Priority of the message.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Must not exceed the value configured for the account used to send the message. /// Must not exceed the value configured for the account used to send the message.
/// For more information please contact our customer service. /// For more information please contact our customer service.
/// </remarks> /// </remarks>
[JsonProperty("priority")] [JsonProperty("priority")]
public int? Priority { get; set; } public int? Priority { get; set; }
/// <summary> /// <summary>
/// List of recipients (E.164 formatted <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>s) /// List of recipients (E.164 formatted <see href="https://en.wikipedia.org/wiki/MSISDN">MSISDN</see>s)
/// to whom the message should be sent. /// to whom the message should be sent.
/// <br/> /// <br/>
/// The list of recipients may contain a maximum of <em>1000</em> entries. /// The list of recipients may contain a maximum of <em>1000</em> entries.
/// </summary> /// </summary>
[JsonProperty("recipientAddressList")] [JsonProperty("recipientAddressList")]
public IReadOnlyCollection<string> RecipientAddressList { get; set; } public IReadOnlyCollection<string> RecipientAddressList { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// <br/> /// <br/>
/// <see langword="true"/>: The message is sent as flash SMS (displayed directly on the screen of the mobile phone). /// <see langword="true"/>: The message is sent as flash SMS (displayed directly on the screen of the mobile phone).
/// <br/> /// <br/>
/// <see langword="false"/>: The message is sent as standard text SMS (default). /// <see langword="false"/>: The message is sent as standard text SMS (default).
/// </summary> /// </summary>
[JsonProperty("sendAsFlashSms")] [JsonProperty("sendAsFlashSms")]
public bool? SendAsFlashSms { get; set; } public bool? SendAsFlashSms { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// Address of the sender (assigned to the account) from which the message is sent. /// Address of the sender (assigned to the account) from which the message is sent.
/// </summary> /// </summary>
[JsonProperty("senderAddress")] [JsonProperty("senderAddress")]
public string? SenderAddress { get; set; } public string? SenderAddress { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// The sender address type. /// The sender address type.
/// </summary> /// </summary>
[JsonProperty("senderAddressType")] [JsonProperty("senderAddressType")]
public AddressType? SenderAddressType { get; set; } public AddressType? SenderAddressType { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// <br/> /// <br/>
/// <see langword="true"/>: The transmission is only simulated, no SMS is sent. /// <see langword="true"/>: The transmission is only simulated, no SMS is sent.
/// Depending on the number of recipients the status code <see cref="StatusCodes.Ok"/> or <see cref="StatusCodes.OkQueued"/> is returned. /// Depending on the number of recipients the status code <see cref="StatusCodes.Ok"/> or <see cref="StatusCodes.OkQueued"/> is returned.
/// <br/> /// <br/>
/// <see langword="false"/>: No simulation is done. The SMS is sent via the SMS Gateway. (default) /// <see langword="false"/>: No simulation is done. The SMS is sent via the SMS Gateway. (default)
/// </summary> /// </summary>
[JsonProperty("test")] [JsonProperty("test")]
public bool? Test { get; set; } public bool? Test { get; set; }
/// <summary> /// <summary>
/// <em>Optional</em>. /// <em>Optional</em>.
/// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient. /// Specifies the validity periode (in seconds) in which the message is tried to be delivered to the recipient.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// A minimum of 1 minute (<c>60</c> seconds) and a maximum of 3 days (<c>259200</c> seconds) are allowed. /// A minimum of 1 minute (<c>60</c> seconds) and a maximum of 3 days (<c>259200</c> seconds) are allowed.
/// </remarks> /// </remarks>
[JsonProperty("validityPeriode")] [JsonProperty("validityPeriode")]
public int? ValidityPeriode { get; set; } public int? ValidityPeriode { get; set; }
} }
} }

View File

@@ -0,0 +1,78 @@
using System.Threading;
using System.Threading.Tasks;
using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility.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 namespace AMWD.Net.Api.LinkMobility.Utils
{ {
internal static class SerializerExtensions internal static class SerializerExtensions
{ {

View File

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,32 @@
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,27 +1,33 @@
namespace AMWD.Net.Api.LinkMobility using AMWD.Net.Api.LinkMobility.Utils;
{
/// <summary> namespace AMWD.Net.Api.LinkMobility.Webhook
/// Representes the response to an incoming message notification. (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>) {
/// </summary> /// <summary>
public class IncomingMessageNotificationResponse /// Representes the response to an incoming message notification.
{ /// (See <see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>)
/// <summary> /// </summary>
/// Gets or sets the status code of the response. /// <remarks>
/// </summary> /// This notification acknowlegement is the same for all webhooks of LINK Mobility.
[JsonProperty("statusCode")] /// </remarks>
public StatusCodes StatusCode { get; set; } = StatusCodes.Ok; public class NotificationResponse
{
/// <summary> /// <summary>
/// Gets or sets the status message of the response. /// Gets or sets the status code of the response.
/// </summary> /// </summary>
[JsonProperty("statusMessage")] [JsonProperty("statusCode")]
public string StatusMessage { get; set; } = "OK"; public StatusCodes StatusCode { get; set; } = StatusCodes.Ok;
/// <summary> /// <summary>
/// Returns a string representation of the current object in serialized format. /// Gets or sets the status message of the response.
/// </summary> /// </summary>
/// <returns>A string containing the serialized form of the object (json).</returns> [JsonProperty("statusMessage")]
public override string ToString() public string StatusMessage { get; set; } = "OK";
=> this.SerializeObject();
} /// <summary>
} /// Returns a string representation of the current object in serialized format.
/// </summary>
/// <returns>A string containing the serialized form of the object (json).</returns>
public override string ToString()
=> this.SerializeObject();
}
}

View File

@@ -1,48 +1,48 @@
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Webhook.Text
{ {
/// <summary> /// <summary>
/// Defines the delivery status of a message on a report. /// Defines the delivery status of a message on a report.
/// </summary> /// </summary>
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
public enum DeliveryStatus public enum DeliveryStatus
{ {
/// <summary> /// <summary>
/// Message has been delivered to the recipient. /// Message has been delivered to the recipient.
/// </summary> /// </summary>
[EnumMember(Value = "delivered")] [EnumMember(Value = "delivered")]
Delivered = 1, Delivered = 1,
/// <summary> /// <summary>
/// Message not delivered and will be re-tried. /// Message not delivered and will be re-tried.
/// </summary> /// </summary>
[EnumMember(Value = "undelivered")] [EnumMember(Value = "undelivered")]
Undelivered = 2, Undelivered = 2,
/// <summary> /// <summary>
/// Message has expired and will no longer re-tried. /// Message has expired and will no longer re-tried.
/// </summary> /// </summary>
[EnumMember(Value = "expired")] [EnumMember(Value = "expired")]
Expired = 3, Expired = 3,
/// <summary> /// <summary>
/// Message has been deleted. /// Message has been deleted.
/// </summary> /// </summary>
[EnumMember(Value = "deleted")] [EnumMember(Value = "deleted")]
Deleted = 4, Deleted = 4,
/// <summary> /// <summary>
/// Message has been accepted by the carrier. /// Message has been accepted by the carrier.
/// </summary> /// </summary>
[EnumMember(Value = "accepted")] [EnumMember(Value = "accepted")]
Accepted = 5, Accepted = 5,
/// <summary> /// <summary>
/// Message has been rejected by the carrier. /// Message has been rejected by the carrier.
/// </summary> /// </summary>
[EnumMember(Value = "rejected")] [EnumMember(Value = "rejected")]
Rejected = 6 Rejected = 6
} }
} }

View File

@@ -0,0 +1,30 @@
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,194 +1,170 @@
using System.Runtime.Serialization; using AMWD.Net.Api.LinkMobility.Text;
using Newtonsoft.Json.Converters; using AMWD.Net.Api.LinkMobility.Utils;
namespace AMWD.Net.Api.LinkMobility namespace AMWD.Net.Api.LinkMobility.Webhook.Text
{ {
/// <summary> /// <summary>
/// Represents a notification for an incoming message or delivery report. (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>) /// Represents a notification for an incoming text message or delivery report.
/// </summary> /// (<see href="https://developer.linkmobility.eu/sms-api/receive-incoming-messages">API</see>)
public class IncomingMessageNotification /// </summary>
{ public class TextNotification
/// <summary> {
/// Initializes a new instance of the <see cref="IncomingMessageNotification"/> class. /// <summary>
/// </summary> /// Initializes a new instance of the <see cref="TextNotification"/> class.
/// <param name="notificationId">The notification id.</param> /// </summary>
/// <param name="transferId">The transfer id.</param> /// <param name="notificationId">The notification id.</param>
public IncomingMessageNotification(string notificationId, string transferId) /// <param name="transferId">The transfer id.</param>
{ public TextNotification(string notificationId, string transferId)
NotificationId = notificationId; {
TransferId = transferId; NotificationId = notificationId;
} TransferId = transferId;
}
/// <summary>
/// Defines the content type of your notification. /// <summary>
/// </summary> /// Defines the content type of your notification.
[JsonProperty("messageType")] /// </summary>
public Type MessageType { get; set; } [JsonProperty("messageType")]
public TextMessageType MessageType { get; set; }
/// <summary>
/// 20 digit long identification of your notification. /// <summary>
/// </summary> /// 20 digit long identification of your notification.
[JsonProperty("notificationId")] /// </summary>
public string NotificationId { get; set; } [JsonProperty("notificationId")]
public string NotificationId { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// Unique transfer-id to connect the deliveryReport to the initial message. /// <br/>
/// </summary> /// Unique transfer-id to connect the deliveryReport to the initial message.
[JsonProperty("transferId")] /// </summary>
public string TransferId { get; set; } [JsonProperty("transferId")]
public string TransferId { get; set; }
/// <summary>
/// <see cref="Type.Text"/>, <see cref="Type.Binary"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.Text"/>, <see cref="Text.TextMessageType.Binary"/>:
/// Indicates whether you received message is a SMS or a flash-SMS. /// <br/>
/// </summary> /// Indicates whether you received message is a SMS or a flash-SMS.
[JsonProperty("messageFlashSms")] /// </summary>
public bool? MessageFlashSms { get; set; } [JsonProperty("messageFlashSms")]
public bool? MessageFlashSms { get; set; }
/// <summary>
/// Originator of the sender. /// <summary>
/// </summary> /// Originator of the sender.
[JsonProperty("senderAddress")] /// </summary>
public string? SenderAddress { get; set; } [JsonProperty("senderAddress")]
public string? SenderAddress { get; set; }
/// <summary>
/// <see cref="Type.Text"/>, <see cref="Type.Binary"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.Text"/>, <see cref="Text.TextMessageType.Binary"/>:
/// <see cref="AddressType.International"/> defines the number format of the mobile originated <see cref="SenderAddress"/>. /// <br/>
/// International numbers always includes the country prefix. /// <see cref="AddressType.International"/> - defines the number format of the mobile originated <see cref="SenderAddress"/>.
/// </summary> /// International numbers always includes the country prefix.
[JsonProperty("senderAddressType")] /// </summary>
public AddressType? SenderAddressType { get; set; } [JsonProperty("senderAddressType")]
public AddressType? SenderAddressType { get; set; }
/// <summary>
/// Senders address, can either be /// <summary>
/// <see cref="AddressType.International"/> (4366012345678), /// Senders address, can either be
/// <see cref="AddressType.National"/> (066012345678) or a /// <see cref="AddressType.International"/> (4366012345678),
/// <see cref="AddressType.Shortcode"/> (1234). /// <see cref="AddressType.National"/> (066012345678) or a
/// </summary> /// <see cref="AddressType.Shortcode"/> (1234).
[JsonProperty("recipientAddress")] /// </summary>
public string? RecipientAddress { get; set; } [JsonProperty("recipientAddress")]
public string? RecipientAddress { get; set; }
/// <summary>
/// <see cref="Type.Text"/>, <see cref="Type.Binary"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.Text"/>, <see cref="Text.TextMessageType.Binary"/>:
/// Defines the number format of the mobile originated message. /// <br/>
/// </summary> /// Defines the number format of the mobile originated message.
[JsonProperty("recipientAddressType")] /// </summary>
public AddressType? RecipientAddressType { get; set; } [JsonProperty("recipientAddressType")]
public AddressType? RecipientAddressType { get; set; }
/// <summary>
/// <see cref="Type.Text"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.Text"/>:
/// Text body of the message encoded in <c>UTF-8</c>. /// <br/>
/// In the case of concatenated SMS it will contain the complete content of all segments. /// Text body of the message encoded in <c>UTF-8</c>.
/// </summary> /// In the case of concatenated SMS it will contain the complete content of all segments.
[JsonProperty("textMessageContent")] /// </summary>
public string? TextMessageContent { get; set; } [JsonProperty("textMessageContent")]
public string? TextMessageContent { get; set; }
/// <summary>
/// <see cref="Type.Binary"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.Binary"/>:
/// Indicates whether a user-data-header is included within a <c>Base64</c> encoded byte segment. /// <br/>
/// </summary> /// Indicates whether a user-data-header is included within a <c>Base64</c> encoded byte segment.
[JsonProperty("userDataHeaderPresent")] /// </summary>
public bool? UserDataHeaderPresent { get; set; } [JsonProperty("userDataHeaderPresent")]
public bool? UserDataHeaderPresent { get; set; }
/// <summary>
/// <see cref="Type.Binary"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.Binary"/>:
/// Content of a binary SMS in an array of <c>Base64</c> strings (URL safe). /// <br/>
/// </summary> /// Content of a binary SMS in an array of <c>Base64</c> strings (URL safe).
[JsonProperty("binaryMessageContent")] /// </summary>
public IReadOnlyCollection<string>? BinaryMessageContent { get; set; } [JsonProperty("binaryMessageContent")]
public IReadOnlyCollection<string>? BinaryMessageContent { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// Status of the message. /// <br/>
/// </summary> /// Status of the message.
[JsonProperty("deliveryReportMessageStatus")] /// </summary>
public DeliveryStatus? DeliveryReportMessageStatus { get; set; } [JsonProperty("deliveryReportMessageStatus")]
public DeliveryStatus? DeliveryReportMessageStatus { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// ISO 8601 timestamp. Point of time sending the message to recipients address. /// <br/>
/// </summary> /// ISO 8601 timestamp. Point of time sending the message to recipients address.
[JsonProperty("sentOn")] /// </summary>
public DateTime? SentOn { get; set; } [JsonProperty("sentOn")]
public DateTime? SentOn { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// ISO 8601 timestamp. Point of time of submitting the message to the mobile operators network. /// <br/>
/// </summary> /// ISO 8601 timestamp. Point of time of submitting the message to the mobile operators network.
[JsonProperty("deliveredOn")] /// </summary>
public DateTime? DeliveredOn { get; set; } [JsonProperty("deliveredOn")]
public DateTime? DeliveredOn { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// Type of delivery used to send the message. /// <br/>
/// </summary> /// Type of delivery used to send the message.
[JsonProperty("deliveredAs")] /// </summary>
public DeliveryType? DeliveredAs { get; set; } [JsonProperty("deliveredAs")]
public DeliveryType? DeliveredAs { get; set; }
/// <summary>
/// <see cref="Type.DeliveryReport"/>: /// <summary>
/// <br/> /// <see cref="Text.TextMessageType.DeliveryReport"/>:
/// In the case of a delivery report, the <see cref="ClientMessageId"/> contains the optional submitted message id. /// <br/>
/// </summary> /// In the case of a delivery report, the <see cref="ClientMessageId"/> contains the optional submitted message id.
[JsonProperty("clientMessageId")] /// </summary>
public string? ClientMessageId { get; set; } [JsonProperty("clientMessageId")]
public string? ClientMessageId { get; set; }
/// <summary>
/// Defines the type of notification. /// <summary>
/// </summary> /// Tries to parse the given content as <see cref="TextNotification"/>.
[JsonConverter(typeof(StringEnumConverter))] /// </summary>
public enum Type /// <param name="json">The given content (should be the notification json).</param>
{ /// <param name="notification">The deserialized notification.</param>
/// <summary> /// <returns>
/// Notification of an incoming text message. /// <see langword="true"/> if the content could be parsed; otherwise, <see langword="false"/>.
/// </summary> /// </returns>
[EnumMember(Value = "text")] public static bool TryParse(string json, out TextNotification? notification)
Text = 1, {
try
/// <summary> {
/// Notification of an incoming binary message. notification = json.DeserializeObject<TextNotification>();
/// </summary> return notification != null;
[EnumMember(Value = "binary")] }
Binary = 2, catch
{
/// <summary> notification = null;
/// Notification of a delivery report. return false;
/// </summary> }
[EnumMember(Value = "deliveryReport")] }
DeliveryReport = 3 }
} }
/// <summary>
/// Tries to parse the given content as <see cref="IncomingMessageNotification"/>.
/// </summary>
/// <param name="json">The given content (should be the notification json).</param>
/// <param name="notification">The deserialized notification.</param>
/// <returns>
/// <see langword="true"/> if the content could be parsed; otherwise, <see langword="false"/>.
/// </returns>
public static bool TryParse(string json, out IncomingMessageNotification? notification)
{
try
{
notification = json.DeserializeObject<IncomingMessageNotification>();
return notification != null;
}
catch
{
notification = null;
return false;
}
}
}
}

View File

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,51 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,66 @@
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

@@ -0,0 +1,54 @@
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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,52 @@
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

@@ -0,0 +1,74 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,66 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,52 @@
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

@@ -0,0 +1,58 @@
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

@@ -0,0 +1,52 @@
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

@@ -0,0 +1,51 @@
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

@@ -0,0 +1,52 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,42 @@
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

@@ -0,0 +1,83 @@
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

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

View File

@@ -145,7 +145,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "test", _request, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, TestClass>("test", _request, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -166,57 +166,12 @@ 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.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.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()
{ {
@@ -227,9 +182,7 @@ namespace LinkMobility.Tests
client.Dispose(); client.Dispose();
// Assert // Assert
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
.Protected()
.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
@@ -245,9 +198,7 @@ namespace LinkMobility.Tests
client.Dispose(); client.Dispose();
// Assert // Assert
_httpMessageHandlerMock.Mock _httpMessageHandlerMock.Protected.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
.Protected()
.Verify("Dispose", Times.Once(), exactParameterMatch: true, true);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
} }
@@ -256,7 +207,7 @@ namespace LinkMobility.Tests
public void ShouldAssertClientOptions() public void ShouldAssertClientOptions()
{ {
// Arrange + Act // Arrange + Act
var client = GetClient(); _ = GetClient();
// Assert // Assert
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -320,7 +271,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ObjectDisposedException>(async () => await Assert.ThrowsExactlyAsync<ObjectDisposedException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "/request/path", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("/request/path", _request, TestContext.CancellationToken);
}); });
} }
@@ -336,7 +287,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", path, _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>(path, _request, TestContext.CancellationToken);
}); });
} }
@@ -349,7 +300,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<ArgumentException>(async () => await Assert.ThrowsExactlyAsync<ArgumentException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo?bar=baz", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("foo?bar=baz", _request, TestContext.CancellationToken);
}); });
} }
@@ -366,7 +317,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "/request/path", _request, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, TestClass>("/request/path", _request, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -389,9 +340,7 @@ 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.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -411,7 +360,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "/request/path", stringContent, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, HttpContent>("/request/path", stringContent, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -434,9 +383,7 @@ 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.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -455,7 +402,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
var response = await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "posting", null, null, TestContext.CancellationToken); var response = await client.PostAsync<TestClass, object>("posting", null, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -479,9 +426,7 @@ 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.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
} }
[TestMethod] [TestMethod]
@@ -501,7 +446,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
var ex = await Assert.ThrowsExactlyAsync<AuthenticationException>(async () => var ex = await Assert.ThrowsExactlyAsync<AuthenticationException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("foo", _request, TestContext.CancellationToken);
}); });
Assert.IsNull(ex.InnerException); Assert.IsNull(ex.InnerException);
Assert.AreEqual($"HTTP auth missing: {statusCode}", ex.Message); Assert.AreEqual($"HTTP auth missing: {statusCode}", ex.Message);
@@ -524,7 +469,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
var ex = await Assert.ThrowsExactlyAsync<ApplicationException>(async () => var ex = await Assert.ThrowsExactlyAsync<ApplicationException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<object>(client, "PostAsync", "foo", _request, null, TestContext.CancellationToken); await client.PostAsync<object, TestClass>("foo", _request, TestContext.CancellationToken);
}); });
Assert.IsNull(ex.InnerException); Assert.IsNull(ex.InnerException);
Assert.AreEqual($"Unknown HTTP response: {statusCode}", ex.Message); Assert.AreEqual($"Unknown HTTP response: {statusCode}", ex.Message);
@@ -545,7 +490,7 @@ namespace LinkMobility.Tests
// Act & Assert // Act & Assert
await Assert.ThrowsExactlyAsync<JsonReaderException>(async () => await Assert.ThrowsExactlyAsync<JsonReaderException>(async () =>
{ {
await ReflectionHelper.InvokePrivateMethodAsync<TestClass>(client, "PostAsync", "some-path", _request, null, TestContext.CancellationToken); await client.PostAsync<TestClass, TestClass>("some-path", _request, TestContext.CancellationToken);
}); });
} }
@@ -563,7 +508,7 @@ namespace LinkMobility.Tests
var client = GetClient(); var client = GetClient();
// Act // Act
string response = await ReflectionHelper.InvokePrivateMethodAsync<string>(client, "PostAsync", "path", _request, null, TestContext.CancellationToken); string response = await client.PostAsync<string, TestClass>("path", _request, TestContext.CancellationToken);
// Assert // Assert
Assert.IsNotNull(response); Assert.IsNotNull(response);
@@ -586,9 +531,7 @@ 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.Mock _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); VerifyNoOtherCalls();
@@ -638,16 +581,5 @@ 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,264 +1,303 @@
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
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; using AMWD.Net.Api.LinkMobility;
using LinkMobility.Tests.Helpers; using AMWD.Net.Api.LinkMobility.Text;
using Moq.Protected; using LinkMobility.Tests.Helpers;
using Moq.Protected;
namespace LinkMobility.Tests.Sms
{ namespace LinkMobility.Tests.Text
[TestClass] {
public class SendBinaryMessageTest [TestClass]
{ public class SendBinaryMessageTest
public TestContext TestContext { get; set; } {
public TestContext TestContext { get; set; }
private const string BASE_URL = "https://localhost/rest/";
private const string BASE_URL = "https://localhost/rest/";
private Mock<IAuthentication> _authenticationMock;
private Mock<ClientOptions> _clientOptionsMock; private Mock<IAuthentication> _authenticationMock;
private HttpMessageHandlerMock _httpMessageHandlerMock; private Mock<ClientOptions> _clientOptionsMock;
private HttpMessageHandlerMock _httpMessageHandlerMock;
private SendBinaryMessageRequest _request;
private SendBinaryMessageRequest _request;
[TestInitialize]
public void Initialize() [TestInitialize]
{ public void Initialize()
_authenticationMock = new Mock<IAuthentication>(); {
_clientOptionsMock = new Mock<ClientOptions>(); _authenticationMock = new Mock<IAuthentication>();
_httpMessageHandlerMock = new HttpMessageHandlerMock(); _clientOptionsMock = new Mock<ClientOptions>();
_httpMessageHandlerMock = new HttpMessageHandlerMock();
_authenticationMock
.Setup(a => a.AddHeader(It.IsAny<HttpClient>())) _authenticationMock
.Callback<HttpClient>(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Scheme", "Parameter")); .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.BaseUrl).Returns(BASE_URL);
_clientOptionsMock.Setup(c => c.DefaultHeaders).Returns(new Dictionary<string, string>()); _clientOptionsMock.Setup(c => c.Timeout).Returns(TimeSpan.FromSeconds(30));
_clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary<string, string>()); _clientOptionsMock.Setup(c => c.DefaultHeaders).Returns(new Dictionary<string, string>());
_clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true); _clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary<string, string>());
_clientOptionsMock.Setup(c => c.UseProxy).Returns(false); _clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true);
_clientOptionsMock.Setup(c => c.UseProxy).Returns(false);
_request = new SendBinaryMessageRequest(["436991234567"])
{ _request = new SendBinaryMessageRequest(["SGVsbG8gV29ybGQ="], ["436991234567"]); // "Hello World" in Base64
MessageContent = ["SGVsbG8gV29ybGQ="] // "Hello World" base64 }
};
} [TestMethod]
public async Task ShouldSendBinaryMessage()
[TestMethod] {
public async Task ShouldSendBinaryMessage() // Arrange
{ _httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
// Arrange {
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage StatusCode = HttpStatusCode.OK,
{ Content = new StringContent(@"{ ""clientMessageId"": ""binId"", ""smsCount"": 1, ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""abc123"" }", Encoding.UTF8, "application/json"),
StatusCode = HttpStatusCode.OK, });
Content = new StringContent(@"{ ""clientMessageId"": ""binId"", ""smsCount"": 1, ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""abc123"" }", Encoding.UTF8, "application/json"),
}); var client = GetClient();
var client = GetClient(); // Act
var response = await client.SendBinaryMessage(_request, TestContext.CancellationToken);
// Act
var response = await client.SendBinaryMessage(_request, TestContext.CancellationToken); // Assert
Assert.IsNotNull(response);
// Assert Assert.AreEqual("binId", response.ClientMessageId);
Assert.IsNotNull(response); Assert.AreEqual(1, response.SmsCount);
Assert.AreEqual("binId", response.ClientMessageId); Assert.AreEqual(StatusCodes.Ok, response.StatusCode);
Assert.AreEqual(1, response.SmsCount); Assert.AreEqual("OK", response.StatusMessage);
Assert.AreEqual(StatusCodes.Ok, response.StatusCode); Assert.AreEqual("abc123", response.TransferId);
Assert.AreEqual("OK", response.StatusMessage);
Assert.AreEqual("abc123", response.TransferId); Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks); var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
var callback = _httpMessageHandlerMock.RequestCallbacks.First(); Assert.AreEqual("https://localhost/rest/smsmessaging/binary", callback.Url);
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod); Assert.AreEqual(@"{""messageContent"":[""SGVsbG8gV29ybGQ=""],""recipientAddressList"":[""436991234567""]}", callback.Content);
Assert.AreEqual("https://localhost/rest/smsmessaging/binary", callback.Url);
Assert.AreEqual(@"{""messageContent"":[""SGVsbG8gV29ybGQ=""],""recipientAddressList"":[""436991234567""]}", callback.Content); Assert.HasCount(3, callback.Headers);
Assert.IsTrue(callback.Headers.ContainsKey("Accept"));
Assert.HasCount(3, callback.Headers); Assert.IsTrue(callback.Headers.ContainsKey("Authorization"));
Assert.IsTrue(callback.Headers.ContainsKey("Accept")); Assert.IsTrue(callback.Headers.ContainsKey("User-Agent"));
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("application/json", callback.Headers["Accept"]); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
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>());
_httpMessageHandlerMock.Mock _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
.Protected() VerifyNoOtherCalls();
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()); }
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); [TestMethod]
VerifyNoOtherCalls(); public void ShouldThrowOnInvalidContentCategoryForBinary()
} {
// Arrange
[TestMethod] _request.ContentCategory = 0;
public async Task ShouldSendBinaryMessageFullDetails() var client = GetClient();
{
// Arrange // Act & Assert
_request.ClientMessageId = "myCustomId"; var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
_request.ContentCategory = ContentCategory.Advertisement; Assert.AreEqual("contentCategory", ex.ParamName);
_request.NotificationCallbackUrl = "https://user:pass@example.com/callback/"; Assert.StartsWith("Content category '0' is not valid.", ex.Message);
_request.Priority = 5;
_request.SendAsFlashSms = false; VerifyNoOtherCalls();
_request.SenderAddress = "4369912345678"; }
_request.SenderAddressType = AddressType.International;
_request.Test = false; [TestMethod]
_request.UserDataHeaderPresent = true; public async Task ShouldSendBinaryMessageFullDetails()
_request.ValidityPeriode = 300; {
// Arrange
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage _request.ClientMessageId = "myCustomId";
{ _request.ContentCategory = ContentCategory.Advertisement;
StatusCode = HttpStatusCode.OK, _request.NotificationCallbackUrl = "https://user:pass@example.com/callback/";
Content = new StringContent(@"{ ""clientMessageId"": ""myCustomId"", ""smsCount"": 1, ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""abc123"" }", Encoding.UTF8, "application/json"), _request.Priority = 5;
}); _request.SendAsFlashSms = false;
_request.SenderAddress = "4369912345678";
var client = GetClient(); _request.SenderAddressType = AddressType.International;
_request.Test = false;
// Act _request.UserDataHeaderPresent = true;
var response = await client.SendBinaryMessage(_request, TestContext.CancellationToken); _request.ValidityPeriode = 300;
// Assert _httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
Assert.IsNotNull(response); {
Assert.AreEqual("myCustomId", response.ClientMessageId); StatusCode = HttpStatusCode.OK,
Assert.AreEqual(1, response.SmsCount); Content = new StringContent(@"{ ""clientMessageId"": ""myCustomId"", ""smsCount"": 1, ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""abc123"" }", Encoding.UTF8, "application/json"),
Assert.AreEqual(StatusCodes.Ok, response.StatusCode); });
Assert.AreEqual("OK", response.StatusMessage);
Assert.AreEqual("abc123", response.TransferId); var client = GetClient();
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks); // Act
var response = await client.SendBinaryMessage(_request, TestContext.CancellationToken);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod); // Assert
Assert.AreEqual("https://localhost/rest/smsmessaging/binary", callback.Url); Assert.IsNotNull(response);
Assert.AreEqual(@"{""clientMessageId"":""myCustomId"",""contentCategory"":""advertisement"",""messageContent"":[""SGVsbG8gV29ybGQ=""],""notificationCallbackUrl"":""https://user:pass@example.com/callback/"",""priority"":5,""recipientAddressList"":[""436991234567""],""sendAsFlashSms"":false,""senderAddress"":""4369912345678"",""senderAddressType"":""international"",""test"":false,""userDataHeaderPresent"":true,""validityPeriode"":300}", callback.Content); Assert.AreEqual("myCustomId", response.ClientMessageId);
Assert.AreEqual(1, response.SmsCount);
Assert.HasCount(3, callback.Headers); Assert.AreEqual(StatusCodes.Ok, response.StatusCode);
Assert.IsTrue(callback.Headers.ContainsKey("Accept")); Assert.AreEqual("OK", response.StatusMessage);
Assert.IsTrue(callback.Headers.ContainsKey("Authorization")); Assert.AreEqual("abc123", response.TransferId);
Assert.IsTrue(callback.Headers.ContainsKey("User-Agent"));
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
Assert.AreEqual("application/json", callback.Headers["Accept"]);
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]); var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual("https://localhost/rest/smsmessaging/binary", callback.Url);
_httpMessageHandlerMock.Mock Assert.AreEqual(@"{""clientMessageId"":""myCustomId"",""contentCategory"":""advertisement"",""messageContent"":[""SGVsbG8gV29ybGQ=""],""notificationCallbackUrl"":""https://user:pass@example.com/callback/"",""priority"":5,""recipientAddressList"":[""436991234567""],""sendAsFlashSms"":false,""senderAddress"":""4369912345678"",""senderAddressType"":""international"",""test"":false,""userDataHeaderPresent"":true,""validityPeriode"":300}", callback.Content);
.Protected()
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()); Assert.HasCount(3, callback.Headers);
Assert.IsTrue(callback.Headers.ContainsKey("Accept"));
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once); Assert.IsTrue(callback.Headers.ContainsKey("Authorization"));
VerifyNoOtherCalls(); Assert.IsTrue(callback.Headers.ContainsKey("User-Agent"));
}
Assert.AreEqual("application/json", callback.Headers["Accept"]);
[TestMethod] Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
public void ShouldThrowOnNullRequest() Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
{
// Arrange _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
var client = GetClient();
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
// Act & Assert VerifyNoOtherCalls();
var ex = Assert.ThrowsExactly<ArgumentNullException>(() => client.SendBinaryMessage(null, TestContext.CancellationToken)); }
Assert.AreEqual("request", ex.ParamName); [TestMethod]
public void ShouldThrowOnNullRequest()
VerifyNoOtherCalls(); {
} // Arrange
var client = GetClient();
[TestMethod]
public void ShouldThrowOnInvalidMessageEncoding() // Act & Assert
{ var ex = Assert.ThrowsExactly<ArgumentNullException>(() => client.SendBinaryMessage(null, TestContext.CancellationToken));
// Arrange
_request.MessageContent = ["InvalidBase64!!"]; Assert.AreEqual("request", ex.ParamName);
var client = GetClient();
VerifyNoOtherCalls();
// Act & Assert }
Assert.ThrowsExactly<FormatException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
[TestMethod]
VerifyNoOtherCalls(); public void ShouldThrowOnNullMessageContentList()
} {
// Arrange
[TestMethod] _request.MessageContent = null;
public void ShouldThrowOnNullMessageContent() var client = GetClient();
{
// Arrange // Act & Assert
_request.MessageContent = [null]; var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
var client = GetClient();
Assert.AreEqual("MessageContent", ex.ParamName);
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentNullException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); VerifyNoOtherCalls();
}
VerifyNoOtherCalls();
} [TestMethod]
public void ShouldThrowOnEmptyMessageContentList()
[TestMethod] {
[DataRow(null)] // Arrange
[DataRow("")] _request.MessageContent = [];
public void ShouldThrowOnNoRecipients(string recipients) var client = GetClient();
{
// Arrange // Act & Assert
_request.RecipientAddressList = recipients?.Split(','); var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
var client = GetClient();
Assert.AreEqual("MessageContent", ex.ParamName);
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); VerifyNoOtherCalls();
}
Assert.AreEqual("RecipientAddressList", ex.ParamName);
[TestMethod]
VerifyNoOtherCalls(); public void ShouldThrowOnInvalidMessageEncoding()
} {
// Arrange
[TestMethod] _request.MessageContent = ["InvalidBase64!!"];
[DataRow(null)] var client = GetClient();
[DataRow("")]
[DataRow(" ")] // Act & Assert
[DataRow("invalid-recipient")] Assert.ThrowsExactly<FormatException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
public void ShouldThrowOnInvalidRecipient(string recipient)
{ VerifyNoOtherCalls();
// Arrange }
_request.RecipientAddressList = ["436991234567", recipient];
var client = GetClient(); [TestMethod]
public void ShouldThrowOnNullMessageContent()
// Act & Assert {
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken)); // Arrange
_request.MessageContent = [null];
Assert.AreEqual("RecipientAddressList", ex.ParamName); var client = GetClient();
Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message);
// Act & Assert
VerifyNoOtherCalls(); var ex = Assert.ThrowsExactly<ArgumentNullException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
}
VerifyNoOtherCalls();
private void VerifyNoOtherCalls() }
{
_authenticationMock.VerifyNoOtherCalls(); [TestMethod]
_clientOptionsMock.VerifyNoOtherCalls(); [DataRow(null)]
_httpMessageHandlerMock.Mock.VerifyNoOtherCalls(); [DataRow("")]
} public void ShouldThrowOnNoRecipients(string recipients)
{
private ILinkMobilityClient GetClient() // Arrange
{ _request.RecipientAddressList = recipients?.Split(',');
var client = new LinkMobilityClient(_authenticationMock.Object, _clientOptionsMock.Object); var client = GetClient();
var httpClient = new HttpClient(_httpMessageHandlerMock.Mock.Object) // Act & Assert
{ var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Timeout = _clientOptionsMock.Object.Timeout,
BaseAddress = new Uri(_clientOptionsMock.Object.BaseUrl) Assert.AreEqual("recipientAddressList", ex.ParamName);
};
VerifyNoOtherCalls();
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LinkMobilityClient", "1.0.0")); }
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
[TestMethod]
_authenticationMock.Object.AddHeader(httpClient); [DataRow(null)]
[DataRow("")]
_authenticationMock.Invocations.Clear(); [DataRow(" ")]
_clientOptionsMock.Invocations.Clear(); [DataRow("invalid-recipient")]
public void ShouldThrowOnInvalidRecipient(string recipient)
ReflectionHelper.GetPrivateField<HttpClient>(client, "_httpClient")?.Dispose(); {
ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient); // Arrange
_request.RecipientAddressList = ["436991234567", recipient];
return client; var client = GetClient();
}
} // Act & Assert
} var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendBinaryMessage(_request, TestContext.CancellationToken));
Assert.AreEqual("recipientAddressList", ex.ParamName);
Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message);
VerifyNoOtherCalls();
}
private void VerifyNoOtherCalls()
{
_authenticationMock.VerifyNoOtherCalls();
_clientOptionsMock.VerifyNoOtherCalls();
_httpMessageHandlerMock.Mock.VerifyNoOtherCalls();
}
private ILinkMobilityClient GetClient()
{
var client = new LinkMobilityClient(_authenticationMock.Object, _clientOptionsMock.Object);
var httpClient = new HttpClient(_httpMessageHandlerMock.Mock.Object)
{
Timeout = _clientOptionsMock.Object.Timeout,
BaseAddress = new Uri(_clientOptionsMock.Object.BaseUrl)
};
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LinkMobilityClient", "1.0.0"));
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_authenticationMock.Object.AddHeader(httpClient);
_authenticationMock.Invocations.Clear();
_clientOptionsMock.Invocations.Clear();
ReflectionHelper.GetPrivateField<HttpClient>(client, "_httpClient")?.Dispose();
ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient);
return client;
}
}
}

View File

@@ -1,251 +1,263 @@
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
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; using AMWD.Net.Api.LinkMobility;
using LinkMobility.Tests.Helpers; using AMWD.Net.Api.LinkMobility.Text;
using Moq.Protected; using LinkMobility.Tests.Helpers;
using Moq.Protected;
namespace LinkMobility.Tests.Sms
{ namespace LinkMobility.Tests.Text
[TestClass] {
public class SendTextMessageTest [TestClass]
{ public class SendTextMessageTest
public TestContext TestContext { get; set; } {
public TestContext TestContext { get; set; }
private const string BASE_URL = "https://localhost/rest/";
private const string BASE_URL = "https://localhost/rest/";
private Mock<IAuthentication> _authenticationMock;
private Mock<ClientOptions> _clientOptionsMock; private Mock<IAuthentication> _authenticationMock;
private HttpMessageHandlerMock _httpMessageHandlerMock; private Mock<ClientOptions> _clientOptionsMock;
private HttpMessageHandlerMock _httpMessageHandlerMock;
private SendTextMessageRequest _request;
private SendTextMessageRequest _request;
[TestInitialize]
public void Initialize() [TestInitialize]
{ public void Initialize()
_authenticationMock = new Mock<IAuthentication>(); {
_clientOptionsMock = new Mock<ClientOptions>(); _authenticationMock = new Mock<IAuthentication>();
_httpMessageHandlerMock = new HttpMessageHandlerMock(); _clientOptionsMock = new Mock<ClientOptions>();
_httpMessageHandlerMock = new HttpMessageHandlerMock();
_authenticationMock
.Setup(a => a.AddHeader(It.IsAny<HttpClient>())) _authenticationMock
.Callback<HttpClient>(c => c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Scheme", "Parameter")); .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.BaseUrl).Returns(BASE_URL);
_clientOptionsMock.Setup(c => c.DefaultHeaders).Returns(new Dictionary<string, string>()); _clientOptionsMock.Setup(c => c.Timeout).Returns(TimeSpan.FromSeconds(30));
_clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary<string, string>()); _clientOptionsMock.Setup(c => c.DefaultHeaders).Returns(new Dictionary<string, string>());
_clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true); _clientOptionsMock.Setup(c => c.DefaultQueryParams).Returns(new Dictionary<string, string>());
_clientOptionsMock.Setup(c => c.UseProxy).Returns(false); _clientOptionsMock.Setup(c => c.AllowRedirects).Returns(true);
_clientOptionsMock.Setup(c => c.UseProxy).Returns(false);
_request = new SendTextMessageRequest("example message content", ["436991234567"]);
} _request = new SendTextMessageRequest("example message content", ["436991234567"]);
}
[TestMethod]
public async Task ShouldSendTextMessage() [TestMethod]
{ public async Task ShouldSendTextMessage()
// Arrange {
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage // Arrange
{ _httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
StatusCode = HttpStatusCode.OK, {
Content = new StringContent(@"{ ""clientMessageId"": ""myUniqueId"", ""smsCount"": 1, ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""0059d0b20100a0a8b803"" }", Encoding.UTF8, "application/json"), StatusCode = HttpStatusCode.OK,
}); Content = new StringContent(@"{ ""clientMessageId"": ""myUniqueId"", ""smsCount"": 1, ""statusCode"": 2000, ""statusMessage"": ""OK"", ""transferId"": ""0059d0b20100a0a8b803"" }", Encoding.UTF8, "application/json"),
});
var client = GetClient();
var client = GetClient();
// Act
var response = await client.SendTextMessage(_request, TestContext.CancellationToken); // Act
var response = await client.SendTextMessage(_request, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response); // Assert
Assert.IsNotNull(response);
Assert.AreEqual("myUniqueId", response.ClientMessageId);
Assert.AreEqual(1, response.SmsCount); Assert.AreEqual("myUniqueId", response.ClientMessageId);
Assert.AreEqual(StatusCodes.Ok, response.StatusCode); Assert.AreEqual(1, response.SmsCount);
Assert.AreEqual("OK", response.StatusMessage); Assert.AreEqual(StatusCodes.Ok, response.StatusCode);
Assert.AreEqual("0059d0b20100a0a8b803", response.TransferId); Assert.AreEqual("OK", response.StatusMessage);
Assert.AreEqual("0059d0b20100a0a8b803", response.TransferId);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod); var callback = _httpMessageHandlerMock.RequestCallbacks.First();
Assert.AreEqual("https://localhost/rest/smsmessaging/text", callback.Url); Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
Assert.AreEqual(@"{""messageContent"":""example message content"",""recipientAddressList"":[""436991234567""]}", callback.Content); Assert.AreEqual("https://localhost/rest/smsmessaging/text", callback.Url);
Assert.AreEqual(@"{""messageContent"":""example message content"",""recipientAddressList"":[""436991234567""]}", callback.Content);
Assert.HasCount(3, callback.Headers);
Assert.IsTrue(callback.Headers.ContainsKey("Accept")); Assert.HasCount(3, callback.Headers);
Assert.IsTrue(callback.Headers.ContainsKey("Authorization")); Assert.IsTrue(callback.Headers.ContainsKey("Accept"));
Assert.IsTrue(callback.Headers.ContainsKey("User-Agent")); 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("application/json", callback.Headers["Accept"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
_httpMessageHandlerMock.Mock
.Protected() _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
.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]
[TestMethod] public void ShouldThrowOnInvalidContentCategory()
public async Task ShouldSendTextMessageFullDetails() {
{ // Arrange
// Arrange _request.ContentCategory = 0;
_request.ClientMessageId = "myCustomId"; var client = GetClient();
_request.ContentCategory = ContentCategory.Informational;
_request.MaxSmsPerMessage = 1; // Act & Assert
_request.MessageType = MessageType.Voice; var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(_request, TestContext.CancellationToken));
_request.NotificationCallbackUrl = "https://user:pass@example.com/callback/"; Assert.AreEqual("contentCategory", ex.ParamName);
_request.Priority = 5; Assert.StartsWith("Content category '0' is not valid.", ex.Message);
_request.SendAsFlashSms = false;
_request.SenderAddress = "4369912345678"; VerifyNoOtherCalls();
_request.SenderAddressType = AddressType.International; }
_request.Test = false;
_request.ValidityPeriode = 300; [TestMethod]
public async Task ShouldSendTextMessageFullDetails()
_httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage {
{ // Arrange
StatusCode = HttpStatusCode.OK, _request.ClientMessageId = "myCustomId";
Content = new StringContent(@"{ ""clientMessageId"": ""myCustomId"", ""smsCount"": 1, ""statusCode"": 4035, ""statusMessage"": ""SMS_DISABLED"", ""transferId"": ""0059d0b20100a0a8b803"" }", Encoding.UTF8, "application/json"), _request.ContentCategory = ContentCategory.Informational;
}); _request.MaxSmsPerMessage = 1;
_request.MessageType = MessageType.Voice;
var client = GetClient(); _request.NotificationCallbackUrl = "https://user:pass@example.com/callback/";
_request.Priority = 5;
// Act _request.SendAsFlashSms = false;
var response = await client.SendTextMessage(_request, TestContext.CancellationToken); _request.SenderAddress = "4369912345678";
_request.SenderAddressType = AddressType.International;
// Assert _request.Test = false;
Assert.IsNotNull(response); _request.ValidityPeriode = 300;
Assert.AreEqual("myCustomId", response.ClientMessageId); _httpMessageHandlerMock.Responses.Enqueue(new HttpResponseMessage
Assert.AreEqual(1, response.SmsCount); {
Assert.AreEqual(StatusCodes.SmsDisabled, response.StatusCode); StatusCode = HttpStatusCode.OK,
Assert.AreEqual("SMS_DISABLED", response.StatusMessage); Content = new StringContent(@"{ ""clientMessageId"": ""myCustomId"", ""smsCount"": 1, ""statusCode"": 4035, ""statusMessage"": ""SMS_DISABLED"", ""transferId"": ""0059d0b20100a0a8b803"" }", Encoding.UTF8, "application/json"),
Assert.AreEqual("0059d0b20100a0a8b803", response.TransferId); });
Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks); var client = GetClient();
var callback = _httpMessageHandlerMock.RequestCallbacks.First(); // Act
Assert.AreEqual(HttpMethod.Post, callback.HttpMethod); var response = await client.SendTextMessage(_request, TestContext.CancellationToken);
Assert.AreEqual("https://localhost/rest/smsmessaging/text", callback.Url);
Assert.AreEqual(@"{""clientMessageId"":""myCustomId"",""contentCategory"":""informational"",""maxSmsPerMessage"":1,""messageContent"":""example message content"",""messageType"":""voice"",""notificationCallbackUrl"":""https://user:pass@example.com/callback/"",""priority"":5,""recipientAddressList"":[""436991234567""],""sendAsFlashSms"":false,""senderAddress"":""4369912345678"",""senderAddressType"":""international"",""test"":false,""validityPeriode"":300}", callback.Content); // Assert
Assert.IsNotNull(response);
Assert.HasCount(3, callback.Headers);
Assert.IsTrue(callback.Headers.ContainsKey("Accept")); Assert.AreEqual("myCustomId", response.ClientMessageId);
Assert.IsTrue(callback.Headers.ContainsKey("Authorization")); Assert.AreEqual(1, response.SmsCount);
Assert.IsTrue(callback.Headers.ContainsKey("User-Agent")); Assert.AreEqual(StatusCodes.SmsDisabled, response.StatusCode);
Assert.AreEqual("SMS_DISABLED", response.StatusMessage);
Assert.AreEqual("application/json", callback.Headers["Accept"]); Assert.AreEqual("0059d0b20100a0a8b803", response.TransferId);
Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]); Assert.HasCount(1, _httpMessageHandlerMock.RequestCallbacks);
_httpMessageHandlerMock.Mock var callback = _httpMessageHandlerMock.RequestCallbacks.First();
.Protected() Assert.AreEqual(HttpMethod.Post, callback.HttpMethod);
.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()); Assert.AreEqual("https://localhost/rest/smsmessaging/text", callback.Url);
Assert.AreEqual(@"{""clientMessageId"":""myCustomId"",""contentCategory"":""informational"",""maxSmsPerMessage"":1,""messageContent"":""example message content"",""messageType"":""voice"",""notificationCallbackUrl"":""https://user:pass@example.com/callback/"",""priority"":5,""recipientAddressList"":[""436991234567""],""sendAsFlashSms"":false,""senderAddress"":""4369912345678"",""senderAddressType"":""international"",""test"":false,""validityPeriode"":300}", callback.Content);
_clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls(); Assert.HasCount(3, callback.Headers);
} Assert.IsTrue(callback.Headers.ContainsKey("Accept"));
Assert.IsTrue(callback.Headers.ContainsKey("Authorization"));
[TestMethod] Assert.IsTrue(callback.Headers.ContainsKey("User-Agent"));
public void ShouldThrowOnNullRequest()
{ Assert.AreEqual("application/json", callback.Headers["Accept"]);
// Arrange Assert.AreEqual("Scheme Parameter", callback.Headers["Authorization"]);
var client = GetClient(); Assert.AreEqual("LinkMobilityClient/1.0.0", callback.Headers["User-Agent"]);
// Act & Assert _httpMessageHandlerMock.Protected.Verify("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
var ex = Assert.ThrowsExactly<ArgumentNullException>(() => client.SendTextMessage(null, TestContext.CancellationToken));
Assert.AreEqual("request", ex.ParamName); _clientOptionsMock.VerifyGet(o => o.DefaultQueryParams, Times.Once);
VerifyNoOtherCalls();
VerifyNoOtherCalls(); }
}
[TestMethod]
[TestMethod] public void ShouldThrowOnNullRequest()
[DataRow(null)] {
[DataRow("")] // Arrange
[DataRow(" ")] var client = GetClient();
public void ShouldThrowOnMissingMessage(string message)
{ // Act & Assert
// Arrange var ex = Assert.ThrowsExactly<ArgumentNullException>(() => client.SendTextMessage(null, TestContext.CancellationToken));
var req = new SendTextMessageRequest(message, ["4791234567"]); Assert.AreEqual("request", ex.ParamName);
var client = GetClient();
VerifyNoOtherCalls();
// Act & Assert }
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
Assert.AreEqual("MessageContent", ex.ParamName); [TestMethod]
[DataRow(null)]
VerifyNoOtherCalls(); [DataRow("")]
} [DataRow(" ")]
public void ShouldThrowOnMissingMessage(string message)
[TestMethod] {
public void ShouldThrowOnNoRecipients() // Arrange
{ var req = new SendTextMessageRequest(message, ["4791234567"]);
// Arrange var client = GetClient();
var req = new SendTextMessageRequest("Hello", []);
var client = GetClient(); // Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
// Act & Assert Assert.AreEqual("MessageContent", ex.ParamName);
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
Assert.AreEqual("RecipientAddressList", ex.ParamName); VerifyNoOtherCalls();
}
VerifyNoOtherCalls();
} [TestMethod]
public void ShouldThrowOnNoRecipients()
[TestMethod] {
[DataRow(null)] // Arrange
[DataRow("")] var req = new SendTextMessageRequest("Hello", []);
[DataRow(" ")] var client = GetClient();
[DataRow("invalid-recipient")]
public void ShouldThrowOnInvalidRecipient(string recipient) // Act & Assert
{ var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
// Arrange Assert.AreEqual("recipientAddressList", ex.ParamName);
var client = GetClient();
var req = new SendTextMessageRequest("Hello", ["4791234567", recipient]); VerifyNoOtherCalls();
}
// Act & Assert
var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken)); [TestMethod]
[DataRow(null)]
Assert.AreEqual("RecipientAddressList", ex.ParamName); [DataRow("")]
Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message); [DataRow(" ")]
[DataRow("invalid-recipient")]
VerifyNoOtherCalls(); public void ShouldThrowOnInvalidRecipient(string recipient)
} {
// Arrange
private void VerifyNoOtherCalls() var client = GetClient();
{ var req = new SendTextMessageRequest("Hello", ["4791234567", recipient]);
_authenticationMock.VerifyNoOtherCalls();
_clientOptionsMock.VerifyNoOtherCalls(); // Act & Assert
_httpMessageHandlerMock.Mock.VerifyNoOtherCalls(); var ex = Assert.ThrowsExactly<ArgumentException>(() => client.SendTextMessage(req, TestContext.CancellationToken));
}
Assert.AreEqual("recipientAddressList", ex.ParamName);
private ILinkMobilityClient GetClient() Assert.StartsWith($"Recipient address '{recipient}' is not a valid MSISDN format.", ex.Message);
{
var client = new LinkMobilityClient(_authenticationMock.Object, _clientOptionsMock.Object); VerifyNoOtherCalls();
}
var httpClient = new HttpClient(_httpMessageHandlerMock.Mock.Object)
{ private void VerifyNoOtherCalls()
Timeout = _clientOptionsMock.Object.Timeout, {
BaseAddress = new Uri(_clientOptionsMock.Object.BaseUrl) _authenticationMock.VerifyNoOtherCalls();
}; _clientOptionsMock.VerifyNoOtherCalls();
_httpMessageHandlerMock.Mock.VerifyNoOtherCalls();
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LinkMobilityClient", "1.0.0")); }
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
private ILinkMobilityClient GetClient()
_authenticationMock.Object.AddHeader(httpClient); {
var client = new LinkMobilityClient(_authenticationMock.Object, _clientOptionsMock.Object);
_authenticationMock.Invocations.Clear();
_clientOptionsMock.Invocations.Clear(); var httpClient = new HttpClient(_httpMessageHandlerMock.Mock.Object)
{
ReflectionHelper.GetPrivateField<HttpClient>(client, "_httpClient")?.Dispose(); Timeout = _clientOptionsMock.Object.Timeout,
ReflectionHelper.SetPrivateField(client, "_httpClient", httpClient); BaseAddress = new Uri(_clientOptionsMock.Object.BaseUrl)
};
return client;
} 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;
}
}
}

View File

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

View File

@@ -1,90 +1,91 @@
using AMWD.Net.Api.LinkMobility; using AMWD.Net.Api.LinkMobility.Text;
using AMWD.Net.Api.LinkMobility.Webhook.Text;
namespace LinkMobility.Tests.Models
{ namespace LinkMobility.Tests.Webhook.Text
[TestClass] {
public class IncomingMessageNotificationTest [TestClass]
{ public class TextNotificationTest
[TestMethod] {
public void ShouldParseAllPropertiesForTextNotification() [TestMethod]
{ public void ShouldParseAllPropertiesForTextNotification()
// Arrange {
string json = @"{ // Arrange
""messageType"": ""text"", string json = @"{
""notificationId"": ""notif-123"", ""messageType"": ""text"",
""transferId"": ""trans-456"", ""notificationId"": ""notif-123"",
""messageFlashSms"": true, ""transferId"": ""trans-456"",
""senderAddress"": ""436991234567"", ""messageFlashSms"": true,
""senderAddressType"": ""international"", ""senderAddress"": ""436991234567"",
""recipientAddress"": ""066012345678"", ""senderAddressType"": ""international"",
""recipientAddressType"": ""national"", ""recipientAddress"": ""066012345678"",
""textMessageContent"": ""Hello from user"", ""recipientAddressType"": ""national"",
""userDataHeaderPresent"": false, ""textMessageContent"": ""Hello from user"",
""binaryMessageContent"": [""SGVsbG8=""], ""userDataHeaderPresent"": false,
""deliveryReportMessageStatus"": 2, ""binaryMessageContent"": [""SGVsbG8=""],
""sentOn"": ""2025-12-03T12:34:56Z"", ""deliveryReportMessageStatus"": 2,
""deliveredOn"": ""2025-12-03T12:35:30Z"", ""sentOn"": ""2025-12-03T12:34:56Z"",
""deliveredAs"": 1, ""deliveredOn"": ""2025-12-03T12:35:30Z"",
""clientMessageId"": ""client-789"" ""deliveredAs"": 1,
}"; ""clientMessageId"": ""client-789""
}";
// Act
bool successful = IncomingMessageNotification.TryParse(json, out var notification); // Act
bool successful = TextNotification.TryParse(json, out var notification);
// Assert
Assert.IsTrue(successful, "TryParse should return true for valid json"); // Assert
Assert.IsNotNull(notification); Assert.IsTrue(successful, "TryParse should return true for valid json");
Assert.IsNotNull(notification);
Assert.AreEqual(IncomingMessageNotification.Type.Text, notification.MessageType);
Assert.AreEqual("notif-123", notification.NotificationId); Assert.AreEqual(TextMessageType.Text, notification.MessageType);
Assert.AreEqual("trans-456", notification.TransferId); Assert.AreEqual("notif-123", notification.NotificationId);
Assert.AreEqual("trans-456", notification.TransferId);
Assert.IsTrue(notification.MessageFlashSms.HasValue && notification.MessageFlashSms.Value);
Assert.AreEqual("436991234567", notification.SenderAddress); Assert.IsTrue(notification.MessageFlashSms.HasValue && notification.MessageFlashSms.Value);
Assert.IsTrue(notification.SenderAddressType.HasValue); Assert.AreEqual("436991234567", notification.SenderAddress);
Assert.AreEqual(AddressType.International, notification.SenderAddressType.Value); Assert.IsTrue(notification.SenderAddressType.HasValue);
Assert.AreEqual(AddressType.International, notification.SenderAddressType.Value);
Assert.AreEqual("066012345678", notification.RecipientAddress);
Assert.IsTrue(notification.RecipientAddressType.HasValue); Assert.AreEqual("066012345678", notification.RecipientAddress);
Assert.AreEqual(AddressType.National, notification.RecipientAddressType.Value); Assert.IsTrue(notification.RecipientAddressType.HasValue);
Assert.AreEqual(AddressType.National, notification.RecipientAddressType.Value);
Assert.AreEqual("Hello from user", notification.TextMessageContent);
Assert.IsTrue(notification.UserDataHeaderPresent.HasValue && !notification.UserDataHeaderPresent.Value); Assert.AreEqual("Hello from user", notification.TextMessageContent);
Assert.IsTrue(notification.UserDataHeaderPresent.HasValue && !notification.UserDataHeaderPresent.Value);
Assert.IsNotNull(notification.BinaryMessageContent);
CollectionAssert.AreEqual(new List<string> { "SGVsbG8=" }, new List<string>(notification.BinaryMessageContent)); Assert.IsNotNull(notification.BinaryMessageContent);
CollectionAssert.AreEqual(new List<string> { "SGVsbG8=" }, new List<string>(notification.BinaryMessageContent));
// delivery status and deliveredAs are numeric in the test json: assert underlying integral values
Assert.IsTrue(notification.DeliveryReportMessageStatus.HasValue); // delivery status and deliveredAs are numeric in the test json: assert underlying integral values
Assert.AreEqual(2, (int)notification.DeliveryReportMessageStatus.Value); Assert.IsTrue(notification.DeliveryReportMessageStatus.HasValue);
Assert.AreEqual(2, (int)notification.DeliveryReportMessageStatus.Value);
Assert.IsTrue(notification.SentOn.HasValue);
Assert.IsTrue(notification.DeliveredOn.HasValue); Assert.IsTrue(notification.SentOn.HasValue);
Assert.IsTrue(notification.DeliveredOn.HasValue);
// Compare instants in UTC
var expectedSent = DateTime.Parse("2025-12-03T12:34:56Z").ToUniversalTime(); // Compare instants in UTC
var expectedDelivered = DateTime.Parse("2025-12-03T12:35:30Z").ToUniversalTime(); var expectedSent = DateTime.Parse("2025-12-03T12:34:56Z").ToUniversalTime();
Assert.AreEqual(expectedSent, notification.SentOn.Value.ToUniversalTime()); var expectedDelivered = DateTime.Parse("2025-12-03T12:35:30Z").ToUniversalTime();
Assert.AreEqual(expectedDelivered, notification.DeliveredOn.Value.ToUniversalTime()); Assert.AreEqual(expectedSent, notification.SentOn.Value.ToUniversalTime());
Assert.AreEqual(expectedDelivered, notification.DeliveredOn.Value.ToUniversalTime());
Assert.IsTrue(notification.DeliveredAs.HasValue);
Assert.AreEqual(1, (int)notification.DeliveredAs.Value); Assert.IsTrue(notification.DeliveredAs.HasValue);
Assert.AreEqual(1, (int)notification.DeliveredAs.Value);
Assert.AreEqual("client-789", notification.ClientMessageId);
} Assert.AreEqual("client-789", notification.ClientMessageId);
}
[TestMethod]
public void TryParseShouldReturnFalseOnInvalidJson() [TestMethod]
{ public void TryParseShouldReturnFalseOnInvalidJson()
// Arrange {
string invalid = "this is not json"; // Arrange
string invalid = "this is not json";
// Act
bool successful = IncomingMessageNotification.TryParse(invalid, out var notification); // Act
bool successful = TextNotification.TryParse(invalid, out var notification);
// Assert
Assert.IsFalse(successful); // Assert
Assert.IsNull(notification); Assert.IsFalse(successful);
} Assert.IsNull(notification);
} }
} }
}

View File

@@ -0,0 +1,155 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,202 @@
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;
}
}
}