diff --git a/src/CCAI.NET/SMS/SMSService.cs b/src/CCAI.NET/SMS/SMSService.cs index 63e7976..93c4ba0 100644 --- a/src/CCAI.NET/SMS/SMSService.cs +++ b/src/CCAI.NET/SMS/SMSService.cs @@ -123,6 +123,17 @@ public async Task SendAsync(SMSRequest request, CancellationToken c // Prepare the endpoint and data var endpoint = $"/clients/{_client.GetClientId()}/campaigns/direct"; + // The API carries custom data per recipient (as "messageData"), so a campaign-level + // CustomData is applied to every account that does not already define its own + if (!string.IsNullOrEmpty(request.CustomData)) + { + accountsList = accountsList + .Select(account => account.CustomData is null + ? account with { CustomData = request.CustomData } + : account) + .ToList(); + } + var campaignData = new SMSCampaign { Accounts = accountsList, diff --git a/tests/CCAI.NET.Tests/SMS/SMSCustomDataWebhookTests.cs b/tests/CCAI.NET.Tests/SMS/SMSCustomDataWebhookTests.cs index 8534c2b..4d8918d 100644 --- a/tests/CCAI.NET.Tests/SMS/SMSCustomDataWebhookTests.cs +++ b/tests/CCAI.NET.Tests/SMS/SMSCustomDataWebhookTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System.Net; +using System.Runtime.CompilerServices; using System.Text.Json; using CCAI.NET.SMS; using Moq; @@ -194,4 +195,137 @@ public void SMSRequest_CreateSingle_WithCustomData_SetsAllFields() Assert.Equal(customAccountId, account.CustomAccountId); Assert.Equal(customData, account.CustomData); } + + [Fact] + public async Task SendAsync_WithCampaignLevelCustomData_AppliesItToEveryAccount() + { + // Arrange + var customData = "OrderBatch-42"; + + var accounts = new[] + { + new Account { FirstName = "John", LastName = "Test", Phone = "+15551234567" }, + new Account { FirstName = "Jane", LastName = "Test", Phone = "+15557654321" } + }; + + var capturedRequestBody = SetupCapture(); + + // Act + await _smsService.SendAsync( + accounts: accounts, + message: "Hello ${FirstName}!", + title: "Custom Data Campaign", + customData: customData); + + // Assert + var sentAccounts = GetSentAccounts(capturedRequestBody); + Assert.Equal(2, sentAccounts.GetArrayLength()); + + foreach (var sentAccount in sentAccounts.EnumerateArray()) + { + Assert.Equal(customData, sentAccount.GetProperty("messageData").GetString()); + } + } + + [Fact] + public async Task SendAsync_WithCampaignLevelCustomData_DoesNotOverrideAccountCustomData() + { + // Arrange + var accounts = new[] + { + new Account { FirstName = "John", LastName = "Test", Phone = "+15551234567", CustomData = "PerAccount-1" }, + new Account { FirstName = "Jane", LastName = "Test", Phone = "+15557654321" } + }; + + var capturedRequestBody = SetupCapture(); + + // Act + await _smsService.SendAsync( + accounts: accounts, + message: "Hello ${FirstName}!", + title: "Custom Data Campaign", + customData: "CampaignLevel"); + + // Assert + var sentAccounts = GetSentAccounts(capturedRequestBody); + Assert.Equal("PerAccount-1", sentAccounts[0].GetProperty("messageData").GetString()); + Assert.Equal("CampaignLevel", sentAccounts[1].GetProperty("messageData").GetString()); + } + + [Fact] + public async Task SendAsync_WithoutCustomData_LeavesMessageDataUnset() + { + // Arrange + var accounts = new[] + { + new Account { FirstName = "John", LastName = "Test", Phone = "+15551234567" } + }; + + var capturedRequestBody = SetupCapture(); + + // Act + await _smsService.SendAsync( + accounts: accounts, + message: "Hello ${FirstName}!", + title: "No Custom Data Campaign"); + + // Assert + var sentAccounts = GetSentAccounts(capturedRequestBody); + Assert.Equal(JsonValueKind.Null, sentAccounts[0].GetProperty("messageData").ValueKind); + } + + [Fact] + public async Task SendAsync_WithCampaignLevelCustomData_DoesNotMutateCallerAccounts() + { + // Arrange + var account = new Account { FirstName = "John", LastName = "Test", Phone = "+15551234567" }; + var accounts = new[] { account }; + + SetupCapture(); + + // Act + await _smsService.SendAsync( + accounts: accounts, + message: "Hello ${FirstName}!", + title: "Custom Data Campaign", + customData: "OrderBatch-42"); + + // Assert + Assert.Null(account.CustomData); + Assert.Null(accounts[0].CustomData); + } + + /// + /// Capture the campaign body handed to the client and return a holder for it + /// + private StrongBox SetupCapture() + { + var capturedRequestBody = new StrongBox(null); + + _mockClient + .Setup(c => c.RequestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback>( + (method, url, body, token, headers) => capturedRequestBody.Value = body) + .ReturnsAsync(new SMSResponse { Id = "msg-123", Status = "sent" }); + + return capturedRequestBody; + } + + /// + /// Serialize the captured campaign body and return its "accounts" array as sent on the wire + /// + private static JsonElement GetSentAccounts(StrongBox capturedRequestBody) + { + Assert.NotNull(capturedRequestBody.Value); + + var requestJson = JsonSerializer.Serialize(capturedRequestBody.Value); + using var document = JsonDocument.Parse(requestJson); + + return document.RootElement.GetProperty("accounts").Clone(); + } } \ No newline at end of file