feat(sms): Transport recipient PhoneNumber + SmsConfig round-trip (S10)

This commit is contained in:
Joseph Doherty
2026-06-19 10:39:12 -04:00
parent f0c69aad83
commit cdfd0ffbd2
6 changed files with 438 additions and 10 deletions
@@ -200,4 +200,130 @@ public sealed class BundleDtoSerializationTests
Assert.NotNull(content.Instances);
Assert.Empty(content.Instances);
}
// --- S10: SMS recipient PhoneNumber + SmsConfig wire format ---------------
[Fact]
public void LegacyRecipient_MissingPhoneNumber_DeserializesToNull()
{
// A pre-SMS bundle: notification-list recipients carry only Name + Email,
// with no PhoneNumber field present in the JSON text at all.
const string legacyJson = """
{
"TemplateFolders": [],
"Templates": [],
"SharedScripts": [],
"ExternalSystems": [],
"DatabaseConnections": [],
"NotificationLists": [
{
"Name": "alerts",
"Type": "Email",
"Recipients": [ { "Name": "Ops", "EmailAddress": "ops@example.com" } ]
}
],
"SmtpConfigs": [],
"ApiMethods": []
}
""";
var content = JsonSerializer.Deserialize<BundleContentDto>(legacyJson, Options);
Assert.NotNull(content);
var recipient = Assert.Single(Assert.Single(content!.NotificationLists).Recipients);
Assert.Equal("Ops", recipient.Name);
Assert.Equal("ops@example.com", recipient.EmailAddress);
// The backward-compat invariant: an absent PhoneNumber comes back null.
Assert.Null(recipient.PhoneNumber);
}
[Fact]
public void LegacyContent_MissingSmsConfigsArray_DeserializesToEmpty_NotNull()
{
// A pre-SMS bundle has no smsConfigs field; the importer must see an empty
// (never null) collection — same forward-compat invariant as the M8 arrays.
const string legacyJson = """
{
"TemplateFolders": [],
"Templates": [],
"SharedScripts": [],
"ExternalSystems": [],
"DatabaseConnections": [],
"NotificationLists": [],
"SmtpConfigs": [],
"ApiMethods": []
}
""";
var content = JsonSerializer.Deserialize<BundleContentDto>(legacyJson, Options);
Assert.NotNull(content);
Assert.NotNull(content!.SmsConfigs);
Assert.Empty(content.SmsConfigs);
}
[Fact]
public void DefaultConstructed_SmsConfigs_IsEmpty_NotNull()
{
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
Assert.NotNull(content.SmsConfigs);
Assert.Empty(content.SmsConfigs);
}
[Fact]
public void SmsConfig_AuthToken_RidesSecretsBlock_NotCleartextPublicFields()
{
// The auth token must travel only inside the SecretsBlock, exactly like the
// SMTP credentials test pattern — it must NOT leak into any public DTO field.
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>())
{
SmsConfigs = new[]
{
new SmsConfigDto(
AccountSid: "AC123",
FromNumber: "+15550001111",
MessagingServiceSid: "MG999",
ApiBaseUrl: "https://api.twilio.com",
ConnectionTimeoutSeconds: 20,
MaxRetries: 7,
RetryDelay: TimeSpan.FromSeconds(45),
Secrets: new SecretsBlock(new Dictionary<string, string>
{
["AuthToken"] = "twilio-super-secret",
})),
},
};
var json = JsonSerializer.Serialize(content, Options);
// The secret appears exactly once — inside the SecretsBlock — and the
// round-trip restores it.
Assert.Contains("twilio-super-secret", json, StringComparison.Ordinal);
Assert.Contains("\"AuthToken\"", json, StringComparison.Ordinal);
var roundTripped = JsonSerializer.Deserialize<BundleContentDto>(json, Options);
Assert.NotNull(roundTripped);
var sms = Assert.Single(roundTripped!.SmsConfigs);
Assert.Equal("AC123", sms.AccountSid);
Assert.Equal("+15550001111", sms.FromNumber);
Assert.NotNull(sms.Secrets);
Assert.Equal("twilio-super-secret", sms.Secrets!.Values["AuthToken"]);
}
}