diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a1c81b..fe51272 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,15 @@ on: push: branches: - main + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - build: + test: + name: Build, test, and pack runs-on: ubuntu-latest steps: @@ -20,10 +26,32 @@ jobs: dotnet-version: 8.0.x - name: Restore - run: dotnet restore + run: dotnet restore Termii.SDK.sln - name: Build - run: dotnet build --configuration Release --no-restore + run: dotnet build Termii.SDK.sln --configuration Release --no-restore + + - name: Unit tests + run: dotnet test tests/Termii.Tests/Termii.Tests.csproj --configuration Release --no-build --logger "trx;LogFileName=unit-tests.trx" + + - name: Integration tests + run: dotnet test tests/Termii.IntegrationTests/Termii.IntegrationTests.csproj --configuration Release --no-build --logger "trx;LogFileName=integration-tests.trx" + + - name: Pack + run: dotnet pack src/Termii/Termii.csproj --configuration Release --no-build --output artifacts/packages - - name: Test - run: dotnet test --configuration Release --no-build + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: | + **/TestResults/*.trx + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: | + artifacts/packages/*.nupkg + artifacts/packages/*.snupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..97d6a96 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + publish: + description: Publish to NuGet when NUGET_API_KEY is configured. + required: true + default: false + type: boolean + +permissions: + contents: read + +jobs: + package: + name: Build, test, pack, and publish + runs-on: ubuntu-latest + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore Termii.SDK.sln + + - name: Build + run: dotnet build Termii.SDK.sln --configuration Release --no-restore + + - name: Unit tests + run: dotnet test tests/Termii.Tests/Termii.Tests.csproj --configuration Release --no-build --logger "trx;LogFileName=unit-tests.trx" + + - name: Integration tests + run: dotnet test tests/Termii.IntegrationTests/Termii.IntegrationTests.csproj --configuration Release --no-build --logger "trx;LogFileName=integration-tests.trx" + + - name: Pack + run: dotnet pack src/Termii/Termii.csproj --configuration Release --no-build --output artifacts/packages + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: | + artifacts/packages/*.nupkg + artifacts/packages/*.snupkg + + - name: Publish to NuGet + if: ${{ (github.event_name == 'push' || inputs.publish) && env.NUGET_API_KEY != '' }} + run: dotnet nuget push "artifacts/packages/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate diff --git a/.gitignore b/.gitignore index eb36e42..203e37c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ obj/ .vs/ .vscode/ .idea/ +.DS_Store TestResults/ coverage/ *.user diff --git a/Directory.Build.props b/Directory.Build.props index bdc37b5..3c476b7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,6 +8,13 @@ git https://github.com/teesofttech/Termii.SDK MIT + README.md + false + true + true + true + snupkg + true Teesoft Tech Teesoft Tech diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4015f4d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Teesoft Tech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3e02b29..2684b68 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,342 @@ # Termii .NET SDK -A .NET SDK for the Termii messaging, token, and insights APIs. +[![CI](https://github.com/teesofttech/Termii.SDK/actions/workflows/ci.yml/badge.svg)](https://github.com/teesofttech/Termii.SDK/actions/workflows/ci.yml) -> This SDK is in early development. The first milestones establish the client, tests, examples, API coverage matrix, and package structure before endpoint support is added feature by feature. +A .NET SDK for the Termii messaging, sender ID, Number API, Token API, and Insights APIs. -## Current Status +## Installation -The initial repository setup includes: +Install from NuGet: -- A `Termii` SDK project targeting `netstandard2.0` and `net8.0`. -- A lightweight unit test project. -- An examples project for developer-facing usage. -- An API coverage matrix in `docs/API_COVERAGE.md`. +```bash +dotnet add package Termii.SDK +``` -## Usage +Then import the SDK namespace: ```csharp using Termii; +``` + +## Compatibility + +The SDK targets `netstandard2.0` and `net8.0`. + +- .NET Core 3.1 applications use the `netstandard2.0` asset. +- .NET 5, .NET 6, .NET 7, .NET 8, .NET 9, and .NET 10 applications can consume the package. +- Modern .NET applications use the most compatible asset NuGet selects for the application target. +## Configuration + +Create a client manually: + +```csharp var client = new TermiiClient(new TermiiOptions { ApiKey = "your-termii-api-key" }); ``` -The default base URL is `https://api.ng.termii.com`. +The default base URL is `https://api.ng.termii.com`. Override it only when your Termii account or environment requires a different base URL: + +```csharp +var client = new TermiiClient(new TermiiOptions +{ + ApiKey = "your-termii-api-key", + BaseUrl = new Uri("https://api.ng.termii.com"), + Timeout = TimeSpan.FromSeconds(30) +}); +``` + +For ASP.NET Core applications, register `TermiiClient` with dependency injection: + +```csharp +builder.Services.AddTermii(options => +{ + options.ApiKey = builder.Configuration["Termii:ApiKey"]!; +}); +``` + +Use it from a service or endpoint: + +```csharp +public sealed class NotificationService +{ + private readonly TermiiClient _termii; + + public NotificationService(TermiiClient termii) + { + _termii = termii; + } + + public Task SendAsync(string phoneNumber, string message) + { + return _termii.Messaging.SendAsync(new SendMessageRequest + { + To = phoneNumber, + From = "Termii", + Sms = message, + Channel = TermiiMessageChannel.Generic, + Type = TermiiMessageType.Plain + }); + } +} +``` + +## Messaging + +Send a single SMS: + +```csharp +var response = await client.Messaging.SendAsync(new SendMessageRequest +{ + To = "2348012345678", + From = "Termii", + Sms = "Hello from .NET", + Channel = TermiiMessageChannel.Generic, + Type = TermiiMessageType.Plain +}); +``` + +Send a bulk SMS: + +```csharp +var response = await client.Messaging.SendBulkAsync(new SendBulkMessageRequest +{ + To = new[] { "2348012345678", "2348098765432" }, + From = "Termii", + Sms = "Hello everyone", + Channel = TermiiMessageChannel.Generic, + Type = TermiiMessageType.Plain +}); +``` + +Send through the Number API: + +```csharp +var response = await client.Numbers.SendAsync(new SendNumberMessageRequest +{ + To = "2348012345678", + Sms = "Hello from a dedicated Termii number" +}); +``` + +Send a WhatsApp template message: + +```csharp +var template = await client.Messaging.SendWhatsAppTemplateAsync(new SendWhatsAppTemplateRequest +{ + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-123", + Language = "en" +}); +``` + +## Sender IDs + +Fetch sender IDs: + +```csharp +var senderIds = await client.SenderIds.GetAsync(); +``` + +Request a sender ID: + +```csharp +var request = await client.SenderIds.RequestAsync(new RequestSenderIdRequest +{ + SenderId = "Termii", + UseCase = "Transactional alerts", + Company = "Example Ltd" +}); +``` + +## Tokens and OTP + +Send an OTP token: + +```csharp +var token = await client.Tokens.SendAsync(new SendTokenRequest +{ + To = "2348012345678", + From = "Termii", + Channel = TermiiMessageChannel.Generic, + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6, + PinPlaceholder = "< 123456 >", + MessageText = "Your verification code is < 123456 >" +}); +``` + +Verify an OTP token: + +```csharp +var verification = await client.Tokens.VerifyAsync(new VerifyTokenRequest +{ + PinId = token.PinId!, + Pin = "123456" +}); +``` + +Generate an in-app OTP: + +```csharp +var generated = await client.Tokens.GenerateAsync(new GenerateTokenRequest +{ + PhoneNumber = "2348012345678", + PinType = TermiiTokenPinType.Numeric, + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6 +}); +``` + +The token client also supports voice OTP, voice call OTP, email OTP, and WhatsApp OTP through `SendVoiceAsync`, `CallAsync`, `SendEmailAsync`, and `SendWhatsAppAsync`. + +## Insights + +Check account balance: + +```csharp +var balance = await client.Insights.GetBalanceAsync(); +``` + +Check DND status: + +```csharp +var dnd = await client.Insights.CheckDndAsync(new CheckDndRequest +{ + PhoneNumber = "2348012345678" +}); +``` + +Query number intelligence: + +```csharp +var number = await client.Insights.QueryNumberAsync(new QueryNumberRequest +{ + PhoneNumber = "2348012345678", + CountryCode = "NG" +}); +``` + +Fetch message history: + +```csharp +var history = await client.Insights.GetMessageHistoryAsync(new GetMessageHistoryRequest +{ + Page = 0, + Size = 15, + Sender = "Termii", + Receiver = "2348012345678" +}); +``` + +Fetch message analytics: + +```csharp +var analytics = await client.Insights.GetMessageAnalyticsAsync(new GetMessageAnalyticsRequest +{ + PhoneNumber = "2348012345678", + DateFrom = "2026-06-01", + DateTo = "2026-06-13" +}); +``` + +## Webhooks + +Termii can send delivery/report callbacks to an endpoint you own. The SDK includes receiver-side models that can be used with ASP.NET Core model binding: + +```csharp +app.MapPost("/webhooks/termii", (TermiiWebhookEvent webhookEvent) => +{ + if (webhookEvent.Status == "delivered") + { + Console.WriteLine($"Delivered message {webhookEvent.MessageId}"); + } + + return Results.Ok(); +}); +``` + +Webhook payloads can vary by event type and Termii account configuration. Unknown fields are preserved in `TermiiWebhookEvent.AdditionalData`. + +## Campaigns + +Fetch campaign phonebooks: + +```csharp +var phonebooks = await client.Campaigns.GetPhonebooksAsync(new GetPhonebooksRequest +{ + Page = 0, + Size = 15 +}); +``` + +Create a phonebook: + +```csharp +var phonebook = await client.Campaigns.CreatePhonebookAsync(new CreatePhonebookRequest +{ + PhonebookName = "Customers", + Description = "Customer contacts" +}); +``` + +## Product Emails + +Send a product notification email: + +```csharp +var email = await client.Emails.SendProductEmailAsync(new SendProductEmailRequest +{ + EmailAddress = "person@example.com", + TemplateId = "template-123", + Subject = "Order update" +}); +``` + +## Error Handling + +The SDK throws `TermiiApiException` for non-success HTTP responses from Termii: + +```csharp +try +{ + await client.Messaging.SendAsync(new SendMessageRequest + { + To = "2348012345678", + From = "Termii", + Sms = "Hello from .NET", + Channel = TermiiMessageChannel.Generic + }); +} +catch (TermiiApiException ex) +{ + Console.WriteLine($"HTTP status: {(int)ex.StatusCode}"); + Console.WriteLine($"Termii code: {ex.TermiiCode}"); + Console.WriteLine($"Termii message: {ex.TermiiMessage}"); +} +``` + +Request models also validate required fields before sending. Missing required values throw `ArgumentException`, and invalid numeric ranges throw `ArgumentOutOfRangeException`. + +## Supported APIs + +Implemented in the current SDK: + +- Messaging: send single, WhatsApp conversational, and bulk messages. +- WhatsApp templates: send template messages with or without media. +- Sender IDs: list and request sender IDs. +- Number API: send message through a dedicated Termii number. +- Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows. +- Insights: balance, DND status, number intelligence, message history, and message analytics. +- Campaigns: list, create, update, and delete phonebooks. +- Product emails: send template-based notification emails. + +See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. ## Examples @@ -35,6 +347,21 @@ export TERMII_API_KEY="your-termii-api-key" dotnet run --project examples/Termii.Examples ``` +Run a live balance example: + +```bash +export TERMII_EXAMPLE_ACTION="balance" +dotnet run --project examples/Termii.Examples +``` + +Send a live Number API example message: + +```bash +export TERMII_EXAMPLE_ACTION="number-message" +export TERMII_EXAMPLE_PHONE_NUMBER="2348012345678" +dotnet run --project examples/Termii.Examples +``` + ## Tests Run the local test suite: @@ -48,20 +375,20 @@ Integration tests are opt-in and must not call live Termii endpoints unless the ```bash export TERMII_API_KEY="your-termii-api-key" export TERMII_BASE_URL="https://api.ng.termii.com" +export TERMII_TEST_PHONE_NUMBER="2348012345678" dotnet test tests/Termii.IntegrationTests ``` -As endpoint support is added, integration tests should remain credential-gated so normal CI can run without network access or Termii credits. +## Release + +Releases are published from version tags through GitHub Actions. See [docs/RELEASING.md](docs/RELEASING.md) for the checklist. -## Roadmap +## Links -Development is tracked through GitHub issues: +- Termii developer docs: https://developer.termii.com/ +- NuGet package: https://www.nuget.org/packages/Termii.SDK +- API coverage: [docs/API_COVERAGE.md](docs/API_COVERAGE.md) -- SDK foundation and HTTP pipeline. -- Messaging APIs. -- Sender ID and number APIs. -- Token/OTP APIs. -- Insights APIs. -- CI, documentation, NuGet packaging, and GitHub Releases. +## License -Official Termii documentation: https://developer.termii.com/ +This project is licensed under the MIT License. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 44234db..a74600d 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -1,21 +1,93 @@ # Termii API Coverage -This matrix tracks the Termii API surface against SDK implementation work. - -| Area | Capability | SDK status | Issue | -| --- | --- | --- | --- | -| Foundation | Solution, projects, examples, basic tests | In progress | #1 | -| Foundation | Client configuration, authentication, HTTP pipeline | Planned | #2 | -| Foundation | Error handling and validation | Planned | #6 | -| Messaging | Send SMS and channel messages | Planned | #7 | -| Messaging | Sender ID APIs | Planned | #3 | -| Messaging | Number API messaging | Planned | #5 | -| Token | Send, verify, and generate OTP | Planned | #4 | -| Insights | Balance, DND, number status, message history | Planned | #9 | -| Release | NuGet package metadata and publishing workflow | Planned | #8 | -| Release | GitHub Release and NuGet publication | Planned | #13 | - -References: +This document tracks the Termii API surface against SDK implementation work. It should be updated in the same PR that adds or changes endpoint support. + +## Sources - Termii developer docs: https://developer.termii.com/ -- Postman collection: https://termii.s3.us-west-1.amazonaws.com/upload/files/UozvGXj5czYEeY4OmE2f.json +- Termii Messaging docs: https://developer.termii.com/messaging +- Termii Token docs: https://developer.termii.com/token +- Termii Insights docs: https://developer.termii.com/insights +- Termii Postman collection: https://termii.s3.us-west-1.amazonaws.com/upload/files/UozvGXj5czYEeY4OmE2f.json + +The Termii docs describe a REST/JSON API and state that each account has its own base URL. The current documentation pages list an update date of April 14, 2026 for the main product areas. + +## Status Legend + +| Status | Meaning | +| --- | --- | +| Implemented | SDK behavior exists, has tests, and is documented. | +| In progress | Work is actively represented by an open issue or PR. | +| Planned | Endpoint is in scope but not implemented yet. | +| Deferred | Endpoint exists in Termii docs, but is outside the first SDK milestone. | +| Needs verification | Endpoint appears in one source but needs confirmation against docs or live behavior before implementation. | + +## Foundation Coverage + +| Capability | SDK status | Tracking | +| --- | --- | --- | +| Solution, SDK project, unit tests, integration test shell, examples project | Implemented | #1, PR #15 | +| Client configuration, `HttpClient` pipeline, API-key injection, DI registration | Implemented | #2, PR #17 | +| API coverage matrix | Implemented | #14, PR #18 | +| Error handling and request validation | Implemented | #6, PR #19 | +| Reusable fake HTTP test helpers and opt-in live test conventions | Implemented | #10, PR #20 | +| README endpoint examples | In progress | #12 | +| CI build/test/package validation | Implemented | #11, PR #22 | +| NuGet package metadata and publishing workflow | Implemented | #8, PR #23 | +| First GitHub Release and NuGet publish | Implemented | #13, PR #28, v0.2.0 | + +## Endpoint Coverage + +| Area | Capability | Method | Path | Auth placement | SDK surface | SDK status | Tracking | Test status | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | Implemented | #3, PR #24 | Unit tests added | +| Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | Implemented | #3, PR #24 | Unit tests added | +| Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | +| Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | +| Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | +| Messaging | Send WhatsApp template without media | POST | `/api/send/template` | JSON body | `TermiiClient.Messaging` | In progress | #30 | Unit tests added | +| Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | JSON body | `TermiiClient.Messaging` | In progress | #30 | Unit tests added | +| Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | Implemented | #5, PR #25 | Unit tests added | +| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send voice OTP | POST | `/api/sms/otp/send/voice` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send voice call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Insights | Get balance | GET | `/api/get-balance` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | +| Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | +| Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | +| Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | +| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Implemented | #34, PR #35 | Unit tests added | +| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | Implemented | #31, PR #39 | Unit tests added | + +## Receiver-Side Coverage + +The following SDK support is not an outbound Termii API call. + +| Area | Capability | Method | Path | Status | Notes | +| --- | --- | --- | --- | --- | --- | +| Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. | + +## Postman Collection Reconciliation + +The provided Postman collection currently lists these groups: + +| Collection group | Requests observed | Coverage decision | +| --- | --- | --- | +| Switch | SenderId, Numbers, Send | Covered by #3, #5, and #7. | +| Token | Send Token, Verify Token, In-App Token | Covered by #4. | +| Insight | history analytics, Search, Balance, Inbox API | Covered by #9 and #34. | + +## Implementation Rules + +- Every implemented endpoint should have request-building unit tests using fake HTTP handlers. +- Live integration tests must be opt-in and gated by environment variables. +- Public models should be typed, nullable-aware, and tolerant of undocumented optional response fields. +- Endpoints that require account activation, WhatsApp devices, sender approval, or spendable credits should not run in CI by default. +- README examples should be added or updated with each endpoint group. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..c1cc6a0 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,27 @@ +# Releasing + +This project publishes the SDK as a NuGet package from version tags. + +## Versioning + +- Use semantic versioning. +- Keep `VersionPrefix` in `src/Termii/Termii.csproj` aligned with the release tag. +- Use preview suffixes for pre-release packages, for example `0.1.0-preview.1`. + +## Release Checklist + +1. Confirm all intended issues for the release are merged. +2. Confirm CI is green on `main`. +3. Update package metadata and README examples if needed. +4. Update `VersionPrefix` and `PackageReleaseNotes` in `src/Termii/Termii.csproj`. +5. Create and push a tag like `v0.2.0`. +6. Confirm the release workflow creates the package artifact. +7. Confirm NuGet publishing succeeds when the `NUGET_API_KEY` secret is configured. + +## Required Secrets + +| Secret | Purpose | +| --- | --- | +| `NUGET_API_KEY` | API key used by the release workflow to publish to NuGet.org. | + +The release workflow skips the publish step when `NUGET_API_KEY` is not set, but still builds, tests, packs, and uploads the package artifact. diff --git a/examples/Termii.Examples/Program.cs b/examples/Termii.Examples/Program.cs index 31b4108..d08e950 100644 --- a/examples/Termii.Examples/Program.cs +++ b/examples/Termii.Examples/Program.cs @@ -14,3 +14,34 @@ }); Console.WriteLine($"Termii client configured for {client.Options.BaseUrl}."); + +var action = Environment.GetEnvironmentVariable("TERMII_EXAMPLE_ACTION"); +var phoneNumber = Environment.GetEnvironmentVariable("TERMII_EXAMPLE_PHONE_NUMBER"); + +if (string.Equals(action, "balance", StringComparison.OrdinalIgnoreCase)) +{ + var balance = await client.Insights.GetBalanceAsync(); + + Console.WriteLine($"Balance: {balance.Balance} {balance.Currency}".Trim()); + return; +} + +if (string.Equals(action, "number-message", StringComparison.OrdinalIgnoreCase)) +{ + if (string.IsNullOrWhiteSpace(phoneNumber)) + { + Console.WriteLine("Set TERMII_EXAMPLE_PHONE_NUMBER to send a Number API message."); + return; + } + + var response = await client.Numbers.SendAsync(new SendNumberMessageRequest + { + To = phoneNumber, + Sms = "Hello from Termii.SDK", + }); + + Console.WriteLine($"Number API response: {response.Message ?? response.Code ?? "sent"}"); + return; +} + +Console.WriteLine("Set TERMII_EXAMPLE_ACTION=balance or TERMII_EXAMPLE_ACTION=number-message to run a live example."); diff --git a/src/Termii/Campaigns/CreatePhonebookRequest.cs b/src/Termii/Campaigns/CreatePhonebookRequest.cs new file mode 100644 index 0000000..42e4b92 --- /dev/null +++ b/src/Termii/Campaigns/CreatePhonebookRequest.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class CreatePhonebookRequest +{ + [JsonPropertyName("phonebook_name")] + public string PhonebookName { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(PhonebookName, nameof(PhonebookName)); + } +} diff --git a/src/Termii/Campaigns/GetPhonebooksRequest.cs b/src/Termii/Campaigns/GetPhonebooksRequest.cs new file mode 100644 index 0000000..68dea34 --- /dev/null +++ b/src/Termii/Campaigns/GetPhonebooksRequest.cs @@ -0,0 +1,20 @@ +namespace Termii; + +public sealed class GetPhonebooksRequest +{ + public int? Page { get; set; } + + public int? Size { get; set; } + + public string? Name { get; set; } + + internal string ToPath() + { + var query = new List(); + TermiiCampaignQueryString.AddIfPresent(query, "page", Page); + TermiiCampaignQueryString.AddIfPresent(query, "size", Size); + TermiiCampaignQueryString.AddIfPresent(query, "name", Name); + + return TermiiCampaignQueryString.Append("/api/phonebooks", query); + } +} diff --git a/src/Termii/Campaigns/ITermiiCampaignClient.cs b/src/Termii/Campaigns/ITermiiCampaignClient.cs new file mode 100644 index 0000000..b70e29d --- /dev/null +++ b/src/Termii/Campaigns/ITermiiCampaignClient.cs @@ -0,0 +1,21 @@ +namespace Termii; + +public interface ITermiiCampaignClient +{ + Task GetPhonebooksAsync( + GetPhonebooksRequest? request = null, + CancellationToken cancellationToken = default); + + Task CreatePhonebookAsync( + CreatePhonebookRequest request, + CancellationToken cancellationToken = default); + + Task UpdatePhonebookAsync( + string phonebookId, + UpdatePhonebookRequest request, + CancellationToken cancellationToken = default); + + Task DeletePhonebookAsync( + string phonebookId, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Campaigns/PhonebookListResponse.cs b/src/Termii/Campaigns/PhonebookListResponse.cs new file mode 100644 index 0000000..af4770c --- /dev/null +++ b/src/Termii/Campaigns/PhonebookListResponse.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookListResponse +{ + [JsonPropertyName("content")] + public IReadOnlyList Content { get; set; } = Array.Empty(); + + [JsonPropertyName("data")] + public IReadOnlyList? Data { get; set; } + + [JsonPropertyName("totalElements")] + public int? TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int? TotalPages { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/PhonebookOperationResponse.cs b/src/Termii/Campaigns/PhonebookOperationResponse.cs new file mode 100644 index 0000000..1336c13 --- /dev/null +++ b/src/Termii/Campaigns/PhonebookOperationResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookOperationResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/PhonebookRecord.cs b/src/Termii/Campaigns/PhonebookRecord.cs new file mode 100644 index 0000000..f13bc31 --- /dev/null +++ b/src/Termii/Campaigns/PhonebookRecord.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookRecord +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("phonebook_id")] + public string? PhonebookId { get; set; } + + [JsonPropertyName("phonebook_name")] + public string? PhonebookName { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("total_contacts")] + public int? TotalContacts { get; set; } + + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/PhonebookResponse.cs b/src/Termii/Campaigns/PhonebookResponse.cs new file mode 100644 index 0000000..6ca11ee --- /dev/null +++ b/src/Termii/Campaigns/PhonebookResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("data")] + public PhonebookRecord? Data { get; set; } + + [JsonPropertyName("phonebook")] + public PhonebookRecord? Phonebook { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/TermiiCampaignClient.cs b/src/Termii/Campaigns/TermiiCampaignClient.cs new file mode 100644 index 0000000..275caf5 --- /dev/null +++ b/src/Termii/Campaigns/TermiiCampaignClient.cs @@ -0,0 +1,78 @@ +namespace Termii; + +internal sealed class TermiiCampaignClient : ITermiiCampaignClient +{ + private static readonly HttpMethod Patch = new("PATCH"); + + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiCampaignClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task GetPhonebooksAsync( + GetPhonebooksRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetPhonebooksRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task CreatePhonebookAsync( + CreatePhonebookRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/phonebooks", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task UpdatePhonebookAsync( + string phonebookId, + UpdatePhonebookRequest request, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(phonebookId, nameof(phonebookId)); + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return _pipeline.SendJsonAsync( + Patch, + $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task DeletePhonebookAsync( + string phonebookId, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(phonebookId, nameof(phonebookId)); + + return _pipeline.SendJsonAsync( + HttpMethod.Delete, + $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}", + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } +} diff --git a/src/Termii/Campaigns/TermiiCampaignQueryString.cs b/src/Termii/Campaigns/TermiiCampaignQueryString.cs new file mode 100644 index 0000000..afbbc32 --- /dev/null +++ b/src/Termii/Campaigns/TermiiCampaignQueryString.cs @@ -0,0 +1,27 @@ +namespace Termii; + +internal static class TermiiCampaignQueryString +{ + public static void AddIfPresent(List query, string name, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + query.Add($"{name}={Uri.EscapeDataString(value)}"); + } + } + + public static void AddIfPresent(List query, string name, int? value) + { + if (value.HasValue) + { + query.Add($"{name}={value.Value}"); + } + } + + public static string Append(string path, List query) + { + return query.Count == 0 + ? path + : $"{path}?{string.Join("&", query)}"; + } +} diff --git a/src/Termii/Campaigns/UpdatePhonebookRequest.cs b/src/Termii/Campaigns/UpdatePhonebookRequest.cs new file mode 100644 index 0000000..cf6e7c8 --- /dev/null +++ b/src/Termii/Campaigns/UpdatePhonebookRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class UpdatePhonebookRequest +{ + [JsonPropertyName("phonebook_name")] + public string? PhonebookName { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } +} diff --git a/src/Termii/Emails/ITermiiEmailClient.cs b/src/Termii/Emails/ITermiiEmailClient.cs new file mode 100644 index 0000000..a8ff5bb --- /dev/null +++ b/src/Termii/Emails/ITermiiEmailClient.cs @@ -0,0 +1,8 @@ +namespace Termii; + +public interface ITermiiEmailClient +{ + Task SendProductEmailAsync( + SendProductEmailRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Emails/SendProductEmailRequest.cs b/src/Termii/Emails/SendProductEmailRequest.cs new file mode 100644 index 0000000..842dc94 --- /dev/null +++ b/src/Termii/Emails/SendProductEmailRequest.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendProductEmailRequest +{ + [JsonPropertyName("email_address")] + public string EmailAddress { get; set; } = string.Empty; + + [JsonPropertyName("template_id")] + public string TemplateId { get; set; } = string.Empty; + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("from")] + public string? From { get; set; } + + [JsonPropertyName("sender_name")] + public string? SenderName { get; set; } + + [JsonPropertyName("data")] + public Dictionary? Data { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(EmailAddress, nameof(EmailAddress)); + TermiiRequestValidation.Required(TemplateId, nameof(TemplateId)); + } +} diff --git a/src/Termii/Emails/SendProductEmailResponse.cs b/src/Termii/Emails/SendProductEmailResponse.cs new file mode 100644 index 0000000..5e72a7d --- /dev/null +++ b/src/Termii/Emails/SendProductEmailResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendProductEmailResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Emails/TermiiEmailClient.cs b/src/Termii/Emails/TermiiEmailClient.cs new file mode 100644 index 0000000..b0867c2 --- /dev/null +++ b/src/Termii/Emails/TermiiEmailClient.cs @@ -0,0 +1,30 @@ +namespace Termii; + +internal sealed class TermiiEmailClient : ITermiiEmailClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiEmailClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task SendProductEmailAsync( + SendProductEmailRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/templates/send-email", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } +} diff --git a/src/Termii/Insights/CheckDndRequest.cs b/src/Termii/Insights/CheckDndRequest.cs new file mode 100644 index 0000000..6583920 --- /dev/null +++ b/src/Termii/Insights/CheckDndRequest.cs @@ -0,0 +1,16 @@ +namespace Termii; + +public sealed class CheckDndRequest +{ + public string PhoneNumber { get; set; } = string.Empty; + + internal string ToPath() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber); + + return TermiiInsightsQueryString.Append("/api/check/dnd", query); + } +} diff --git a/src/Termii/Insights/CheckDndResponse.cs b/src/Termii/Insights/CheckDndResponse.cs new file mode 100644 index 0000000..ad683b6 --- /dev/null +++ b/src/Termii/Insights/CheckDndResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class CheckDndResponse +{ + [JsonPropertyName("number")] + public string? Number { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("network")] + public string? Network { get; set; } + + [JsonPropertyName("dnd_active")] + public bool? DndActive { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/GetBalanceResponse.cs b/src/Termii/Insights/GetBalanceResponse.cs new file mode 100644 index 0000000..590a5e0 --- /dev/null +++ b/src/Termii/Insights/GetBalanceResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class GetBalanceResponse +{ + [JsonPropertyName("user")] + public string? User { get; set; } + + [JsonPropertyName("balance")] + public decimal? Balance { get; set; } + + [JsonPropertyName("currency")] + public string? Currency { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/GetMessageAnalyticsRequest.cs b/src/Termii/Insights/GetMessageAnalyticsRequest.cs new file mode 100644 index 0000000..df43302 --- /dev/null +++ b/src/Termii/Insights/GetMessageAnalyticsRequest.cs @@ -0,0 +1,23 @@ +namespace Termii; + +public sealed class GetMessageAnalyticsRequest +{ + public string? MessageId { get; set; } + + public string? DateFrom { get; set; } + + public string? DateTo { get; set; } + + public string? PhoneNumber { get; set; } + + internal string ToPath() + { + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "message_id", MessageId); + TermiiInsightsQueryString.AddIfPresent(query, "date_from", DateFrom); + TermiiInsightsQueryString.AddIfPresent(query, "date_to", DateTo); + TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber); + + return TermiiInsightsQueryString.Append("/api/sms/history/analytics", query); + } +} diff --git a/src/Termii/Insights/GetMessageHistoryRequest.cs b/src/Termii/Insights/GetMessageHistoryRequest.cs new file mode 100644 index 0000000..7ad4778 --- /dev/null +++ b/src/Termii/Insights/GetMessageHistoryRequest.cs @@ -0,0 +1,35 @@ +namespace Termii; + +public sealed class GetMessageHistoryRequest +{ + public int? Page { get; set; } + + public int? Size { get; set; } + + public string? Sender { get; set; } + + public string? Receiver { get; set; } + + public string? MessageId { get; set; } + + public string? Status { get; set; } + + public string? FromDate { get; set; } + + public string? ToDate { get; set; } + + internal string ToPath() + { + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "page", Page); + TermiiInsightsQueryString.AddIfPresent(query, "size", Size); + TermiiInsightsQueryString.AddIfPresent(query, "sender", Sender); + TermiiInsightsQueryString.AddIfPresent(query, "receiver", Receiver); + TermiiInsightsQueryString.AddIfPresent(query, "message_id", MessageId); + TermiiInsightsQueryString.AddIfPresent(query, "status", Status); + TermiiInsightsQueryString.AddIfPresent(query, "from", FromDate); + TermiiInsightsQueryString.AddIfPresent(query, "to", ToDate); + + return TermiiInsightsQueryString.Append("/api/sms/inbox", query); + } +} diff --git a/src/Termii/Insights/ITermiiInsightsClient.cs b/src/Termii/Insights/ITermiiInsightsClient.cs new file mode 100644 index 0000000..52121e8 --- /dev/null +++ b/src/Termii/Insights/ITermiiInsightsClient.cs @@ -0,0 +1,22 @@ +namespace Termii; + +public interface ITermiiInsightsClient +{ + Task GetBalanceAsync(CancellationToken cancellationToken = default); + + Task CheckDndAsync( + CheckDndRequest request, + CancellationToken cancellationToken = default); + + Task QueryNumberAsync( + QueryNumberRequest request, + CancellationToken cancellationToken = default); + + Task GetMessageHistoryAsync( + GetMessageHistoryRequest? request = null, + CancellationToken cancellationToken = default); + + Task GetMessageAnalyticsAsync( + GetMessageAnalyticsRequest? request = null, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Insights/MessageAnalyticsResponse.cs b/src/Termii/Insights/MessageAnalyticsResponse.cs new file mode 100644 index 0000000..baf92f8 --- /dev/null +++ b/src/Termii/Insights/MessageAnalyticsResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class MessageAnalyticsResponse +{ + [JsonPropertyName("sent")] + public int? Sent { get; set; } + + [JsonPropertyName("delivered")] + public int? Delivered { get; set; } + + [JsonPropertyName("failed")] + public int? Failed { get; set; } + + [JsonPropertyName("pending")] + public int? Pending { get; set; } + + [JsonPropertyName("rejected")] + public int? Rejected { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/MessageHistoryRecord.cs b/src/Termii/Insights/MessageHistoryRecord.cs new file mode 100644 index 0000000..878abec --- /dev/null +++ b/src/Termii/Insights/MessageHistoryRecord.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class MessageHistoryRecord +{ + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("sender")] + public string? Sender { get; set; } + + [JsonPropertyName("receiver")] + public string? Receiver { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("amount")] + public decimal? Amount { get; set; } + + [JsonPropertyName("reroute")] + public int? Reroute { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("sms_type")] + public string? SmsType { get; set; } + + [JsonPropertyName("send_by")] + public string? SentBy { get; set; } + + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/MessageHistoryResponse.cs b/src/Termii/Insights/MessageHistoryResponse.cs new file mode 100644 index 0000000..e365ef6 --- /dev/null +++ b/src/Termii/Insights/MessageHistoryResponse.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class MessageHistoryResponse +{ + [JsonPropertyName("content")] + public IReadOnlyList Content { get; set; } = Array.Empty(); + + [JsonPropertyName("totalElements")] + public int? TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int? TotalPages { get; set; } + + [JsonPropertyName("last")] + public bool? Last { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonPropertyName("first")] + public bool? First { get; set; } + + [JsonPropertyName("empty")] + public bool? Empty { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/QueryNumberRequest.cs b/src/Termii/Insights/QueryNumberRequest.cs new file mode 100644 index 0000000..e00f1b1 --- /dev/null +++ b/src/Termii/Insights/QueryNumberRequest.cs @@ -0,0 +1,19 @@ +namespace Termii; + +public sealed class QueryNumberRequest +{ + public string PhoneNumber { get; set; } = string.Empty; + + public string? CountryCode { get; set; } + + internal string ToPath() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber); + TermiiInsightsQueryString.AddIfPresent(query, "country_code", CountryCode); + + return TermiiInsightsQueryString.Append("/api/insight/number/query", query); + } +} diff --git a/src/Termii/Insights/QueryNumberResponse.cs b/src/Termii/Insights/QueryNumberResponse.cs new file mode 100644 index 0000000..a108030 --- /dev/null +++ b/src/Termii/Insights/QueryNumberResponse.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class QueryNumberResponse +{ + [JsonPropertyName("number")] + public string? Number { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("network")] + public string? Network { get; set; } + + [JsonPropertyName("network_code")] + public string? NetworkCode { get; set; } + + [JsonPropertyName("country_code")] + public string? CountryCode { get; set; } + + [JsonPropertyName("country_name")] + public string? CountryName { get; set; } + + [JsonPropertyName("ported")] + public bool? Ported { get; set; } + + [JsonPropertyName("roaming")] + public bool? Roaming { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/TermiiInsightsClient.cs b/src/Termii/Insights/TermiiInsightsClient.cs new file mode 100644 index 0000000..1359550 --- /dev/null +++ b/src/Termii/Insights/TermiiInsightsClient.cs @@ -0,0 +1,79 @@ +namespace Termii; + +internal sealed class TermiiInsightsClient : ITermiiInsightsClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiInsightsClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task GetBalanceAsync(CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + "/api/get-balance", + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task CheckDndAsync( + CheckDndRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return _pipeline.SendJsonAsync( + HttpMethod.Get, + request.ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task QueryNumberAsync( + QueryNumberRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return _pipeline.SendJsonAsync( + HttpMethod.Get, + request.ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task GetMessageHistoryAsync( + GetMessageHistoryRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetMessageHistoryRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task GetMessageAnalyticsAsync( + GetMessageAnalyticsRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetMessageAnalyticsRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } +} diff --git a/src/Termii/Insights/TermiiInsightsQueryString.cs b/src/Termii/Insights/TermiiInsightsQueryString.cs new file mode 100644 index 0000000..b86862c --- /dev/null +++ b/src/Termii/Insights/TermiiInsightsQueryString.cs @@ -0,0 +1,27 @@ +namespace Termii; + +internal static class TermiiInsightsQueryString +{ + public static void AddIfPresent(List query, string name, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + query.Add($"{name}={Uri.EscapeDataString(value)}"); + } + } + + public static void AddIfPresent(List query, string name, int? value) + { + if (value.HasValue) + { + query.Add($"{name}={value.Value}"); + } + } + + public static string Append(string path, List query) + { + return query.Count == 0 + ? path + : $"{path}?{string.Join("&", query)}"; + } +} diff --git a/src/Termii/Messaging/ITermiiMessagingClient.cs b/src/Termii/Messaging/ITermiiMessagingClient.cs new file mode 100644 index 0000000..c990964 --- /dev/null +++ b/src/Termii/Messaging/ITermiiMessagingClient.cs @@ -0,0 +1,20 @@ +namespace Termii; + +public interface ITermiiMessagingClient +{ + Task SendAsync( + SendMessageRequest request, + CancellationToken cancellationToken = default); + + Task SendBulkAsync( + SendBulkMessageRequest request, + CancellationToken cancellationToken = default); + + Task SendWhatsAppTemplateAsync( + SendWhatsAppTemplateRequest request, + CancellationToken cancellationToken = default); + + Task SendWhatsAppTemplateMediaAsync( + SendWhatsAppTemplateMediaRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Messaging/SendBulkMessageRequest.cs b/src/Termii/Messaging/SendBulkMessageRequest.cs new file mode 100644 index 0000000..fe4db96 --- /dev/null +++ b/src/Termii/Messaging/SendBulkMessageRequest.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendBulkMessageRequest +{ + [JsonPropertyName("to")] + public IReadOnlyCollection To { get; set; } = Array.Empty(); + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonPropertyName("sms")] + public string Sms { get; set; } = string.Empty; + + [JsonIgnore] + public TermiiMessageType Type { get; set; } = TermiiMessageType.Plain; + + [JsonIgnore] + public TermiiMessageChannel Channel { get; set; } = TermiiMessageChannel.Generic; + + internal void Validate() + { + if (To is null || To.Count == 0) + { + throw new ArgumentException("At least one recipient phone number is required.", nameof(To)); + } + + foreach (var recipient in To) + { + TermiiRequestValidation.Required(recipient, nameof(To)); + } + + TermiiRequestValidation.Required(From, nameof(From)); + TermiiRequestValidation.Required(Sms, nameof(Sms)); + } +} diff --git a/src/Termii/Messaging/SendMessageRequest.cs b/src/Termii/Messaging/SendMessageRequest.cs new file mode 100644 index 0000000..f4e7bae --- /dev/null +++ b/src/Termii/Messaging/SendMessageRequest.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendMessageRequest +{ + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonPropertyName("sms")] + public string Sms { get; set; } = string.Empty; + + [JsonIgnore] + public TermiiMessageType Type { get; set; } = TermiiMessageType.Plain; + + [JsonIgnore] + public TermiiMessageChannel Channel { get; set; } = TermiiMessageChannel.Generic; + + [JsonPropertyName("media")] + public TermiiMessageMedia? Media { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(To, nameof(To)); + TermiiRequestValidation.Required(From, nameof(From)); + TermiiRequestValidation.Required(Sms, nameof(Sms)); + } +} diff --git a/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs b/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs new file mode 100644 index 0000000..0ec5098 --- /dev/null +++ b/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendWhatsAppTemplateMediaRequest : SendWhatsAppTemplateRequest +{ + [JsonPropertyName("media")] + public WhatsAppTemplateMedia Media { get; set; } = new(); + + internal override void Validate() + { + base.Validate(); + + if (Media is null) + { + throw new ArgumentNullException(nameof(Media)); + } + + Media.Validate(); + } +} diff --git a/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs b/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs new file mode 100644 index 0000000..647fef7 --- /dev/null +++ b/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public class SendWhatsAppTemplateRequest +{ + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("device_id")] + public string DeviceId { get; set; } = string.Empty; + + [JsonPropertyName("template_id")] + public string TemplateId { get; set; } = string.Empty; + + [JsonPropertyName("template_name")] + public string? TemplateName { get; set; } + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("variables")] + public Dictionary? Variables { get; set; } + + [JsonPropertyName("components")] + public IReadOnlyCollection? Components { get; set; } + + internal virtual void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Required(DeviceId, nameof(DeviceId)); + TermiiRequestValidation.Required(TemplateId, nameof(TemplateId)); + } +} diff --git a/src/Termii/Messaging/TermiiMessageChannel.cs b/src/Termii/Messaging/TermiiMessageChannel.cs new file mode 100644 index 0000000..a33bf92 --- /dev/null +++ b/src/Termii/Messaging/TermiiMessageChannel.cs @@ -0,0 +1,9 @@ +namespace Termii; + +public enum TermiiMessageChannel +{ + Generic, + Dnd, + WhatsApp, + Voice, +} diff --git a/src/Termii/Messaging/TermiiMessageMedia.cs b/src/Termii/Messaging/TermiiMessageMedia.cs new file mode 100644 index 0000000..fa36a60 --- /dev/null +++ b/src/Termii/Messaging/TermiiMessageMedia.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class TermiiMessageMedia +{ + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("caption")] + public string? Caption { get; set; } +} diff --git a/src/Termii/Messaging/TermiiMessageResponse.cs b/src/Termii/Messaging/TermiiMessageResponse.cs new file mode 100644 index 0000000..7707115 --- /dev/null +++ b/src/Termii/Messaging/TermiiMessageResponse.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class TermiiMessageResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("balance")] + public decimal? Balance { get; set; } + + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("user")] + public string? User { get; set; } + + [JsonPropertyName("message_id_str")] + public string? MessageIdString { get; set; } +} diff --git a/src/Termii/Messaging/TermiiMessageType.cs b/src/Termii/Messaging/TermiiMessageType.cs new file mode 100644 index 0000000..eefc107 --- /dev/null +++ b/src/Termii/Messaging/TermiiMessageType.cs @@ -0,0 +1,7 @@ +namespace Termii; + +public enum TermiiMessageType +{ + Plain, + Unicode, +} diff --git a/src/Termii/Messaging/TermiiMessagingClient.cs b/src/Termii/Messaging/TermiiMessagingClient.cs new file mode 100644 index 0000000..02f1abb --- /dev/null +++ b/src/Termii/Messaging/TermiiMessagingClient.cs @@ -0,0 +1,147 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +internal sealed class TermiiMessagingClient : ITermiiMessagingClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiMessagingClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task SendAsync( + SendMessageRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/send", + new SendMessagePayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendBulkAsync( + SendBulkMessageRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/send/bulk", + new SendBulkMessagePayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendWhatsAppTemplateAsync( + SendWhatsAppTemplateRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/send/template", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendWhatsAppTemplateMediaAsync( + SendWhatsAppTemplateMediaRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/send/template/media", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + private sealed class SendMessagePayload + { + public SendMessagePayload(SendMessageRequest request) + { + To = request.To; + From = request.From; + Sms = request.Sms; + Type = request.Type.ToWireValue(); + Channel = request.Channel.ToWireValue(); + Media = request.Media; + } + + [JsonPropertyName("to")] + public string To { get; } + + [JsonPropertyName("from")] + public string From { get; } + + [JsonPropertyName("sms")] + public string Sms { get; } + + [JsonPropertyName("type")] + public string Type { get; } + + [JsonPropertyName("channel")] + public string Channel { get; } + + [JsonPropertyName("media")] + public TermiiMessageMedia? Media { get; } + } + + private sealed class SendBulkMessagePayload + { + public SendBulkMessagePayload(SendBulkMessageRequest request) + { + To = request.To; + From = request.From; + Sms = request.Sms; + Type = request.Type.ToWireValue(); + Channel = request.Channel.ToWireValue(); + } + + [JsonPropertyName("to")] + public IReadOnlyCollection To { get; } + + [JsonPropertyName("from")] + public string From { get; } + + [JsonPropertyName("sms")] + public string Sms { get; } + + [JsonPropertyName("type")] + public string Type { get; } + + [JsonPropertyName("channel")] + public string Channel { get; } + } +} diff --git a/src/Termii/Messaging/TermiiMessagingEnumExtensions.cs b/src/Termii/Messaging/TermiiMessagingEnumExtensions.cs new file mode 100644 index 0000000..ad1441e --- /dev/null +++ b/src/Termii/Messaging/TermiiMessagingEnumExtensions.cs @@ -0,0 +1,26 @@ +namespace Termii; + +internal static class TermiiMessagingEnumExtensions +{ + public static string ToWireValue(this TermiiMessageChannel channel) + { + return channel switch + { + TermiiMessageChannel.Generic => "generic", + TermiiMessageChannel.Dnd => "dnd", + TermiiMessageChannel.WhatsApp => "whatsapp", + TermiiMessageChannel.Voice => "voice", + _ => throw new ArgumentOutOfRangeException(nameof(channel), channel, "Unsupported Termii message channel."), + }; + } + + public static string ToWireValue(this TermiiMessageType type) + { + return type switch + { + TermiiMessageType.Plain => "plain", + TermiiMessageType.Unicode => "unicode", + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported Termii message type."), + }; + } +} diff --git a/src/Termii/Messaging/WhatsAppTemplateComponent.cs b/src/Termii/Messaging/WhatsAppTemplateComponent.cs new file mode 100644 index 0000000..1c07ab6 --- /dev/null +++ b/src/Termii/Messaging/WhatsAppTemplateComponent.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class WhatsAppTemplateComponent +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("parameters")] + public IReadOnlyCollection>? Parameters { get; set; } +} diff --git a/src/Termii/Messaging/WhatsAppTemplateMedia.cs b/src/Termii/Messaging/WhatsAppTemplateMedia.cs new file mode 100644 index 0000000..c3d9746 --- /dev/null +++ b/src/Termii/Messaging/WhatsAppTemplateMedia.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class WhatsAppTemplateMedia +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("caption")] + public string? Caption { get; set; } + + [JsonPropertyName("filename")] + public string? FileName { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(Type, nameof(Type)); + TermiiRequestValidation.Required(Url, nameof(Url)); + } +} diff --git a/src/Termii/Messaging/WhatsAppTemplateResponse.cs b/src/Termii/Messaging/WhatsAppTemplateResponse.cs new file mode 100644 index 0000000..677e1d9 --- /dev/null +++ b/src/Termii/Messaging/WhatsAppTemplateResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class WhatsAppTemplateResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Numbers/ITermiiNumberClient.cs b/src/Termii/Numbers/ITermiiNumberClient.cs new file mode 100644 index 0000000..6ee5b19 --- /dev/null +++ b/src/Termii/Numbers/ITermiiNumberClient.cs @@ -0,0 +1,8 @@ +namespace Termii; + +public interface ITermiiNumberClient +{ + Task SendAsync( + SendNumberMessageRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Numbers/SendNumberMessageRequest.cs b/src/Termii/Numbers/SendNumberMessageRequest.cs new file mode 100644 index 0000000..3c8bc07 --- /dev/null +++ b/src/Termii/Numbers/SendNumberMessageRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendNumberMessageRequest +{ + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("sms")] + public string Sms { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(To, nameof(To)); + TermiiRequestValidation.Required(Sms, nameof(Sms)); + } +} diff --git a/src/Termii/Numbers/TermiiNumberClient.cs b/src/Termii/Numbers/TermiiNumberClient.cs new file mode 100644 index 0000000..90bc525 --- /dev/null +++ b/src/Termii/Numbers/TermiiNumberClient.cs @@ -0,0 +1,30 @@ +namespace Termii; + +internal sealed class TermiiNumberClient : ITermiiNumberClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiNumberClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task SendAsync( + SendNumberMessageRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/number/send", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } +} diff --git a/src/Termii/Properties/AssemblyInfo.cs b/src/Termii/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..6e3734c --- /dev/null +++ b/src/Termii/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Termii.Tests")] diff --git a/src/Termii/SenderIds/GetSenderIdsRequest.cs b/src/Termii/SenderIds/GetSenderIdsRequest.cs new file mode 100644 index 0000000..6096906 --- /dev/null +++ b/src/Termii/SenderIds/GetSenderIdsRequest.cs @@ -0,0 +1,27 @@ +namespace Termii; + +public sealed class GetSenderIdsRequest +{ + public string? SenderId { get; set; } + + public string? Status { get; set; } + + internal string ToPath() + { + var query = new List(); + + if (!string.IsNullOrWhiteSpace(SenderId)) + { + query.Add($"sender_id={Uri.EscapeDataString(SenderId)}"); + } + + if (!string.IsNullOrWhiteSpace(Status)) + { + query.Add($"status={Uri.EscapeDataString(Status)}"); + } + + return query.Count == 0 + ? "/api/sender-id" + : $"/api/sender-id?{string.Join("&", query)}"; + } +} diff --git a/src/Termii/SenderIds/ITermiiSenderIdClient.cs b/src/Termii/SenderIds/ITermiiSenderIdClient.cs new file mode 100644 index 0000000..c9382ca --- /dev/null +++ b/src/Termii/SenderIds/ITermiiSenderIdClient.cs @@ -0,0 +1,12 @@ +namespace Termii; + +public interface ITermiiSenderIdClient +{ + Task GetAsync( + GetSenderIdsRequest? request = null, + CancellationToken cancellationToken = default); + + Task RequestAsync( + RequestSenderIdRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/SenderIds/RequestSenderIdRequest.cs b/src/Termii/SenderIds/RequestSenderIdRequest.cs new file mode 100644 index 0000000..e1850d3 --- /dev/null +++ b/src/Termii/SenderIds/RequestSenderIdRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class RequestSenderIdRequest +{ + [JsonPropertyName("sender_id")] + public string SenderId { get; set; } = string.Empty; + + [JsonPropertyName("use_case")] + public string UseCase { get; set; } = string.Empty; + + [JsonPropertyName("company")] + public string Company { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(SenderId, nameof(SenderId)); + TermiiRequestValidation.Required(UseCase, nameof(UseCase)); + TermiiRequestValidation.Required(Company, nameof(Company)); + } +} diff --git a/src/Termii/SenderIds/RequestSenderIdResponse.cs b/src/Termii/SenderIds/RequestSenderIdResponse.cs new file mode 100644 index 0000000..2f2c056 --- /dev/null +++ b/src/Termii/SenderIds/RequestSenderIdResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class RequestSenderIdResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/src/Termii/SenderIds/SenderIdListResponse.cs b/src/Termii/SenderIds/SenderIdListResponse.cs new file mode 100644 index 0000000..e14922e --- /dev/null +++ b/src/Termii/SenderIds/SenderIdListResponse.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SenderIdListResponse +{ + [JsonPropertyName("content")] + public IReadOnlyCollection Content { get; set; } = Array.Empty(); + + [JsonPropertyName("totalElements")] + public int? TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int? TotalPages { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonPropertyName("first")] + public bool? First { get; set; } + + [JsonPropertyName("last")] + public bool? Last { get; set; } + + [JsonPropertyName("empty")] + public bool? Empty { get; set; } +} diff --git a/src/Termii/SenderIds/SenderIdRecord.cs b/src/Termii/SenderIds/SenderIdRecord.cs new file mode 100644 index 0000000..746bb2c --- /dev/null +++ b/src/Termii/SenderIds/SenderIdRecord.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SenderIdRecord +{ + [JsonPropertyName("sender_id")] + public string? SenderId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("country")] + public string? Country { get; set; } + + [JsonPropertyName("company")] + public string? Company { get; set; } + + [JsonPropertyName("usecase")] + public string? UseCase { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } +} diff --git a/src/Termii/SenderIds/TermiiSenderIdClient.cs b/src/Termii/SenderIds/TermiiSenderIdClient.cs new file mode 100644 index 0000000..0affe28 --- /dev/null +++ b/src/Termii/SenderIds/TermiiSenderIdClient.cs @@ -0,0 +1,42 @@ +namespace Termii; + +internal sealed class TermiiSenderIdClient : ITermiiSenderIdClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiSenderIdClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task GetAsync( + GetSenderIdsRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetSenderIdsRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task RequestAsync( + RequestSenderIdRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sender-id/request", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } +} diff --git a/src/Termii/Termii.csproj b/src/Termii/Termii.csproj index cc7e907..4c1a259 100644 --- a/src/Termii/Termii.csproj +++ b/src/Termii/Termii.csproj @@ -3,10 +3,22 @@ netstandard2.0;net8.0 Termii Termii - Termii - A .NET SDK for the Termii messaging, token, and insights APIs. - termii;sms;otp;messaging;dotnet;sdk + Termii.SDK + 0.4.0 + A .NET SDK for the Termii messaging, token, and insights APIs, compatible with .NET Core 3.1 through .NET 10. + Add Campaign phonebook APIs, product notification email API, and WhatsApp template message APIs. + termii;sms;otp;messaging;dotnet;sdk;netstandard2.0;netcoreapp3.1;net10 true $(NoWarn);1591 + + + + + + + + + + diff --git a/src/Termii/TermiiApiError.cs b/src/Termii/TermiiApiError.cs new file mode 100644 index 0000000..a288252 --- /dev/null +++ b/src/Termii/TermiiApiError.cs @@ -0,0 +1,59 @@ +using System.Text.Json; + +namespace Termii; + +internal sealed class TermiiApiError +{ + public string? Message { get; private set; } + + public string? Code { get; private set; } + + public static TermiiApiError Parse(string rawResponseBody) + { + if (string.IsNullOrWhiteSpace(rawResponseBody)) + { + return new TermiiApiError(); + } + + try + { + using var document = JsonDocument.Parse(rawResponseBody); + + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return new TermiiApiError(); + } + + return new TermiiApiError + { + Message = ReadString(document.RootElement, "message") + ?? ReadString(document.RootElement, "error") + ?? ReadString(document.RootElement, "errors") + ?? ReadString(document.RootElement, "status"), + Code = ReadString(document.RootElement, "code") + ?? ReadString(document.RootElement, "error_code"), + }; + } + catch (JsonException) + { + return new TermiiApiError(); + } + } + + private static string? ReadString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property)) + { + return null; + } + + return property.ValueKind switch + { + JsonValueKind.String => property.GetString(), + JsonValueKind.Number => property.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => property.GetRawText(), + }; + } +} diff --git a/src/Termii/TermiiApiException.cs b/src/Termii/TermiiApiException.cs new file mode 100644 index 0000000..d434b61 --- /dev/null +++ b/src/Termii/TermiiApiException.cs @@ -0,0 +1,44 @@ +using System.Net; + +namespace Termii; + +public sealed class TermiiApiException : Exception +{ + public TermiiApiException( + HttpStatusCode statusCode, + string? termiiMessage, + string? termiiCode, + string rawResponseBody) + : base(CreateMessage(statusCode, termiiMessage, termiiCode)) + { + StatusCode = statusCode; + TermiiMessage = termiiMessage; + TermiiCode = termiiCode; + RawResponseBody = rawResponseBody; + } + + public HttpStatusCode StatusCode { get; } + + public string? TermiiMessage { get; } + + public string? TermiiCode { get; } + + public string RawResponseBody { get; } + + private static string CreateMessage(HttpStatusCode statusCode, string? termiiMessage, string? termiiCode) + { + var message = $"Termii API request failed with HTTP {(int)statusCode} ({statusCode})."; + + if (!string.IsNullOrWhiteSpace(termiiCode)) + { + message += $" Code: {termiiCode}."; + } + + if (!string.IsNullOrWhiteSpace(termiiMessage)) + { + message += $" Message: {termiiMessage}."; + } + + return message; + } +} diff --git a/src/Termii/TermiiAuthenticationLocation.cs b/src/Termii/TermiiAuthenticationLocation.cs new file mode 100644 index 0000000..d1701f7 --- /dev/null +++ b/src/Termii/TermiiAuthenticationLocation.cs @@ -0,0 +1,8 @@ +namespace Termii; + +internal enum TermiiAuthenticationLocation +{ + None, + Query, + Body, +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index dfe9b6f..68f9572 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -3,13 +3,90 @@ namespace Termii; /// /// Main entry point for the Termii SDK. /// -public sealed class TermiiClient +public sealed class TermiiClient : IDisposable { + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly TermiiJsonHttpPipeline _pipeline; + public TermiiClient(TermiiOptions options) + : this(CreateDefaultHttpClient(options), options, ownsHttpClient: true) + { + } + + public TermiiClient(HttpClient httpClient, TermiiOptions options) + : this(httpClient, options, ownsHttpClient: false) + { + } + + private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttpClient) { Options = options ?? throw new ArgumentNullException(nameof(options)); Options.Validate(); + + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _ownsHttpClient = ownsHttpClient; + _httpClient.BaseAddress ??= Options.BaseUrl; + + if (_ownsHttpClient) + { + _httpClient.Timeout = Options.Timeout; + } + + _pipeline = new TermiiJsonHttpPipeline(_httpClient, Options); + Messaging = new TermiiMessagingClient(_pipeline); + SenderIds = new TermiiSenderIdClient(_pipeline); + Numbers = new TermiiNumberClient(_pipeline); + Tokens = new TermiiTokenClient(_pipeline); + Insights = new TermiiInsightsClient(_pipeline); + Campaigns = new TermiiCampaignClient(_pipeline); + Emails = new TermiiEmailClient(_pipeline); } public TermiiOptions Options { get; } + + public ITermiiMessagingClient Messaging { get; } + + public ITermiiSenderIdClient SenderIds { get; } + + public ITermiiNumberClient Numbers { get; } + + public ITermiiTokenClient Tokens { get; } + + public ITermiiInsightsClient Insights { get; } + + public ITermiiCampaignClient Campaigns { get; } + + public ITermiiEmailClient Emails { get; } + + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + internal Task SendAsync( + HttpMethod method, + string path, + object? body = null, + TermiiAuthenticationLocation authenticationLocation = TermiiAuthenticationLocation.Query, + CancellationToken cancellationToken = default) + { + return _pipeline.SendAsync(method, path, body, authenticationLocation, cancellationToken); + } + + private static HttpClient CreateDefaultHttpClient(TermiiOptions options) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return new HttpClient + { + BaseAddress = options.BaseUrl, + }; + } } diff --git a/src/Termii/TermiiJsonHttpPipeline.cs b/src/Termii/TermiiJsonHttpPipeline.cs new file mode 100644 index 0000000..3f156fa --- /dev/null +++ b/src/Termii/TermiiJsonHttpPipeline.cs @@ -0,0 +1,158 @@ +using System.Net.Http.Headers; +using System.Text.Json.Serialization; +using System.Text; +using System.Text.Json; + +namespace Termii; + +internal sealed class TermiiJsonHttpPipeline +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + + private readonly HttpClient _httpClient; + private readonly TermiiOptions _options; + + public TermiiJsonHttpPipeline(HttpClient httpClient, TermiiOptions options) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task SendAsync( + HttpMethod method, + string path, + object? body, + TermiiAuthenticationLocation authenticationLocation, + CancellationToken cancellationToken) + { + if (method is null) + { + throw new ArgumentNullException(nameof(method)); + } + + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("A request path is required.", nameof(path)); + } + + if (!path.StartsWith("/", StringComparison.Ordinal)) + { + throw new ArgumentException("Termii request paths must start with '/'.", nameof(path)); + } + + if (Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uri) && uri.IsAbsoluteUri) + { + throw new ArgumentException("Termii request paths must be relative.", nameof(path)); + } + + var requestUri = authenticationLocation == TermiiAuthenticationLocation.Query + ? AppendApiKey(path) + : path; + + var request = new HttpRequestMessage(method, requestUri); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (body is not null || authenticationLocation == TermiiAuthenticationLocation.Body) + { + request.Content = CreateJsonContent(body, authenticationLocation); + } + + var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + return response; + } + + var statusCode = response.StatusCode; + var rawResponseBody = response.Content is null + ? string.Empty + : await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var error = TermiiApiError.Parse(rawResponseBody); + + response.Dispose(); + + throw new TermiiApiException(statusCode, error.Message, error.Code, rawResponseBody); + } + + public async Task SendJsonAsync( + HttpMethod method, + string path, + object? body, + TermiiAuthenticationLocation authenticationLocation, + CancellationToken cancellationToken) + { + using var response = await SendAsync(method, path, body, authenticationLocation, cancellationToken) + .ConfigureAwait(false); + + if (response.Content is null) + { + throw new InvalidOperationException("The Termii API returned an empty response."); + } + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var value = JsonSerializer.Deserialize(json, JsonSerializerOptions); + + if (value is null) + { + throw new InvalidOperationException("The Termii API returned an empty response."); + } + + return value; + } + + private string AppendApiKey(string path) + { + var separator = path.IndexOf("?", StringComparison.Ordinal) >= 0 ? "&" : "?"; + + return $"{path}{separator}api_key={Uri.EscapeDataString(_options.ApiKey)}"; + } + + private HttpContent CreateJsonContent(object? body, TermiiAuthenticationLocation authenticationLocation) + { + var json = authenticationLocation == TermiiAuthenticationLocation.Body + ? SerializeBodyWithApiKey(body) + : JsonSerializer.Serialize(body, JsonSerializerOptions); + + return new StringContent(json, Encoding.UTF8, "application/json"); + } + + private string SerializeBodyWithApiKey(object? body) + { + using var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("api_key", _options.ApiKey); + + if (body is not null) + { + using var document = JsonDocument.Parse(JsonSerializer.Serialize(body, JsonSerializerOptions)); + + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("Termii JSON request bodies must serialize to an object."); + } + + foreach (var property in document.RootElement.EnumerateObject()) + { + if (string.Equals(property.Name, "api_key", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + property.WriteTo(writer); + } + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } +} diff --git a/src/Termii/TermiiOptions.cs b/src/Termii/TermiiOptions.cs index 6e3ea21..fe7558c 100644 --- a/src/Termii/TermiiOptions.cs +++ b/src/Termii/TermiiOptions.cs @@ -11,6 +11,8 @@ public sealed class TermiiOptions public Uri BaseUrl { get; set; } = new(DefaultBaseUrl); + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100); + internal void Validate() { if (string.IsNullOrWhiteSpace(ApiKey)) @@ -18,9 +20,14 @@ internal void Validate() throw new InvalidOperationException("A Termii API key is required."); } - if (!BaseUrl.IsAbsoluteUri) + if (BaseUrl is null || !BaseUrl.IsAbsoluteUri) { throw new InvalidOperationException("The Termii base URL must be absolute."); } + + if (Timeout <= TimeSpan.Zero) + { + throw new InvalidOperationException("The Termii timeout must be greater than zero."); + } } } diff --git a/src/Termii/TermiiRequestValidation.cs b/src/Termii/TermiiRequestValidation.cs new file mode 100644 index 0000000..992a603 --- /dev/null +++ b/src/Termii/TermiiRequestValidation.cs @@ -0,0 +1,31 @@ +namespace Termii; + +internal static class TermiiRequestValidation +{ + public static void Required(string? value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"{parameterName} is required.", parameterName); + } + } + + public static void Positive(int value, string parameterName) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(parameterName, value, $"{parameterName} must be greater than zero."); + } + } + + public static void Range(int value, int minimum, int maximum, string parameterName) + { + if (value < minimum || value > maximum) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + $"{parameterName} must be between {minimum} and {maximum}."); + } + } +} diff --git a/src/Termii/TermiiServiceCollectionExtensions.cs b/src/Termii/TermiiServiceCollectionExtensions.cs new file mode 100644 index 0000000..ac7c45e --- /dev/null +++ b/src/Termii/TermiiServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Termii; + +public static class TermiiServiceCollectionExtensions +{ + public static IHttpClientBuilder AddTermii( + this IServiceCollection services, + Action configureOptions) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configureOptions is null) + { + throw new ArgumentNullException(nameof(configureOptions)); + } + + var options = new TermiiOptions(); + configureOptions(options); + options.Validate(); + + services.AddSingleton(options); + + return services.AddHttpClient((serviceProvider, httpClient) => + { + var termiiOptions = serviceProvider.GetRequiredService(); + httpClient.BaseAddress = termiiOptions.BaseUrl; + httpClient.Timeout = termiiOptions.Timeout; + }); + } +} diff --git a/src/Termii/Tokens/GenerateTokenRequest.cs b/src/Termii/Tokens/GenerateTokenRequest.cs new file mode 100644 index 0000000..944d6f2 --- /dev/null +++ b/src/Termii/Tokens/GenerateTokenRequest.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class GenerateTokenRequest +{ + [JsonIgnore] + public TermiiTokenPinType PinType { get; set; } = TermiiTokenPinType.Numeric; + + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; set; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; set; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts)); + TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive)); + TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength)); + } +} diff --git a/src/Termii/Tokens/GenerateTokenResponse.cs b/src/Termii/Tokens/GenerateTokenResponse.cs new file mode 100644 index 0000000..8298cd2 --- /dev/null +++ b/src/Termii/Tokens/GenerateTokenResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class GenerateTokenResponse +{ + [JsonPropertyName("phone_number_other")] + public string? PhoneNumberOther { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("otp")] + public string? Otp { get; set; } + + [JsonPropertyName("pin_id")] + public string? PinId { get; set; } +} diff --git a/src/Termii/Tokens/ITermiiTokenClient.cs b/src/Termii/Tokens/ITermiiTokenClient.cs new file mode 100644 index 0000000..b072182 --- /dev/null +++ b/src/Termii/Tokens/ITermiiTokenClient.cs @@ -0,0 +1,32 @@ +namespace Termii; + +public interface ITermiiTokenClient +{ + Task SendAsync( + SendTokenRequest request, + CancellationToken cancellationToken = default); + + Task VerifyAsync( + VerifyTokenRequest request, + CancellationToken cancellationToken = default); + + Task GenerateAsync( + GenerateTokenRequest request, + CancellationToken cancellationToken = default); + + Task SendVoiceAsync( + SendVoiceTokenRequest request, + CancellationToken cancellationToken = default); + + Task CallAsync( + VoiceCallTokenRequest request, + CancellationToken cancellationToken = default); + + Task SendEmailAsync( + SendEmailTokenRequest request, + CancellationToken cancellationToken = default); + + Task SendWhatsAppAsync( + SendWhatsAppTokenRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Tokens/SendEmailTokenRequest.cs b/src/Termii/Tokens/SendEmailTokenRequest.cs new file mode 100644 index 0000000..eb538ad --- /dev/null +++ b/src/Termii/Tokens/SendEmailTokenRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendEmailTokenRequest +{ + [JsonPropertyName("email_address")] + public string EmailAddress { get; set; } = string.Empty; + + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("email_configuration_id")] + public string EmailConfigurationId { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(EmailAddress, nameof(EmailAddress)); + TermiiRequestValidation.Required(Code, nameof(Code)); + TermiiRequestValidation.Required(EmailConfigurationId, nameof(EmailConfigurationId)); + } +} diff --git a/src/Termii/Tokens/SendTokenRequest.cs b/src/Termii/Tokens/SendTokenRequest.cs new file mode 100644 index 0000000..4c23176 --- /dev/null +++ b/src/Termii/Tokens/SendTokenRequest.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendTokenRequest +{ + [JsonIgnore] + public TermiiTokenPinType PinType { get; set; } = TermiiTokenPinType.Numeric; + + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonIgnore] + public TermiiMessageChannel Channel { get; set; } = TermiiMessageChannel.Generic; + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; set; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; set; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; set; } + + [JsonPropertyName("pin_placeholder")] + public string PinPlaceholder { get; set; } = string.Empty; + + [JsonPropertyName("message_text")] + public string MessageText { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(To, nameof(To)); + TermiiRequestValidation.Required(From, nameof(From)); + TermiiRequestValidation.Required(PinPlaceholder, nameof(PinPlaceholder)); + TermiiRequestValidation.Required(MessageText, nameof(MessageText)); + TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts)); + TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive)); + TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength)); + } +} diff --git a/src/Termii/Tokens/SendTokenResponse.cs b/src/Termii/Tokens/SendTokenResponse.cs new file mode 100644 index 0000000..da457f0 --- /dev/null +++ b/src/Termii/Tokens/SendTokenResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendTokenResponse +{ + [JsonPropertyName("smsStatus")] + public string? SmsStatus { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("to")] + public string? To { get; set; } + + [JsonPropertyName("pinId")] + public string? PinId { get; set; } + + [JsonPropertyName("pin_id")] + public string? PinIdSnakeCase { get; set; } + + [JsonPropertyName("message_id_str")] + public string? MessageIdString { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } +} diff --git a/src/Termii/Tokens/SendVoiceTokenRequest.cs b/src/Termii/Tokens/SendVoiceTokenRequest.cs new file mode 100644 index 0000000..e3f32ee --- /dev/null +++ b/src/Termii/Tokens/SendVoiceTokenRequest.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendVoiceTokenRequest +{ + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; set; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; set; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts)); + TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive)); + TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength)); + } +} diff --git a/src/Termii/Tokens/SendWhatsAppTokenRequest.cs b/src/Termii/Tokens/SendWhatsAppTokenRequest.cs new file mode 100644 index 0000000..777bb6a --- /dev/null +++ b/src/Termii/Tokens/SendWhatsAppTokenRequest.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendWhatsAppTokenRequest +{ + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonPropertyName("sms")] + public string Sms { get; set; } = string.Empty; + + [JsonIgnore] + public TermiiMessageType Type { get; set; } = TermiiMessageType.Plain; + + internal void Validate() + { + TermiiRequestValidation.Required(To, nameof(To)); + TermiiRequestValidation.Required(From, nameof(From)); + TermiiRequestValidation.Required(Sms, nameof(Sms)); + } +} diff --git a/src/Termii/Tokens/TermiiTokenClient.cs b/src/Termii/Tokens/TermiiTokenClient.cs new file mode 100644 index 0000000..e0a92da --- /dev/null +++ b/src/Termii/Tokens/TermiiTokenClient.cs @@ -0,0 +1,246 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +internal sealed class TermiiTokenClient : ITermiiTokenClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiTokenClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task SendAsync( + SendTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/send", + new SendTokenPayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task VerifyAsync( + VerifyTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/verify", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task GenerateAsync( + GenerateTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/generate", + new GenerateTokenPayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendVoiceAsync( + SendVoiceTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/send/voice", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task CallAsync( + VoiceCallTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/call", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendEmailAsync( + SendEmailTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/email/otp/send", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendWhatsAppAsync( + SendWhatsAppTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/send", + new SendWhatsAppTokenPayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + private sealed class SendTokenPayload + { + public SendTokenPayload(SendTokenRequest request) + { + MessageType = request.PinType.ToWireValue(); + PinType = request.PinType.ToWireValue(); + To = request.To; + From = request.From; + Channel = request.Channel.ToWireValue(); + PinAttempts = request.PinAttempts; + PinTimeToLive = request.PinTimeToLive; + PinLength = request.PinLength; + PinPlaceholder = request.PinPlaceholder; + MessageText = request.MessageText; + } + + [JsonPropertyName("message_type")] + public string MessageType { get; } + + [JsonPropertyName("pin_type")] + public string PinType { get; } + + [JsonPropertyName("to")] + public string To { get; } + + [JsonPropertyName("from")] + public string From { get; } + + [JsonPropertyName("channel")] + public string Channel { get; } + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; } + + [JsonPropertyName("pin_placeholder")] + public string PinPlaceholder { get; } + + [JsonPropertyName("message_text")] + public string MessageText { get; } + } + + private sealed class GenerateTokenPayload + { + public GenerateTokenPayload(GenerateTokenRequest request) + { + PinType = request.PinType.ToWireValue(); + PhoneNumber = request.PhoneNumber; + PinAttempts = request.PinAttempts; + PinTimeToLive = request.PinTimeToLive; + PinLength = request.PinLength; + } + + [JsonPropertyName("pin_type")] + public string PinType { get; } + + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; } + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; } + } + + private sealed class SendWhatsAppTokenPayload + { + public SendWhatsAppTokenPayload(SendWhatsAppTokenRequest request) + { + To = request.To; + From = request.From; + Sms = request.Sms; + Type = request.Type.ToWireValue(); + } + + [JsonPropertyName("to")] + public string To { get; } + + [JsonPropertyName("from")] + public string From { get; } + + [JsonPropertyName("sms")] + public string Sms { get; } + + [JsonPropertyName("type")] + public string Type { get; } + + [JsonPropertyName("channel")] + public string Channel => "whatsapp_otp"; + } +} diff --git a/src/Termii/Tokens/TermiiTokenEnumExtensions.cs b/src/Termii/Tokens/TermiiTokenEnumExtensions.cs new file mode 100644 index 0000000..aed901a --- /dev/null +++ b/src/Termii/Tokens/TermiiTokenEnumExtensions.cs @@ -0,0 +1,14 @@ +namespace Termii; + +internal static class TermiiTokenEnumExtensions +{ + public static string ToWireValue(this TermiiTokenPinType pinType) + { + return pinType switch + { + TermiiTokenPinType.Numeric => "NUMERIC", + TermiiTokenPinType.Alphanumeric => "ALPHANUMERIC", + _ => throw new ArgumentOutOfRangeException(nameof(pinType), pinType, "Unsupported Termii token PIN type."), + }; + } +} diff --git a/src/Termii/Tokens/TermiiTokenPinType.cs b/src/Termii/Tokens/TermiiTokenPinType.cs new file mode 100644 index 0000000..fb0dc87 --- /dev/null +++ b/src/Termii/Tokens/TermiiTokenPinType.cs @@ -0,0 +1,7 @@ +namespace Termii; + +public enum TermiiTokenPinType +{ + Numeric, + Alphanumeric, +} diff --git a/src/Termii/Tokens/VerifyTokenRequest.cs b/src/Termii/Tokens/VerifyTokenRequest.cs new file mode 100644 index 0000000..2798c54 --- /dev/null +++ b/src/Termii/Tokens/VerifyTokenRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class VerifyTokenRequest +{ + [JsonPropertyName("pin_id")] + public string PinId { get; set; } = string.Empty; + + [JsonPropertyName("pin")] + public string Pin { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(PinId, nameof(PinId)); + TermiiRequestValidation.Required(Pin, nameof(Pin)); + } +} diff --git a/src/Termii/Tokens/VerifyTokenResponse.cs b/src/Termii/Tokens/VerifyTokenResponse.cs new file mode 100644 index 0000000..16b5ad7 --- /dev/null +++ b/src/Termii/Tokens/VerifyTokenResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class VerifyTokenResponse +{ + [JsonPropertyName("pinId")] + public string? PinId { get; set; } + + [JsonPropertyName("verified")] + public string? Verified { get; set; } + + [JsonPropertyName("msisdn")] + public string? Msisdn { get; set; } +} diff --git a/src/Termii/Tokens/VoiceCallTokenRequest.cs b/src/Termii/Tokens/VoiceCallTokenRequest.cs new file mode 100644 index 0000000..2d3324b --- /dev/null +++ b/src/Termii/Tokens/VoiceCallTokenRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class VoiceCallTokenRequest +{ + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Required(Code, nameof(Code)); + } +} diff --git a/src/Termii/Webhooks/TermiiWebhookEvent.cs b/src/Termii/Webhooks/TermiiWebhookEvent.cs new file mode 100644 index 0000000..0c71ea7 --- /dev/null +++ b/src/Termii/Webhooks/TermiiWebhookEvent.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class TermiiWebhookEvent +{ + [JsonPropertyName("event")] + public string? Event { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("message_id_str")] + public string? MessageIdString { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("to")] + public string? To { get; set; } + + [JsonPropertyName("from")] + public string? From { get; set; } + + [JsonPropertyName("sender")] + public string? Sender { get; set; } + + [JsonPropertyName("receiver")] + public string? Receiver { get; set; } + + [JsonPropertyName("channel")] + public string? Channel { get; set; } + + [JsonPropertyName("network")] + public string? Network { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("amount")] + public decimal? Amount { get; set; } + + [JsonPropertyName("error_code")] + public string? ErrorCode { get; set; } + + [JsonPropertyName("error_message")] + public string? ErrorMessage { get; set; } + + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + + [JsonPropertyName("sent_at")] + public string? SentAt { get; set; } + + [JsonPropertyName("delivered_at")] + public string? DeliveredAt { get; set; } + + [JsonPropertyName("done_date")] + public string? DoneDate { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs b/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs new file mode 100644 index 0000000..4f70b90 --- /dev/null +++ b/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs @@ -0,0 +1,34 @@ +using Termii; + +namespace Termii.IntegrationTests; + +internal static class IntegrationTestEnvironment +{ + public static string? ApiKey => Environment.GetEnvironmentVariable("TERMII_API_KEY"); + + public static string? BaseUrl => Environment.GetEnvironmentVariable("TERMII_BASE_URL"); + + public static string? TestPhoneNumber => Environment.GetEnvironmentVariable("TERMII_TEST_PHONE_NUMBER"); + + public static bool HasCredentials => !string.IsNullOrWhiteSpace(ApiKey); + + public static TermiiOptions CreateOptions() + { + if (string.IsNullOrWhiteSpace(ApiKey)) + { + throw new InvalidOperationException("TERMII_API_KEY is required for live Termii integration tests."); + } + + var options = new TermiiOptions + { + ApiKey = ApiKey, + }; + + if (!string.IsNullOrWhiteSpace(BaseUrl)) + { + options.BaseUrl = new Uri(BaseUrl); + } + + return options; + } +} diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index df2c1d8..820f433 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -8,27 +8,21 @@ public sealed class TermiiClientIntegrationTests [Fact] public void CanCreateClientFromIntegrationEnvironment() { - var apiKey = Environment.GetEnvironmentVariable("TERMII_API_KEY"); - - if (string.IsNullOrWhiteSpace(apiKey)) + if (!IntegrationTestEnvironment.HasCredentials) { return; } - var baseUrl = Environment.GetEnvironmentVariable("TERMII_BASE_URL"); - var options = new TermiiOptions - { - ApiKey = apiKey, - }; - - if (!string.IsNullOrWhiteSpace(baseUrl)) - { - options.BaseUrl = new Uri(baseUrl); - } - - var client = new TermiiClient(options); + var client = new TermiiClient(IntegrationTestEnvironment.CreateOptions()); Assert.False(string.IsNullOrWhiteSpace(client.Options.ApiKey)); Assert.True(client.Options.BaseUrl.IsAbsoluteUri); + Assert.NotNull(client.Messaging); + Assert.NotNull(client.SenderIds); + Assert.NotNull(client.Numbers); + Assert.NotNull(client.Tokens); + Assert.NotNull(client.Insights); + Assert.NotNull(client.Campaigns); + Assert.NotNull(client.Emails); } } diff --git a/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs b/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs new file mode 100644 index 0000000..e13fd22 --- /dev/null +++ b/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs @@ -0,0 +1,44 @@ +using System.Net; + +namespace Termii.Tests.Infrastructure; + +internal sealed class TestHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue _responses = new(); + + public TestHttpMessageHandler() + : this(HttpStatusCode.OK, "{}") + { + } + + public TestHttpMessageHandler(HttpStatusCode statusCode, string responseBody) + { + Enqueue(statusCode, responseBody); + } + + public IReadOnlyList Requests => _requests; + + public HttpRequestMessage? LastRequest => _requests.Count == 0 ? null : _requests[^1]; + + private readonly List _requests = new(); + + public void Enqueue(HttpStatusCode statusCode, string responseBody) + { + _responses.Enqueue(new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseBody), + }); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _requests.Add(request); + + if (_responses.Count == 0) + { + throw new InvalidOperationException("No fake HTTP response was queued for the request."); + } + + return Task.FromResult(_responses.Dequeue()); + } +} diff --git a/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs b/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs new file mode 100644 index 0000000..9db82a3 --- /dev/null +++ b/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs @@ -0,0 +1,20 @@ +using System.Text.Json; + +namespace Termii.Tests.Infrastructure; + +internal static class TestRequestExtensions +{ + public static async Task ReadJsonBodyAsync( + this HttpRequestMessage request, + CancellationToken cancellationToken = default) + { + if (request.Content is null) + { + throw new InvalidOperationException("The request does not have a body."); + } + + var json = await request.Content.ReadAsStringAsync(cancellationToken); + + return JsonDocument.Parse(json); + } +} diff --git a/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs b/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs new file mode 100644 index 0000000..473cbcc --- /dev/null +++ b/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs @@ -0,0 +1,23 @@ +using Termii; + +namespace Termii.Tests.Infrastructure; + +internal static class TestTermiiClientFactory +{ + public static TermiiClient Create( + TestHttpMessageHandler handler, + string apiKey = "test-api-key", + string baseUrl = "https://example.test") + { + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri(baseUrl), + }; + + return new TermiiClient(httpClient, new TermiiOptions + { + ApiKey = apiKey, + BaseUrl = new Uri(baseUrl), + }); + } +} diff --git a/tests/Termii.Tests/Termii.Tests.csproj b/tests/Termii.Tests/Termii.Tests.csproj index 219cfb4..04c274a 100644 --- a/tests/Termii.Tests/Termii.Tests.csproj +++ b/tests/Termii.Tests/Termii.Tests.csproj @@ -5,6 +5,7 @@ + diff --git a/tests/Termii.Tests/TermiiCampaignClientTests.cs b/tests/Termii.Tests/TermiiCampaignClientTests.cs new file mode 100644 index 0000000..0e56582 --- /dev/null +++ b/tests/Termii.Tests/TermiiCampaignClientTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiCampaignClientTests +{ + [Fact] + public async Task GetPhonebooksAsyncAddsFiltersAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "content": [ + { + "phonebook_id": "pb-123", + "phonebook_name": "Customers", + "description": "Customer contacts", + "total_contacts": 42, + "created_at": "2026-06-14 10:00:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "size": 15, + "number": 0 + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.GetPhonebooksAsync( + new GetPhonebooksRequest + { + Page = 0, + Size = 15, + Name = "Customers", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var phonebook = Assert.Single(response.Content); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal( + "https://example.test/api/phonebooks?page=0&size=15&name=Customers&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal(1, response.TotalElements); + Assert.Equal("pb-123", phonebook.PhonebookId); + Assert.Equal("Customers", phonebook.PhonebookName); + Assert.Equal(42, phonebook.TotalContacts); + } + + [Fact] + public async Task CreatePhonebookAsyncPostsJsonBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Phonebook created","data":{"phonebook_id":"pb-123","phonebook_name":"Customers"}}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.CreatePhonebookAsync( + new CreatePhonebookRequest + { + PhonebookName = "Customers", + Description = "Customer contacts", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/phonebooks", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("Customers", body.RootElement.GetProperty("phonebook_name").GetString()); + Assert.Equal("Customer contacts", body.RootElement.GetProperty("description").GetString()); + Assert.Equal("ok", response.Code); + Assert.Equal("pb-123", response.Data!.PhonebookId); + } + + [Fact] + public async Task UpdatePhonebookAsyncPatchesEscapedPhonebookPath() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"message":"Phonebook updated","phonebook":{"id":"pb 123","name":"Customers 2026"}}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.UpdatePhonebookAsync( + "pb 123", + new UpdatePhonebookRequest + { + PhonebookName = "Customers 2026", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(new HttpMethod("PATCH"), request.Method); + Assert.Equal("https://example.test/api/phonebooks/pb%20123", request.RequestUri!.AbsoluteUri); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("Customers 2026", body.RootElement.GetProperty("phonebook_name").GetString()); + Assert.Equal("Phonebook updated", response.Message); + Assert.Equal("Customers 2026", response.Phonebook!.Name); + } + + [Fact] + public async Task DeletePhonebookAsyncAddsApiKeyToQueryString() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"status":"success","message":"Phonebook deleted"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.DeletePhonebookAsync("pb-123", CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal(HttpMethod.Delete, request.Method); + Assert.Equal("https://example.test/api/phonebooks/pb-123?api_key=test-api-key", request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal("success", response.Status); + Assert.Equal("Phonebook deleted", response.Message); + } + + [Fact] + public async Task CreatePhonebookAsyncRejectsMissingName() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Campaigns.CreatePhonebookAsync( + new CreatePhonebookRequest + { + PhonebookName = "", + }, + CancellationToken.None)); + } +} diff --git a/tests/Termii.Tests/TermiiClientTests.cs b/tests/Termii.Tests/TermiiClientTests.cs index 46efaab..e525fc6 100644 --- a/tests/Termii.Tests/TermiiClientTests.cs +++ b/tests/Termii.Tests/TermiiClientTests.cs @@ -1,4 +1,7 @@ using Termii; +using Termii.Tests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using System.Net; using Xunit; namespace Termii.Tests; @@ -26,4 +29,120 @@ public void ConstructorAcceptsValidOptions() Assert.Equal("test-api-key", client.Options.ApiKey); Assert.Equal(TermiiOptions.DefaultBaseUrl, client.Options.BaseUrl.ToString().TrimEnd('/')); } + + [Fact] + public async Task SendAsyncAddsApiKeyToQueryString() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler, apiKey: "test key"); + + using var response = await client.SendAsync(HttpMethod.Get, "/api/sms/inbox", cancellationToken: CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method); + Assert.Equal("https://example.test/api/sms/inbox?api_key=test%20key", handler.LastRequest.RequestUri!.AbsoluteUri); + Assert.Null(handler.LastRequest.Content); + } + + [Fact] + public async Task SendAsyncAddsApiKeyToJsonBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + using var response = await client.SendAsync( + HttpMethod.Post, + "/api/sms/send", + new { to = "2348012345678", from = "Termii", sms = "Hello" }, + TermiiAuthenticationLocation.Body, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var document = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", document.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", document.RootElement.GetProperty("to").GetString()); + Assert.Equal("Termii", document.RootElement.GetProperty("from").GetString()); + Assert.Equal("Hello", document.RootElement.GetProperty("sms").GetString()); + } + + [Fact] + public async Task SendAsyncThrowsTermiiApiExceptionForJsonErrorResponses() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.BadRequest, + """{"message":"Invalid sender ID","code":"sender_id_invalid"}"""); + var client = TestTermiiClientFactory.Create(handler, apiKey: "secret-api-key"); + + var exception = await Assert.ThrowsAsync(() => client.SendAsync( + HttpMethod.Post, + "/api/sms/send", + new { to = "2348012345678", from = "Termii", sms = "Hello" }, + TermiiAuthenticationLocation.Body, + CancellationToken.None)); + + Assert.Equal(HttpStatusCode.BadRequest, exception.StatusCode); + Assert.Equal("Invalid sender ID", exception.TermiiMessage); + Assert.Equal("sender_id_invalid", exception.TermiiCode); + Assert.Equal("""{"message":"Invalid sender ID","code":"sender_id_invalid"}""", exception.RawResponseBody); + Assert.DoesNotContain("secret-api-key", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SendAsyncThrowsTermiiApiExceptionForPlainTextErrorResponses() + { + using var handler = new TestHttpMessageHandler(HttpStatusCode.InternalServerError, "Service unavailable"); + var client = TestTermiiClientFactory.Create(handler, apiKey: "secret-api-key"); + + var exception = await Assert.ThrowsAsync(() => client.SendAsync( + HttpMethod.Get, + "/api/get-balance", + cancellationToken: CancellationToken.None)); + + Assert.Equal(HttpStatusCode.InternalServerError, exception.StatusCode); + Assert.Null(exception.TermiiMessage); + Assert.Null(exception.TermiiCode); + Assert.Equal("Service unavailable", exception.RawResponseBody); + Assert.DoesNotContain("secret-api-key", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("api/sms/send")] + [InlineData("https://example.test/api/sms/send")] + public async Task SendAsyncRejectsInvalidPaths(string path) + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAnyAsync(() => client.SendAsync( + HttpMethod.Get, + path, + cancellationToken: CancellationToken.None)); + } + + [Fact] + public void AddTermiiRegistersConfiguredClient() + { + var services = new ServiceCollection(); + + services.AddTermii(options => + { + options.ApiKey = "test-api-key"; + options.BaseUrl = new Uri("https://example.test"); + options.Timeout = TimeSpan.FromSeconds(30); + }); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + Assert.Equal("test-api-key", client.Options.ApiKey); + Assert.Equal("https://example.test/", client.Options.BaseUrl.ToString()); + Assert.Equal(TimeSpan.FromSeconds(30), client.Options.Timeout); + } } diff --git a/tests/Termii.Tests/TermiiEmailClientTests.cs b/tests/Termii.Tests/TermiiEmailClientTests.cs new file mode 100644 index 0000000..8194c0e --- /dev/null +++ b/tests/Termii.Tests/TermiiEmailClientTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Text.Json; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiEmailClientTests +{ + [Fact] + public async Task SendProductEmailAsyncPostsTemplateEmailBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Email accepted","message_id":"email-123","status":"queued"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Emails.SendProductEmailAsync( + new SendProductEmailRequest + { + EmailAddress = "person@example.com", + TemplateId = "template-123", + Subject = "Order update", + From = "noreply@example.com", + SenderName = "Example Store", + Data = new Dictionary + { + ["first_name"] = JsonDocument.Parse("\"Ada\"").RootElement.Clone(), + ["order_id"] = JsonDocument.Parse("\"ORD-123\"").RootElement.Clone(), + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/templates/send-email", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("person@example.com", body.RootElement.GetProperty("email_address").GetString()); + Assert.Equal("template-123", body.RootElement.GetProperty("template_id").GetString()); + Assert.Equal("Order update", body.RootElement.GetProperty("subject").GetString()); + Assert.Equal("noreply@example.com", body.RootElement.GetProperty("from").GetString()); + Assert.Equal("Example Store", body.RootElement.GetProperty("sender_name").GetString()); + Assert.Equal("Ada", body.RootElement.GetProperty("data").GetProperty("first_name").GetString()); + Assert.Equal("ORD-123", body.RootElement.GetProperty("data").GetProperty("order_id").GetString()); + + Assert.Equal("ok", response.Code); + Assert.Equal("Email accepted", response.Message); + Assert.Equal("email-123", response.MessageId); + Assert.Equal("queued", response.Status); + } + + [Fact] + public async Task SendProductEmailAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Emails.SendProductEmailAsync( + new SendProductEmailRequest + { + EmailAddress = "", + TemplateId = "template-123", + }, + CancellationToken.None)); + } +} diff --git a/tests/Termii.Tests/TermiiInsightsClientTests.cs b/tests/Termii.Tests/TermiiInsightsClientTests.cs new file mode 100644 index 0000000..825d69a --- /dev/null +++ b/tests/Termii.Tests/TermiiInsightsClientTests.cs @@ -0,0 +1,190 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiInsightsClientTests +{ + [Fact] + public async Task GetBalanceAsyncAddsApiKeyAndDeserializesBalance() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"user":"termii-user","balance":"125.50","currency":"NGN"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.GetBalanceAsync(CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("https://example.test/api/get-balance?api_key=test-api-key", request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal("termii-user", response.User); + Assert.Equal(125.50m, response.Balance); + Assert.Equal("NGN", response.Currency); + } + + [Fact] + public async Task CheckDndAsyncAddsPhoneNumberAndApiKey() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"phone_number":"2348012345678","status":"DND","network":"MTN","dnd_active":true}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.CheckDndAsync( + new CheckDndRequest + { + PhoneNumber = "2348012345678", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal( + "https://example.test/api/check/dnd?phone_number=2348012345678&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal("2348012345678", response.PhoneNumber); + Assert.Equal("DND", response.Status); + Assert.Equal("MTN", response.Network); + Assert.True(response.DndActive); + } + + [Fact] + public async Task QueryNumberAsyncAddsCountryCodeAndDeserializesStatus() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"number":"2348012345678","status":"active","network":"MTN","country_code":"NG","country_name":"Nigeria","ported":false}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.QueryNumberAsync( + new QueryNumberRequest + { + PhoneNumber = "2348012345678", + CountryCode = "NG", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal( + "https://example.test/api/insight/number/query?phone_number=2348012345678&country_code=NG&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal("active", response.Status); + Assert.Equal("MTN", response.Network); + Assert.Equal("Nigeria", response.CountryName); + Assert.False(response.Ported); + } + + [Fact] + public async Task GetMessageHistoryAsyncAddsFiltersAndDeserializesContent() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "content": [ + { + "message_id": "msg-123", + "sender": "Termii", + "receiver": "2348012345678", + "message": "Hello", + "amount": "4.25", + "reroute": 0, + "status": "sent", + "sms_type": "plain", + "send_by": "api", + "created_at": "2026-06-13 10:00:00", + "updated_at": "2026-06-13 10:01:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "size": 15, + "number": 0, + "first": true, + "last": true, + "empty": false + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.GetMessageHistoryAsync( + new GetMessageHistoryRequest + { + Page = 0, + Size = 15, + Sender = "Termii", + Receiver = "2348012345678", + Status = "sent", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var message = Assert.Single(response.Content); + + Assert.Equal( + "https://example.test/api/sms/inbox?page=0&size=15&sender=Termii&receiver=2348012345678&status=sent&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal(1, response.TotalElements); + Assert.Equal("msg-123", message.MessageId); + Assert.Equal("Termii", message.Sender); + Assert.Equal("2348012345678", message.Receiver); + Assert.Equal(4.25m, message.Amount); + Assert.Equal("sent", message.Status); + } + + [Fact] + public async Task GetMessageAnalyticsAsyncAddsFiltersAndDeserializesFlexibleResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"sent":12,"delivered":10,"failed":1,"pending":1,"unknown_metric":5}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.GetMessageAnalyticsAsync( + new GetMessageAnalyticsRequest + { + MessageId = "msg-123", + DateFrom = "2026-06-01", + DateTo = "2026-06-13", + PhoneNumber = "2348012345678", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal( + "https://example.test/api/sms/history/analytics?message_id=msg-123&date_from=2026-06-01&date_to=2026-06-13&phone_number=2348012345678&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal(12, response.Sent); + Assert.Equal(10, response.Delivered); + Assert.Equal(1, response.Failed); + Assert.Equal(1, response.Pending); + Assert.NotNull(response.AdditionalData); + Assert.Equal(5, response.AdditionalData["unknown_metric"].GetInt32()); + } + + [Fact] + public async Task CheckDndAsyncRejectsMissingPhoneNumber() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Insights.CheckDndAsync( + new CheckDndRequest + { + PhoneNumber = "", + }, + CancellationToken.None)); + } +} diff --git a/tests/Termii.Tests/TermiiMessagingClientTests.cs b/tests/Termii.Tests/TermiiMessagingClientTests.cs new file mode 100644 index 0000000..b9844f5 --- /dev/null +++ b/tests/Termii.Tests/TermiiMessagingClientTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiMessagingClientTests +{ + [Fact] + public async Task SendAsyncPostsMessageBodyAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "code": "ok", + "balance": "42.50", + "message_id": "12345", + "message": "Successfully Sent", + "user": "termii-user", + "message_id_str": "12345" + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Messaging.SendAsync( + new SendMessageRequest + { + To = "2348012345678", + From = "Termii", + Sms = "Hello from the SDK", + Type = TermiiMessageType.Unicode, + Channel = TermiiMessageChannel.Dnd, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("to").GetString()); + Assert.Equal("Termii", body.RootElement.GetProperty("from").GetString()); + Assert.Equal("Hello from the SDK", body.RootElement.GetProperty("sms").GetString()); + Assert.Equal("unicode", body.RootElement.GetProperty("type").GetString()); + Assert.Equal("dnd", body.RootElement.GetProperty("channel").GetString()); + + Assert.Equal("ok", response.Code); + Assert.Equal(42.50m, response.Balance); + Assert.Equal("12345", response.MessageId); + Assert.Equal("Successfully Sent", response.Message); + Assert.Equal("termii-user", response.User); + Assert.Equal("12345", response.MessageIdString); + } + + [Fact] + public async Task SendAsyncIncludesWhatsAppMedia() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Messaging.SendAsync( + new SendMessageRequest + { + To = "2348012345678", + From = "Termii", + Sms = "A document for you", + Channel = TermiiMessageChannel.WhatsApp, + Media = new TermiiMessageMedia + { + Url = "https://example.test/file.pdf", + Caption = "Termii SDK", + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + var media = body.RootElement.GetProperty("media"); + + Assert.Equal("whatsapp", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal("https://example.test/file.pdf", media.GetProperty("url").GetString()); + Assert.Equal("Termii SDK", media.GetProperty("caption").GetString()); + } + + [Fact] + public async Task SendBulkAsyncPostsBulkMessageBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Messaging.SendBulkAsync( + new SendBulkMessageRequest + { + To = new[] { "2348012345678", "2348098765432" }, + From = "Termii", + Sms = "Hello everyone", + Channel = TermiiMessageChannel.Generic, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/send/bulk", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("to")[0].GetString()); + Assert.Equal("2348098765432", body.RootElement.GetProperty("to")[1].GetString()); + Assert.Equal("generic", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal("plain", body.RootElement.GetProperty("type").GetString()); + } + + [Fact] + public async Task SendAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Messaging.SendAsync( + new SendMessageRequest + { + To = "2348012345678", + From = "", + Sms = "Hello", + }, + CancellationToken.None)); + } + + [Fact] + public async Task SendBulkAsyncRejectsMissingRecipients() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Messaging.SendBulkAsync( + new SendBulkMessageRequest + { + To = Array.Empty(), + From = "Termii", + Sms = "Hello", + }, + CancellationToken.None)); + } +} diff --git a/tests/Termii.Tests/TermiiNumberClientTests.cs b/tests/Termii.Tests/TermiiNumberClientTests.cs new file mode 100644 index 0000000..ebdb952 --- /dev/null +++ b/tests/Termii.Tests/TermiiNumberClientTests.cs @@ -0,0 +1,68 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiNumberClientTests +{ + [Fact] + public async Task SendAsyncPostsNumberMessageBodyAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "code": "ok", + "balance": "38.75", + "message_id": "number-12345", + "message": "Successfully Sent", + "user": "termii-user", + "message_id_str": "number-12345" + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Numbers.SendAsync( + new SendNumberMessageRequest + { + To = "2348012345678", + Sms = "Hello from the Number API", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/number/send", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("to").GetString()); + Assert.Equal("Hello from the Number API", body.RootElement.GetProperty("sms").GetString()); + Assert.False(body.RootElement.TryGetProperty("from", out _)); + + Assert.Equal("ok", response.Code); + Assert.Equal(38.75m, response.Balance); + Assert.Equal("number-12345", response.MessageId); + Assert.Equal("Successfully Sent", response.Message); + Assert.Equal("termii-user", response.User); + Assert.Equal("number-12345", response.MessageIdString); + } + + [Fact] + public async Task SendAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Numbers.SendAsync( + new SendNumberMessageRequest + { + To = "2348012345678", + Sms = "", + }, + CancellationToken.None)); + } +} diff --git a/tests/Termii.Tests/TermiiSenderIdClientTests.cs b/tests/Termii.Tests/TermiiSenderIdClientTests.cs new file mode 100644 index 0000000..6fdeb27 --- /dev/null +++ b/tests/Termii.Tests/TermiiSenderIdClientTests.cs @@ -0,0 +1,110 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiSenderIdClientTests +{ + [Fact] + public async Task GetAsyncAddsApiKeyAndDeserializesSenderIds() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "content": [ + { + "sender_id": "Termii", + "status": "active", + "country": "NG", + "company": "Teesoft Tech", + "usecase": "Transactional alerts", + "createdAt": "2026-06-13 10:00:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "size": 15, + "number": 0, + "first": true, + "last": true, + "empty": false + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.SenderIds.GetAsync( + new GetSenderIdsRequest + { + SenderId = "Termii", + Status = "active", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var senderId = Assert.Single(response.Content); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal( + "https://example.test/api/sender-id?sender_id=Termii&status=active&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal(1, response.TotalElements); + Assert.Equal("Termii", senderId.SenderId); + Assert.Equal("active", senderId.Status); + Assert.Equal("NG", senderId.Country); + Assert.Equal("Teesoft Tech", senderId.Company); + Assert.Equal("Transactional alerts", senderId.UseCase); + Assert.Equal("2026-06-13 10:00:00", senderId.CreatedAt); + } + + [Fact] + public async Task RequestAsyncPostsRequestBodyAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Sender ID request received"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.SenderIds.RequestAsync( + new RequestSenderIdRequest + { + SenderId = "Termii", + UseCase = "Transactional alerts", + Company = "Teesoft Tech", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sender-id/request", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("Termii", body.RootElement.GetProperty("sender_id").GetString()); + Assert.Equal("Transactional alerts", body.RootElement.GetProperty("use_case").GetString()); + Assert.Equal("Teesoft Tech", body.RootElement.GetProperty("company").GetString()); + Assert.Equal("ok", response.Code); + Assert.Equal("Sender ID request received", response.Message); + } + + [Fact] + public async Task RequestAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.SenderIds.RequestAsync( + new RequestSenderIdRequest + { + SenderId = "", + UseCase = "Transactional alerts", + Company = "Teesoft Tech", + }, + CancellationToken.None)); + } +} diff --git a/tests/Termii.Tests/TermiiTokenClientTests.cs b/tests/Termii.Tests/TermiiTokenClientTests.cs new file mode 100644 index 0000000..56c2229 --- /dev/null +++ b/tests/Termii.Tests/TermiiTokenClientTests.cs @@ -0,0 +1,231 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiTokenClientTests +{ + [Fact] + public async Task SendAsyncPostsTokenBodyAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"smsStatus":"Message Sent","to":"2348012345678","pinId":"pin-123","message_id_str":"msg-123","status":"success"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Tokens.SendAsync( + new SendTokenRequest + { + To = "2348012345678", + From = "Termii", + Channel = TermiiMessageChannel.Dnd, + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6, + PinPlaceholder = "< 1234 >", + MessageText = "Your pin is < 1234 >", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/otp/send", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("NUMERIC", body.RootElement.GetProperty("message_type").GetString()); + Assert.Equal("NUMERIC", body.RootElement.GetProperty("pin_type").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("to").GetString()); + Assert.Equal("Termii", body.RootElement.GetProperty("from").GetString()); + Assert.Equal("dnd", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal(3, body.RootElement.GetProperty("pin_attempts").GetInt32()); + Assert.Equal(10, body.RootElement.GetProperty("pin_time_to_live").GetInt32()); + Assert.Equal(6, body.RootElement.GetProperty("pin_length").GetInt32()); + Assert.Equal("< 1234 >", body.RootElement.GetProperty("pin_placeholder").GetString()); + Assert.Equal("Your pin is < 1234 >", body.RootElement.GetProperty("message_text").GetString()); + + Assert.Equal("Message Sent", response.SmsStatus); + Assert.Equal("pin-123", response.PinId); + Assert.Equal("msg-123", response.MessageIdString); + Assert.Equal("success", response.Status); + } + + [Fact] + public async Task VerifyAsyncPostsPinDetails() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"pinId":"pin-123","verified":"true","msisdn":"2348012345678"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Tokens.VerifyAsync( + new VerifyTokenRequest + { + PinId = "pin-123", + Pin = "123456", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/verify", request.RequestUri!.ToString()); + Assert.Equal("pin-123", body.RootElement.GetProperty("pin_id").GetString()); + Assert.Equal("123456", body.RootElement.GetProperty("pin").GetString()); + Assert.Equal("true", response.Verified); + Assert.Equal("2348012345678", response.Msisdn); + } + + [Fact] + public async Task GenerateAsyncPostsInAppTokenBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"phone_number":"2348012345678","otp":"123456","pin_id":"pin-123"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Tokens.GenerateAsync( + new GenerateTokenRequest + { + PinType = TermiiTokenPinType.Alphanumeric, + PhoneNumber = "2348012345678", + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/generate", request.RequestUri!.ToString()); + Assert.Equal("ALPHANUMERIC", body.RootElement.GetProperty("pin_type").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal("123456", response.Otp); + Assert.Equal("pin-123", response.PinId); + } + + [Fact] + public async Task SendVoiceAsyncPostsVoiceTokenBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.SendVoiceAsync( + new SendVoiceTokenRequest + { + PhoneNumber = "2348012345678", + PinAttempts = 2, + PinTimeToLive = 5, + PinLength = 4, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/send/voice", request.RequestUri!.ToString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal(2, body.RootElement.GetProperty("pin_attempts").GetInt32()); + } + + [Fact] + public async Task CallAsyncPostsVoiceCallTokenBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.CallAsync( + new VoiceCallTokenRequest + { + PhoneNumber = "2348012345678", + Code = "123456", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/call", request.RequestUri!.ToString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal("123456", body.RootElement.GetProperty("code").GetString()); + } + + [Fact] + public async Task SendEmailAsyncPostsEmailTokenBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.SendEmailAsync( + new SendEmailTokenRequest + { + EmailAddress = "person@example.com", + Code = "123456", + EmailConfigurationId = "email-config", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/email/otp/send", request.RequestUri!.ToString()); + Assert.Equal("person@example.com", body.RootElement.GetProperty("email_address").GetString()); + Assert.Equal("123456", body.RootElement.GetProperty("code").GetString()); + Assert.Equal("email-config", body.RootElement.GetProperty("email_configuration_id").GetString()); + } + + [Fact] + public async Task SendWhatsAppAsyncPostsWhatsAppOtpMessage() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.SendWhatsAppAsync( + new SendWhatsAppTokenRequest + { + To = "2348012345678", + From = "Termii", + Sms = "Your code is 123456", + Type = TermiiMessageType.Unicode, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString()); + Assert.Equal("whatsapp_otp", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal("unicode", body.RootElement.GetProperty("type").GetString()); + Assert.Equal("Your code is 123456", body.RootElement.GetProperty("sms").GetString()); + } + + [Fact] + public async Task SendAsyncRejectsInvalidPinLength() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Tokens.SendAsync( + new SendTokenRequest + { + To = "2348012345678", + From = "Termii", + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 3, + PinPlaceholder = "< 1234 >", + MessageText = "Your pin is < 1234 >", + }, + CancellationToken.None)); + } +} diff --git a/tests/Termii.Tests/TermiiWebhookEventTests.cs b/tests/Termii.Tests/TermiiWebhookEventTests.cs new file mode 100644 index 0000000..823f0a9 --- /dev/null +++ b/tests/Termii.Tests/TermiiWebhookEventTests.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Termii; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiWebhookEventTests +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, + }; + + [Fact] + public void CanDeserializeDeliveryReportPayload() + { + var webhookEvent = JsonSerializer.Deserialize( + """ + { + "event": "message.status", + "message_id": "msg-123", + "message_id_str": "msg-123", + "status": "delivered", + "to": "2348012345678", + "from": "Termii", + "channel": "generic", + "network": "MTN", + "amount": "4.25", + "delivered_at": "2026-06-14 09:00:00", + "provider_reference": "provider-123" + } + """, + JsonSerializerOptions); + + Assert.NotNull(webhookEvent); + Assert.Equal("message.status", webhookEvent.Event); + Assert.Equal("msg-123", webhookEvent.MessageId); + Assert.Equal("delivered", webhookEvent.Status); + Assert.Equal("2348012345678", webhookEvent.To); + Assert.Equal("Termii", webhookEvent.From); + Assert.Equal("generic", webhookEvent.Channel); + Assert.Equal("MTN", webhookEvent.Network); + Assert.Equal(4.25m, webhookEvent.Amount); + Assert.Equal("2026-06-14 09:00:00", webhookEvent.DeliveredAt); + Assert.NotNull(webhookEvent.AdditionalData); + Assert.Equal("provider-123", webhookEvent.AdditionalData["provider_reference"].GetString()); + } + + [Fact] + public void CanDeserializeFailedReportPayload() + { + var webhookEvent = JsonSerializer.Deserialize( + """ + { + "type": "delivery_report", + "message_id": "msg-456", + "status": "failed", + "receiver": "2348012345678", + "sender": "Termii", + "error_code": "3001", + "error_message": "Insufficient balance", + "done_date": "2026-06-14 09:05:00" + } + """, + JsonSerializerOptions); + + Assert.NotNull(webhookEvent); + Assert.Equal("delivery_report", webhookEvent.Type); + Assert.Equal("msg-456", webhookEvent.MessageId); + Assert.Equal("failed", webhookEvent.Status); + Assert.Equal("2348012345678", webhookEvent.Receiver); + Assert.Equal("Termii", webhookEvent.Sender); + Assert.Equal("3001", webhookEvent.ErrorCode); + Assert.Equal("Insufficient balance", webhookEvent.ErrorMessage); + Assert.Equal("2026-06-14 09:05:00", webhookEvent.DoneDate); + } +} diff --git a/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs b/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs new file mode 100644 index 0000000..ae50c00 --- /dev/null +++ b/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Text.Json; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiWhatsAppTemplateClientTests +{ + [Fact] + public async Task SendWhatsAppTemplateAsyncPostsTemplateBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Template accepted","message_id":"template-123","status":"queued"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Messaging.SendWhatsAppTemplateAsync( + new SendWhatsAppTemplateRequest + { + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-123", + TemplateName = "order_update", + Language = "en", + Variables = new Dictionary + { + ["first_name"] = JsonDocument.Parse("\"Ada\"").RootElement.Clone(), + ["order_id"] = JsonDocument.Parse("\"ORD-123\"").RootElement.Clone(), + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/send/template", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal("device-123", body.RootElement.GetProperty("device_id").GetString()); + Assert.Equal("template-123", body.RootElement.GetProperty("template_id").GetString()); + Assert.Equal("order_update", body.RootElement.GetProperty("template_name").GetString()); + Assert.Equal("en", body.RootElement.GetProperty("language").GetString()); + Assert.Equal("Ada", body.RootElement.GetProperty("variables").GetProperty("first_name").GetString()); + + Assert.Equal("ok", response.Code); + Assert.Equal("Template accepted", response.Message); + Assert.Equal("template-123", response.MessageId); + Assert.Equal("queued", response.Status); + } + + [Fact] + public async Task SendWhatsAppTemplateMediaAsyncPostsMediaTemplateBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"message":"Media template accepted","status":"queued"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Messaging.SendWhatsAppTemplateMediaAsync( + new SendWhatsAppTemplateMediaRequest + { + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-media-123", + Language = "en", + Media = new WhatsAppTemplateMedia + { + Type = "document", + Url = "https://example.test/invoice.pdf", + Caption = "Invoice", + FileName = "invoice.pdf", + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + var media = body.RootElement.GetProperty("media"); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/send/template/media", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("template-media-123", body.RootElement.GetProperty("template_id").GetString()); + Assert.Equal("document", media.GetProperty("type").GetString()); + Assert.Equal("https://example.test/invoice.pdf", media.GetProperty("url").GetString()); + Assert.Equal("Invoice", media.GetProperty("caption").GetString()); + Assert.Equal("invoice.pdf", media.GetProperty("filename").GetString()); + Assert.Equal("Media template accepted", response.Message); + } + + [Fact] + public async Task SendWhatsAppTemplateAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Messaging.SendWhatsAppTemplateAsync( + new SendWhatsAppTemplateRequest + { + PhoneNumber = "", + DeviceId = "device-123", + TemplateId = "template-123", + }, + CancellationToken.None)); + } + + [Fact] + public async Task SendWhatsAppTemplateMediaAsyncRejectsMissingMediaUrl() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Messaging.SendWhatsAppTemplateMediaAsync( + new SendWhatsAppTemplateMediaRequest + { + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-123", + Media = new WhatsAppTemplateMedia + { + Type = "image", + Url = "", + }, + }, + CancellationToken.None)); + } +}