From 2e672d23f6d6a4e433439a6771940b34c7216d87 Mon Sep 17 00:00:00 2001 From: eastagiletracker <310448263+eastagiletracker@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:06:19 +0000 Subject: [PATCH] fix: apply campaign-level customData to every SMS recipient SMSService.SendAsync built the outgoing SMSCampaign from Accounts, Message, Title and SenderPhone only, so SMSRequest.CustomData never reached the wire. Callers of the multi-recipient overload lost the value silently; only SendSingleAsync worked, because CreateSingle copies customData onto the single Account it builds, where it serializes as messageData. Apply a campaign-level CustomData to every account that does not define its own. Per-account values keep precedence and the caller's Account instances are left untouched. --- src/CCAI.NET/SMS/SMSService.cs | 11 ++ .../SMS/SMSCustomDataWebhookTests.cs | 134 ++++++++++++++++++ 2 files changed, 145 insertions(+) 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