From cdfd0ffbd2609a69e266e124d1553de3ab1bcabc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 19 Jun 2026 10:39:12 -0400 Subject: [PATCH] feat(sms): Transport recipient PhoneNumber + SmsConfig round-trip (S10) --- .../Import/ArtifactDiff.cs | 4 +- .../Import/BundleImporter.cs | 22 ++- .../Serialization/EntityDtos.cs | 45 ++++- .../Serialization/EntitySerializer.cs | 67 ++++++- .../BundleDtoSerializationTests.cs | 126 ++++++++++++ .../Serialization/EntitySerializerTests.cs | 184 ++++++++++++++++++ 6 files changed, 438 insertions(+), 10 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs index 83156e36..336042bf 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs @@ -210,7 +210,9 @@ public sealed class ArtifactDiff incoming.Recipients, e => e.Name, i => i.Name, - (e, i) => e.EmailAddress == i.EmailAddress, + // S10: a recipient is unchanged only when BOTH contacts match — a phone + // number added/changed on an existing recipient is a real diff. + (e, i) => e.EmailAddress == i.EmailAddress && e.PhoneNumber == i.PhoneNumber, "Recipients", changes); diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 6c8a4d31..faa0b6a8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -2411,7 +2411,7 @@ public sealed class BundleImporter : IBundleImporter existing.Recipients.Clear(); foreach (var r in dto.Recipients) { - existing.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress)); + existing.Recipients.Add(BuildRecipient(r)); } await _notificationRepo.UpdateNotificationListAsync(existing, ct).ConfigureAwait(false); await _auditService.LogAsync(user, "Update", "NotificationList", existing.Id.ToString(), existing.Name, @@ -2438,11 +2438,29 @@ public sealed class BundleImporter : IBundleImporter var list = new NotificationList(overrideName ?? dto.Name) { Type = dto.Type }; foreach (var r in dto.Recipients) { - list.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress)); + list.Recipients.Add(BuildRecipient(r)); } return list; } + // S10: reconstruct a recipient carrying whichever contact(s) the bundle holds. + // Email-only and SMS-only round-trip exactly; a recipient with both keeps both. + // Build via the email ctor when an address is present (so the historical + // non-null-email path is unchanged) and additively restore the phone number; + // otherwise fall back to the SMS factory for phone-only recipients. + private static NotificationRecipient BuildRecipient(NotificationRecipientDto r) + { + if (r.EmailAddress is not null) + { + return new NotificationRecipient(r.Name, r.EmailAddress) + { + PhoneNumber = r.PhoneNumber, + }; + } + + return NotificationRecipient.ForSms(r.Name, r.PhoneNumber ?? string.Empty); + } + private async Task ApplySmtpConfigsAsync( IReadOnlyList dtos, Dictionary<(string, string), ImportResolution> map, diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs index 226c1e1c..8abca45e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs @@ -34,6 +34,13 @@ public sealed record EntityAggregate( public IReadOnlyList Sites { get; init; } = Array.Empty(); public IReadOnlyList DataConnections { get; init; } = Array.Empty(); public IReadOnlyList Instances { get; init; } = Array.Empty(); + + // SMS (S10): carried alongside SmtpConfigurations. Init-only with an empty + // default for the same source-compat reason as the M8 collections above — + // every existing positional `new EntityAggregate(...)` caller keeps compiling + // and never sees null; producers that resolve SMS config opt in via + // object-initializer. + public IReadOnlyList SmsConfigurations { get; init; } = Array.Empty(); } /// @@ -77,6 +84,17 @@ public sealed record BundleContentDto( public IReadOnlyList Sites { get; init; } = Array.Empty(); public IReadOnlyList DataConnections { get; init; } = Array.Empty(); public IReadOnlyList Instances { get; init; } = Array.Empty(); + + // SMS (S10): central-only SMS provider configs, carried alongside SmtpConfigs. + // Modeled as an init-only property with an empty default (NOT a positional ctor + // param) for the same two reasons as the M8 site/instance arrays above: + // 1. Forward-compat: deserializing a bundle written before SMS support leaves + // this at Array.Empty() — consumers always see an empty list, + // never null. + // 2. Source-compat: every existing positional `new BundleContentDto(...)` caller + // keeps compiling; producers that pack SMS configs opt in via the + // object-initializer. + public IReadOnlyList SmsConfigs { get; init; } = Array.Empty(); } /// @@ -171,7 +189,16 @@ public sealed record NotificationListDto( public sealed record NotificationRecipientDto( string Name, - string EmailAddress); + // S10: nullable so an SMS-only recipient (no email) round-trips faithfully + // instead of being coerced to an empty string. Email-only recipients (the + // historical case) still carry a non-null address; the entity enforces that at + // least one contact is present. + string? EmailAddress, + // S10: SMS recipients carry an E.164 phone number instead of (or in addition + // to) an email. Trailing + nullable so a bundle written before SMS support + // deserializes this as null (backward-compatible); the serializer's + // WhenWritingNull policy keeps it out of email-only bundles' JSON. + string? PhoneNumber = null); public sealed record SmtpConfigDto( string Host, @@ -185,6 +212,22 @@ public sealed record SmtpConfigDto( TimeSpan RetryDelay, SecretsBlock? Secrets); +/// +/// An SMS provider configuration (the SmsConfiguration entity). Mirrors +/// : all non-sensitive fields are carried directly and +/// the provider auth token rides inside so a future +/// "share without secrets" export can drop it as a unit. +/// +public sealed record SmsConfigDto( + string AccountSid, + string FromNumber, + string? MessagingServiceSid, + string? ApiBaseUrl, + int ConnectionTimeoutSeconds, + int MaxRetries, + TimeSpan RetryDelay, + SecretsBlock? Secrets); + // Legacy DTO: only deserialized from pre-C4 bundles so the importer can count and // ignore the keys they contain. New exports never emit an ApiKeys array. public sealed record ApiKeyDto( diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs index e5f7807b..5a736401 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs @@ -138,11 +138,17 @@ public sealed class EntitySerializer NotificationLists: aggregate.NotificationLists.Select(nl => new NotificationListDto( Name: nl.Name, Type: nl.Type, + // S10: carry BOTH contacts. Keep any recipient that has at least + // one contact (email OR phone) — SMS-only recipients have a null + // EmailAddress and would have been dropped by the old email-only + // filter. EmailAddress/PhoneNumber are nullable on the DTO so a + // recipient round-trips exactly the contact(s) it holds. Recipients: nl.Recipients - .Where(r => r.EmailAddress is not null) + .Where(r => r.EmailAddress is not null || r.PhoneNumber is not null) .Select(r => new NotificationRecipientDto( Name: r.Name, - EmailAddress: r.EmailAddress!)).ToList())).ToList(), + EmailAddress: r.EmailAddress, + PhoneNumber: r.PhoneNumber)).ToList())).ToList(), SmtpConfigs: aggregate.SmtpConfigurations.Select(smtp => { SecretsBlock? secrets = null; @@ -212,6 +218,32 @@ public sealed class EntitySerializer FailoverRetryCount: c.FailoverRetryCount, Secrets: secretValues.Count > 0 ? new SecretsBlock(secretValues) : null); }).ToList(), + // SMS (S10): mirror the SMTP secret handling exactly — the provider + // auth token (never a public field) rides inside the SecretsBlock, so a + // "share without secrets" export can drop it as a unit. Omit the secret + // entirely when the token is null/empty so the importer's TryGetValue + // cleanly yields "no value". + SmsConfigs = aggregate.SmsConfigurations.Select(sms => + { + SecretsBlock? secrets = null; + if (!string.IsNullOrEmpty(sms.AuthToken)) + { + secrets = new SecretsBlock(new Dictionary(StringComparer.Ordinal) + { + ["AuthToken"] = sms.AuthToken, + }); + } + + return new SmsConfigDto( + AccountSid: sms.AccountSid, + FromNumber: sms.FromNumber, + MessagingServiceSid: sms.MessagingServiceSid, + ApiBaseUrl: sms.ApiBaseUrl, + ConnectionTimeoutSeconds: sms.ConnectionTimeoutSeconds, + MaxRetries: sms.MaxRetries, + RetryDelay: sms.RetryDelay, + Secrets: secrets); + }).ToList(), Instances = aggregate.Instances.Select(inst => new InstanceDto( UniqueName: inst.UniqueName, TemplateName: templateNameById.TryGetValue(inst.TemplateId, out var tn) ? tn : string.Empty, @@ -392,10 +424,16 @@ public sealed class EntitySerializer var list = new NotificationList(dto.Name) { Id = ix + 1, Type = dto.Type }; foreach (var r in dto.Recipients) { - list.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress) - { - NotificationListId = list.Id, - }); + // S10: reconstruct whichever contact(s) the bundle carries. + // Email-present recipients use the email ctor (unchanged + // historical path) and additively restore the phone number; + // phone-only recipients (null email) take the SMS factory so we + // never pass null to the non-null email ctor. + var recipient = r.EmailAddress is not null + ? new NotificationRecipient(r.Name, r.EmailAddress) { PhoneNumber = r.PhoneNumber } + : NotificationRecipient.ForSms(r.Name, r.PhoneNumber ?? string.Empty); + recipient.NotificationListId = list.Id; + list.Recipients.Add(recipient); } return list; }) @@ -415,6 +453,22 @@ public sealed class EntitySerializer }) .ToList(); + // SMS (S10): mirror the SMTP path — restore AccountSid/FromNumber and decrypt + // the auth token back out of the SecretsBlock (null when the key was omitted + // because the source token was null/empty). + var smsConfigurations = content.SmsConfigs + .Select((dto, ix) => new SmsConfiguration(dto.AccountSid, dto.FromNumber) + { + Id = ix + 1, + AuthToken = dto.Secrets?.Values.TryGetValue("AuthToken", out var token) == true ? token : null, + MessagingServiceSid = dto.MessagingServiceSid, + ApiBaseUrl = dto.ApiBaseUrl, + ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds, + MaxRetries = dto.MaxRetries, + RetryDelay = dto.RetryDelay, + }) + .ToList(); + // Inbound API keys are not transported (re-arch C4) — content.ApiKeys is a // legacy field only present on pre-C4 bundles; it is ignored here. The // BundleImporter is responsible for counting and reporting any such keys. @@ -538,6 +592,7 @@ public sealed class EntitySerializer Sites = sites, DataConnections = dataConnections, Instances = instances, + SmsConfigurations = smsConfigurations, }; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs index 73ed1206..560d8483 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/BundleDtoSerializationTests.cs @@ -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(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(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(), + Templates: Array.Empty(), + SharedScripts: Array.Empty(), + ExternalSystems: Array.Empty(), + DatabaseConnections: Array.Empty(), + NotificationLists: Array.Empty(), + SmtpConfigs: Array.Empty(), + ApiMethods: Array.Empty()); + + 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(), + Templates: Array.Empty(), + SharedScripts: Array.Empty(), + ExternalSystems: Array.Empty(), + DatabaseConnections: Array.Empty(), + NotificationLists: Array.Empty(), + SmtpConfigs: Array.Empty(), + ApiMethods: Array.Empty()) + { + 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 + { + ["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(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"]); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs index 975b682f..a1250d60 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs @@ -618,4 +618,188 @@ public sealed class EntitySerializerTests Assert.Equal("ns=2;s=Pump1.P", rtBinding.DataSourceReferenceOverride); Assert.Equal(0, rtBinding.DataConnectionId); } + + // --- S10: SMS recipient PhoneNumber + SmsConfiguration round-trip --------- + + [Fact] + public void ToDto_carries_sms_recipient_phone_number() + { + var list = new NotificationList("on-call") { Id = 1, Type = NotificationType.Sms }; + list.Recipients.Add(NotificationRecipient.ForSms("Pager", "+15551234567")); + var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } }; + + var dto = new EntitySerializer().ToBundleContent(aggregate); + + var dtoList = Assert.Single(dto.NotificationLists); + Assert.Equal(NotificationType.Sms, dtoList.Type); + var dtoRecipient = Assert.Single(dtoList.Recipients); + Assert.Equal("Pager", dtoRecipient.Name); + Assert.Equal("+15551234567", dtoRecipient.PhoneNumber); + // SMS-only recipient: no email is carried (the old email-only filter would + // have dropped this recipient entirely). + Assert.Null(dtoRecipient.EmailAddress); + } + + [Fact] + public void Roundtrip_sms_recipient_preserves_phone_number() + { + var list = new NotificationList("on-call") { Id = 1, Type = NotificationType.Sms }; + list.Recipients.Add(NotificationRecipient.ForSms("Pager", "+15551234567")); + var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } }; + + var sut = new EntitySerializer(); + var rt = sut.FromBundleContent(sut.ToBundleContent(aggregate)); + + var rtList = Assert.Single(rt.NotificationLists); + Assert.Equal(NotificationType.Sms, rtList.Type); + var rtRecipient = Assert.Single(rtList.Recipients); + Assert.Equal("Pager", rtRecipient.Name); + Assert.Equal("+15551234567", rtRecipient.PhoneNumber); + Assert.Null(rtRecipient.EmailAddress); + } + + [Fact] + public void Roundtrip_email_recipient_preserves_email_and_leaves_phone_null() + { + var list = new NotificationList("alerts") { Id = 1, Type = NotificationType.Email }; + list.Recipients.Add(NotificationRecipient.ForEmail("Ops", "ops@example.com")); + var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } }; + + var sut = new EntitySerializer(); + var dto = sut.ToBundleContent(aggregate); + var rt = sut.FromBundleContent(dto); + + var dtoRecipient = Assert.Single(Assert.Single(dto.NotificationLists).Recipients); + Assert.Equal("ops@example.com", dtoRecipient.EmailAddress); + Assert.Null(dtoRecipient.PhoneNumber); + + var rtRecipient = Assert.Single(Assert.Single(rt.NotificationLists).Recipients); + Assert.Equal("Ops", rtRecipient.Name); + Assert.Equal("ops@example.com", rtRecipient.EmailAddress); + Assert.Null(rtRecipient.PhoneNumber); + } + + [Fact] + public void Roundtrip_recipient_with_both_contacts_preserves_both() + { + var list = new NotificationList("dual") { Id = 1, Type = NotificationType.Sms }; + // A recipient carrying BOTH an email and a phone number must round-trip + // both contacts (neither is lost on import). + list.Recipients.Add(new NotificationRecipient("Both", "both@example.com") + { + PhoneNumber = "+15559876543", + }); + var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } }; + + var sut = new EntitySerializer(); + var rt = sut.FromBundleContent(sut.ToBundleContent(aggregate)); + + var rtRecipient = Assert.Single(Assert.Single(rt.NotificationLists).Recipients); + Assert.Equal("both@example.com", rtRecipient.EmailAddress); + Assert.Equal("+15559876543", rtRecipient.PhoneNumber); + } + + [Fact] + public void FromDto_recipient_without_phone_number_yields_null_phone() + { + // Backward-compat at the DTO level: a recipient DTO built without a phone + // number (old bundles never carried one) imports with PhoneNumber == null. + var dto = new BundleContentDto( + TemplateFolders: Array.Empty(), + Templates: Array.Empty(), + SharedScripts: Array.Empty(), + ExternalSystems: Array.Empty(), + DatabaseConnections: Array.Empty(), + NotificationLists: new[] + { + new NotificationListDto( + "alerts", + NotificationType.Email, + new[] { new NotificationRecipientDto("Ops", "ops@example.com") }), + }, + SmtpConfigs: Array.Empty(), + ApiMethods: Array.Empty()); + + var aggregate = new EntitySerializer().FromBundleContent(dto); + + var recipient = Assert.Single(Assert.Single(aggregate.NotificationLists).Recipients); + Assert.Equal("ops@example.com", recipient.EmailAddress); + Assert.Null(recipient.PhoneNumber); + } + + [Fact] + public void ToDto_carves_sms_auth_token_into_secrets_block() + { + var sms = new SmsConfiguration("AC123", "+15550001111") + { + Id = 1, + AuthToken = "twilio-super-secret", + MessagingServiceSid = "MG999", + ApiBaseUrl = "https://api.twilio.com", + ConnectionTimeoutSeconds = 20, + MaxRetries = 7, + RetryDelay = TimeSpan.FromSeconds(45), + }; + var aggregate = MakeEmptyAggregate() with { SmsConfigurations = new[] { sms } }; + + var dto = new EntitySerializer().ToBundleContent(aggregate); + + var dtoSms = Assert.Single(dto.SmsConfigs); + Assert.Equal("AC123", dtoSms.AccountSid); + Assert.Equal("+15550001111", dtoSms.FromNumber); + Assert.Equal("MG999", dtoSms.MessagingServiceSid); + // The auth token lands in the SecretsBlock — NOT in any public field. + Assert.NotNull(dtoSms.Secrets); + Assert.Equal("twilio-super-secret", dtoSms.Secrets!.Values["AuthToken"]); + Assert.DoesNotContain("twilio-super-secret", dtoSms.AccountSid); + Assert.DoesNotContain("twilio-super-secret", dtoSms.FromNumber); + } + + [Fact] + public void Roundtrip_sms_config_restores_auth_token_from_secrets_block() + { + var sms = new SmsConfiguration("AC123", "+15550001111") + { + Id = 1, + AuthToken = "twilio-super-secret", + MessagingServiceSid = "MG999", + ApiBaseUrl = "https://api.twilio.com", + ConnectionTimeoutSeconds = 20, + MaxRetries = 7, + RetryDelay = TimeSpan.FromSeconds(45), + }; + var aggregate = MakeEmptyAggregate() with { SmsConfigurations = new[] { sms } }; + + var sut = new EntitySerializer(); + var rt = sut.FromBundleContent(sut.ToBundleContent(aggregate)); + + var rtSms = Assert.Single(rt.SmsConfigurations); + Assert.Equal("AC123", rtSms.AccountSid); + Assert.Equal("+15550001111", rtSms.FromNumber); + Assert.Equal("MG999", rtSms.MessagingServiceSid); + Assert.Equal("https://api.twilio.com", rtSms.ApiBaseUrl); + Assert.Equal(20, rtSms.ConnectionTimeoutSeconds); + Assert.Equal(7, rtSms.MaxRetries); + Assert.Equal(TimeSpan.FromSeconds(45), rtSms.RetryDelay); + // Secret decrypted back out of the SecretsBlock. + Assert.Equal("twilio-super-secret", rtSms.AuthToken); + } + + [Fact] + public void ToDto_omits_sms_secret_key_when_auth_token_is_null() + { + var sms = new SmsConfiguration("AC123", "+15550001111") { Id = 1, AuthToken = null }; + var aggregate = MakeEmptyAggregate() with { SmsConfigurations = new[] { sms } }; + + var dto = new EntitySerializer().ToBundleContent(aggregate); + + var dtoSms = Assert.Single(dto.SmsConfigs); + // No token => no SecretsBlock at all (mirrors the SMTP "empty credentials" + // behaviour where the whole block stays null). + Assert.Null(dtoSms.Secrets); + + // And it round-trips to a null token without throwing. + var rt = new EntitySerializer().FromBundleContent(dto); + Assert.Null(Assert.Single(rt.SmsConfigurations).AuthToken); + } }