Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/CCAI.NET/SMS/SMSService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ public async Task<SMSResponse> 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,
Expand Down
134 changes: 134 additions & 0 deletions tests/CCAI.NET.Tests/SMS/SMSCustomDataWebhookTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/// <summary>
/// Capture the campaign body handed to the client and return a holder for it
/// </summary>
private StrongBox<object?> SetupCapture()
{
var capturedRequestBody = new StrongBox<object?>(null);

_mockClient
.Setup(c => c.RequestAsync<SMSResponse>(
It.IsAny<HttpMethod>(),
It.IsAny<string>(),
It.IsAny<object>(),
It.IsAny<CancellationToken>(),
It.IsAny<Dictionary<string, string>>()))
.Callback<HttpMethod, string, object, CancellationToken, Dictionary<string, string>>(
(method, url, body, token, headers) => capturedRequestBody.Value = body)
.ReturnsAsync(new SMSResponse { Id = "msg-123", Status = "sent" });

return capturedRequestBody;
}

/// <summary>
/// Serialize the captured campaign body and return its "accounts" array as sent on the wire
/// </summary>
private static JsonElement GetSentAccounts(StrongBox<object?> capturedRequestBody)
{
Assert.NotNull(capturedRequestBody.Value);

var requestJson = JsonSerializer.Serialize(capturedRequestBody.Value);
using var document = JsonDocument.Parse(requestJson);

return document.RootElement.GetProperty("accounts").Clone();
}
}