From 796d1521ae7e501b10186a6a2effe5c1402b0a07 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sun, 14 Jun 2026 08:59:42 +0100 Subject: [PATCH] Add Contacts API support --- README.md | 30 +++ docs/API_COVERAGE.md | 4 + src/Termii/Contacts/AddContactRequest.cs | 29 +++ src/Termii/Contacts/ContactListResponse.cs | 84 +++++++ .../Contacts/ContactOperationResponse.cs | 17 ++ src/Termii/Contacts/ContactResponse.cs | 13 ++ src/Termii/Contacts/ContactUploadResponse.cs | 6 + src/Termii/Contacts/GetContactsRequest.cs | 21 ++ src/Termii/Contacts/ITermiiContactClient.cs | 22 ++ src/Termii/Contacts/TermiiContactClient.cs | 105 +++++++++ src/Termii/Contacts/UploadContactsRequest.cs | 27 +++ src/Termii/TermiiClient.cs | 3 + src/Termii/TermiiJsonHttpPipeline.cs | 82 +++++++ .../TermiiClientIntegrationTests.cs | 1 + .../Termii.Tests/TermiiContactClientTests.cs | 212 ++++++++++++++++++ 15 files changed, 656 insertions(+) create mode 100644 src/Termii/Contacts/AddContactRequest.cs create mode 100644 src/Termii/Contacts/ContactListResponse.cs create mode 100644 src/Termii/Contacts/ContactOperationResponse.cs create mode 100644 src/Termii/Contacts/ContactResponse.cs create mode 100644 src/Termii/Contacts/ContactUploadResponse.cs create mode 100644 src/Termii/Contacts/GetContactsRequest.cs create mode 100644 src/Termii/Contacts/ITermiiContactClient.cs create mode 100644 src/Termii/Contacts/TermiiContactClient.cs create mode 100644 src/Termii/Contacts/UploadContactsRequest.cs create mode 100644 tests/Termii.Tests/TermiiContactClientTests.cs diff --git a/README.md b/README.md index 2684b68..3be1f9d 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,35 @@ var phonebook = await client.Campaigns.CreatePhonebookAsync(new CreatePhonebookR }); ``` +## Contacts + +Add a single contact to a phonebook: + +```csharp +var contact = await client.Contacts.AddAsync("phonebook-123", new AddContactRequest +{ + PhoneNumber = "8123696237", + CountryCode = "234", + EmailAddress = "person@example.com", + FirstName = "Ada", + LastName = "Lovelace" +}); +``` + +Upload contacts from a CSV file: + +```csharp +await using var file = File.OpenRead("contacts.csv"); + +var upload = await client.Contacts.UploadAsync(new UploadContactsRequest +{ + PhonebookId = "phonebook-123", + CountryCode = "234", + File = file, + FileName = "contacts.csv" +}); +``` + ## Product Emails Send a product notification email: @@ -334,6 +363,7 @@ Implemented in the current SDK: - 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. +- Contacts: list, add, upload, and delete phonebook contacts. - Product emails: send template-based notification emails. See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index a74600d..f0a751a 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -64,6 +64,10 @@ The Termii docs describe a REST/JSON API and state that each account has its own | 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 | +| Contacts | Fetch contacts by phonebook | GET | `/api/phonebooks/{phonebook_id}/contacts` | Query | `TermiiClient.Contacts` | Implemented | #43 | Unit tests added | +| Contacts | Add single contact to phonebook | POST | `/api/phonebooks/{phonebook_id}/contacts` | JSON body | `TermiiClient.Contacts` | Implemented | #43 | Unit tests added | +| Contacts | Upload contacts CSV | POST | `/api/phonebooks/contacts/upload` | Multipart body | `TermiiClient.Contacts` | Implemented | #43 | Unit tests added | +| Contacts | Delete contact in phonebook | DELETE | `/api/phonebooks/{phonebook_id}/contacts` | Query | `TermiiClient.Contacts` | Implemented | #43 | 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 diff --git a/src/Termii/Contacts/AddContactRequest.cs b/src/Termii/Contacts/AddContactRequest.cs new file mode 100644 index 0000000..7de7770 --- /dev/null +++ b/src/Termii/Contacts/AddContactRequest.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class AddContactRequest +{ + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("country_code")] + public string? CountryCode { get; set; } + + [JsonPropertyName("email_address")] + public string? EmailAddress { get; set; } + + [JsonPropertyName("first_name")] + public string? FirstName { get; set; } + + [JsonPropertyName("last_name")] + public string? LastName { get; set; } + + [JsonPropertyName("company")] + public string? Company { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + } +} diff --git a/src/Termii/Contacts/ContactListResponse.cs b/src/Termii/Contacts/ContactListResponse.cs new file mode 100644 index 0000000..44b26f6 --- /dev/null +++ b/src/Termii/Contacts/ContactListResponse.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class ContactListResponse +{ + public IReadOnlyList Headers { get; set; } = Array.Empty(); + + public ContactPhonebookRecord? Phonebook { get; set; } + + public ContactListData? Data { get; set; } +} + +public sealed class ContactListData +{ + public IReadOnlyList Content { get; set; } = Array.Empty(); + + public int? TotalPages { get; set; } + + public int? TotalElements { get; set; } + + public int? Size { get; set; } + + public int? Number { get; set; } + + public bool? First { get; set; } + + public bool? Last { get; set; } + + public bool? Empty { get; set; } +} + +public sealed class ContactPhonebookRecord +{ + public string? Id { get; set; } + + public string? ApplicationId { get; set; } + + public string? Description { get; set; } + + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("phonebook_name")] + public string? PhonebookName { get; set; } + + [JsonPropertyName("total_contact")] + public int? TotalContact { get; set; } + + [JsonPropertyName("total_campaign")] + public int? TotalCampaign { get; set; } +} + +public sealed class ContactRecord +{ + public string? Id { get; set; } + + public string? Pid { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("email_address")] + public string? EmailAddress { get; set; } + + [JsonPropertyName("first_name")] + public string? FirstName { get; set; } + + [JsonPropertyName("last_name")] + public string? LastName { get; set; } + + [JsonPropertyName("company")] + public string? Company { get; set; } + + [JsonPropertyName("contact_list_key_value")] + public IReadOnlyList ContactListKeyValue { get; set; } = Array.Empty(); +} + +public sealed class ContactKeyValue +{ + public string? Key { get; set; } + + public string? Value { get; set; } +} diff --git a/src/Termii/Contacts/ContactOperationResponse.cs b/src/Termii/Contacts/ContactOperationResponse.cs new file mode 100644 index 0000000..d3d8edb --- /dev/null +++ b/src/Termii/Contacts/ContactOperationResponse.cs @@ -0,0 +1,17 @@ +namespace Termii; + +public sealed class ContactOperationResponse +{ + public int? Code { get; set; } + + public ContactOperationData? Data { get; set; } + + public string? Message { get; set; } + + public string? Status { get; set; } +} + +public sealed class ContactOperationData +{ + public string? Message { get; set; } +} diff --git a/src/Termii/Contacts/ContactResponse.cs b/src/Termii/Contacts/ContactResponse.cs new file mode 100644 index 0000000..f16de0d --- /dev/null +++ b/src/Termii/Contacts/ContactResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class ContactResponse +{ + [JsonPropertyName("Contact added successfully")] + public ContactRecord? ContactAddedSuccessfully { get; set; } + + public string? Message { get; set; } + + public ContactRecord? Data { get; set; } +} diff --git a/src/Termii/Contacts/ContactUploadResponse.cs b/src/Termii/Contacts/ContactUploadResponse.cs new file mode 100644 index 0000000..32d8e27 --- /dev/null +++ b/src/Termii/Contacts/ContactUploadResponse.cs @@ -0,0 +1,6 @@ +namespace Termii; + +public sealed class ContactUploadResponse +{ + public string? Message { get; set; } +} diff --git a/src/Termii/Contacts/GetContactsRequest.cs b/src/Termii/Contacts/GetContactsRequest.cs new file mode 100644 index 0000000..3f0c3d8 --- /dev/null +++ b/src/Termii/Contacts/GetContactsRequest.cs @@ -0,0 +1,21 @@ +namespace Termii; + +public sealed class GetContactsRequest +{ + public int? Page { get; set; } + + public int? Size { get; set; } + + internal string ToPath(string phonebookId) + { + TermiiRequestValidation.Required(phonebookId, nameof(phonebookId)); + + var query = new List(); + TermiiCampaignQueryString.AddIfPresent(query, "page", Page); + TermiiCampaignQueryString.AddIfPresent(query, "size", Size); + + return TermiiCampaignQueryString.Append( + $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}/contacts", + query); + } +} diff --git a/src/Termii/Contacts/ITermiiContactClient.cs b/src/Termii/Contacts/ITermiiContactClient.cs new file mode 100644 index 0000000..66e47d3 --- /dev/null +++ b/src/Termii/Contacts/ITermiiContactClient.cs @@ -0,0 +1,22 @@ +namespace Termii; + +public interface ITermiiContactClient +{ + Task GetAsync( + string phonebookId, + GetContactsRequest? request = null, + CancellationToken cancellationToken = default); + + Task AddAsync( + string phonebookId, + AddContactRequest request, + CancellationToken cancellationToken = default); + + Task UploadAsync( + UploadContactsRequest request, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + string phonebookId, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Contacts/TermiiContactClient.cs b/src/Termii/Contacts/TermiiContactClient.cs new file mode 100644 index 0000000..f1199a2 --- /dev/null +++ b/src/Termii/Contacts/TermiiContactClient.cs @@ -0,0 +1,105 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +internal sealed class TermiiContactClient : ITermiiContactClient +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiContactClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task GetAsync( + string phonebookId, + GetContactsRequest? request = null, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(phonebookId, nameof(phonebookId)); + + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetContactsRequest()).ToPath(phonebookId), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task AddAsync( + string phonebookId, + AddContactRequest request, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(phonebookId, nameof(phonebookId)); + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}/contacts", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task UploadAsync( + UploadContactsRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + var content = new MultipartFormDataContent(); + var fileContent = new StreamContent(request.File); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(request.ContentType); + content.Add(fileContent, "file", request.FileName); + + var contact = new + { + pid = request.PhonebookId, + country_code = request.CountryCode, + api_key = _pipeline.ApiKey, + }; + content.Add( + new StringContent(JsonSerializer.Serialize(contact, JsonSerializerOptions), Encoding.UTF8, "application/json"), + "contact"); + + return _pipeline.SendContentJsonAsync( + HttpMethod.Post, + "/api/phonebooks/contacts/upload", + content, + cancellationToken); + } + + public Task DeleteAsync( + string phonebookId, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(phonebookId, nameof(phonebookId)); + + return _pipeline.SendJsonAsync( + HttpMethod.Delete, + $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}/contacts", + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } +} diff --git a/src/Termii/Contacts/UploadContactsRequest.cs b/src/Termii/Contacts/UploadContactsRequest.cs new file mode 100644 index 0000000..423b93d --- /dev/null +++ b/src/Termii/Contacts/UploadContactsRequest.cs @@ -0,0 +1,27 @@ +namespace Termii; + +public sealed class UploadContactsRequest +{ + public string PhonebookId { get; set; } = string.Empty; + + public string CountryCode { get; set; } = string.Empty; + + public Stream File { get; set; } = Stream.Null; + + public string FileName { get; set; } = "contacts.csv"; + + public string ContentType { get; set; } = "text/csv"; + + internal void Validate() + { + TermiiRequestValidation.Required(PhonebookId, nameof(PhonebookId)); + TermiiRequestValidation.Required(CountryCode, nameof(CountryCode)); + TermiiRequestValidation.Required(FileName, nameof(FileName)); + TermiiRequestValidation.Required(ContentType, nameof(ContentType)); + + if (File is null || File == Stream.Null) + { + throw new ArgumentException("A contacts CSV file stream is required.", nameof(File)); + } + } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index 68f9572..3eb7dd1 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -40,6 +40,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp Tokens = new TermiiTokenClient(_pipeline); Insights = new TermiiInsightsClient(_pipeline); Campaigns = new TermiiCampaignClient(_pipeline); + Contacts = new TermiiContactClient(_pipeline); Emails = new TermiiEmailClient(_pipeline); } @@ -57,6 +58,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp public ITermiiCampaignClient Campaigns { get; } + public ITermiiContactClient Contacts { get; } + public ITermiiEmailClient Emails { get; } public void Dispose() diff --git a/src/Termii/TermiiJsonHttpPipeline.cs b/src/Termii/TermiiJsonHttpPipeline.cs index 3f156fa..1af1375 100644 --- a/src/Termii/TermiiJsonHttpPipeline.cs +++ b/src/Termii/TermiiJsonHttpPipeline.cs @@ -79,6 +79,61 @@ public async Task SendAsync( throw new TermiiApiException(statusCode, error.Message, error.Code, rawResponseBody); } + public async Task SendContentAsync( + HttpMethod method, + string path, + HttpContent content, + CancellationToken cancellationToken) + { + if (method is null) + { + throw new ArgumentNullException(nameof(method)); + } + + if (content is null) + { + throw new ArgumentNullException(nameof(content)); + } + + 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 request = new HttpRequestMessage(method, path) + { + Content = content, + }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + 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, @@ -105,6 +160,33 @@ public async Task SendJsonAsync( return value; } + public async Task SendContentJsonAsync( + HttpMethod method, + string path, + HttpContent content, + CancellationToken cancellationToken) + { + using var response = await SendContentAsync(method, path, content, 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; + } + + internal string ApiKey => _options.ApiKey; + private string AppendApiKey(string path) { var separator = path.IndexOf("?", StringComparison.Ordinal) >= 0 ? "&" : "?"; diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 820f433..bd7aabb 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -23,6 +23,7 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.NotNull(client.Tokens); Assert.NotNull(client.Insights); Assert.NotNull(client.Campaigns); + Assert.NotNull(client.Contacts); Assert.NotNull(client.Emails); } } diff --git a/tests/Termii.Tests/TermiiContactClientTests.cs b/tests/Termii.Tests/TermiiContactClientTests.cs new file mode 100644 index 0000000..f61cf09 --- /dev/null +++ b/tests/Termii.Tests/TermiiContactClientTests.cs @@ -0,0 +1,212 @@ +using System.Net; +using System.Text; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiContactClientTests +{ + [Fact] + public async Task GetAsyncAddsPhonebookIdFiltersAndApiKey() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "headers": ["phone number"], + "phonebook": { + "id": "pb-123", + "phonebook_name": "Customers", + "total_contact": 12 + }, + "data": { + "content": [ + { + "id": "contact-123", + "pid": "pb-123", + "phone_number": "2348139538813", + "contact_list_key_value": [ + { + "key": "phone number", + "value": "8139538813" + } + ] + } + ], + "totalElements": 1, + "totalPages": 1, + "size": 15, + "number": 0 + } + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Contacts.GetAsync( + "pb 123", + new GetContactsRequest + { + Page = 0, + Size = 15, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var contact = Assert.Single(response.Data!.Content); + var header = Assert.Single(response.Headers); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal( + "https://example.test/api/phonebooks/pb%20123/contacts?page=0&size=15&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal("phone number", header); + Assert.Equal("Customers", response.Phonebook!.PhonebookName); + Assert.Equal("contact-123", contact.Id); + Assert.Equal("2348139538813", contact.PhoneNumber); + Assert.Equal("8139538813", Assert.Single(contact.ContactListKeyValue).Value); + } + + [Fact] + public async Task AddAsyncPostsContactBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "Contact added successfully": { + "id": "contact-123", + "company": "Termii", + "phone_number": "2347068410455", + "email_address": "test@example.com", + "first_name": "Promise", + "last_name": "John" + } + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Contacts.AddAsync( + "pb-123", + new AddContactRequest + { + PhoneNumber = "8123696237", + CountryCode = "234", + EmailAddress = "test@example.com", + FirstName = "Promise", + LastName = "John", + Company = "Termii", + }, + 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/pb-123/contacts", request.RequestUri!.AbsoluteUri); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("8123696237", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal("234", body.RootElement.GetProperty("country_code").GetString()); + Assert.Equal("test@example.com", body.RootElement.GetProperty("email_address").GetString()); + Assert.Equal("Promise", body.RootElement.GetProperty("first_name").GetString()); + Assert.Equal("John", body.RootElement.GetProperty("last_name").GetString()); + Assert.Equal("Termii", body.RootElement.GetProperty("company").GetString()); + Assert.Equal("contact-123", response.ContactAddedSuccessfully!.Id); + Assert.Equal("2347068410455", response.ContactAddedSuccessfully.PhoneNumber); + } + + [Fact] + public async Task UploadAsyncPostsMultipartFileAndContactMetadata() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"message":"Your list is being uploaded in the background."}"""); + var client = TestTermiiClientFactory.Create(handler); + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes("phone number\n8123696237")); + + var response = await client.Contacts.UploadAsync( + new UploadContactsRequest + { + PhonebookId = "pb-123", + CountryCode = "234", + File = stream, + FileName = "contacts.csv", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var body = await request.Content!.ReadAsStringAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/phonebooks/contacts/upload", request.RequestUri!.AbsoluteUri); + Assert.Equal("multipart/form-data", request.Content.Headers.ContentType!.MediaType); + Assert.Contains("name=file", body, StringComparison.Ordinal); + Assert.Contains("filename=contacts.csv", body, StringComparison.Ordinal); + Assert.Contains("phone number", body, StringComparison.Ordinal); + Assert.Contains("8123696237", body, StringComparison.Ordinal); + Assert.Contains("name=contact", body, StringComparison.Ordinal); + Assert.Contains("application/json", body, StringComparison.Ordinal); + Assert.Contains("\"pid\":\"pb-123\"", body, StringComparison.Ordinal); + Assert.Contains("\"country_code\":\"234\"", body, StringComparison.Ordinal); + Assert.Contains("\"api_key\":\"test-api-key\"", body, StringComparison.Ordinal); + Assert.Equal("Your list is being uploaded in the background.", response.Message); + } + + [Fact] + public async Task DeleteAsyncAddsApiKeyToQueryString() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":200,"data":{"message":"Contact has been deleted"},"message":"Deleted Successfully","status":"success"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Contacts.DeleteAsync("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/contacts?api_key=test-api-key", request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal(200, response.Code); + Assert.Equal("Contact has been deleted", response.Data!.Message); + Assert.Equal("Deleted Successfully", response.Message); + Assert.Equal("success", response.Status); + } + + [Fact] + public async Task AddAsyncRejectsMissingPhoneNumber() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Contacts.AddAsync( + "pb-123", + new AddContactRequest + { + PhoneNumber = "", + }, + CancellationToken.None)); + } + + [Fact] + public async Task UploadAsyncRejectsMissingFile() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Contacts.UploadAsync( + new UploadContactsRequest + { + PhonebookId = "pb-123", + CountryCode = "234", + File = Stream.Null, + }, + CancellationToken.None)); + } +}