feat(sms): make FromNumber optional — support Twilio Messaging-Service-only configs (UI-Med-2)
Code-review finding UI-Med-2: the design doc + delivery adapter treat FromNumber and MessagingServiceSid as either-or, but the entity ctor, EF schema, UI and CLI all hard- required FromNumber — so a Messaging-Service-only Twilio config (a normal production setup) could not be created. Bring the implementation into line with the spec: - Commons: SmsConfiguration.FromNumber -> string? (ctor fromNumber optional); UpdateSmsConfigCommand.FromNumber -> string?. - ConfigurationDatabase: FromNumber.IsRequired(false) + migration SmsFromNumberOptional (ALTER COLUMN nullable, idempotent; Down backfills '' — harmless, MsgSid keeps it deliverable) + regenerated model snapshot. - Transport: SmsConfigDto.FromNumber -> string? (round-trips a Messaging-Service-only config). - CentralUI: form validation requires AccountSid + at-least-one-of(FromNumber, MsgSid); nullable create/edit paths; From-number help text. - CLI: --from-number no longer Required; BuildUpdateSmsConfigCommand validates the either-or. - Adapter: From branch null-forgiving (guarded by the existing incomplete-config check). Tests: ManagementActor MsgSid-only persists null FromNumber; CLI MsgSid-only builds + neither-throws + contract (--from-number not Required); CentralUI MsgSid-only save.
This commit is contained in:
@@ -208,11 +208,37 @@ public class NotificationSmsCommandTests
|
||||
public void SmsUpdate_OptionalFlags_AreNotRequired()
|
||||
{
|
||||
var update = SmsUpdate();
|
||||
Assert.False(update.Options.Single(o => o.Name == "--from-number").Required);
|
||||
Assert.False(update.Options.Single(o => o.Name == "--messaging-service-sid").Required);
|
||||
Assert.False(update.Options.Single(o => o.Name == "--api-base-url").Required);
|
||||
Assert.False(update.Options.Single(o => o.Name == "--auth-token").Required);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmsUpdate_MessagingServiceSidOnly_NoFromNumber_Builds()
|
||||
{
|
||||
// Twilio Messaging-Service-only config: From number omitted, Messaging Service SID
|
||||
// supplied. The either-or validation accepts it and FromNumber maps to null.
|
||||
var parse = Parse(SmsUpdate(),
|
||||
"--id", "3", "--account-sid", "ACmsg", "--messaging-service-sid", "MGonly");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildUpdateSmsConfigCommand(parse);
|
||||
|
||||
Assert.Null(cmd.FromNumber);
|
||||
Assert.Equal("MGonly", cmd.MessagingServiceSid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmsUpdate_NeitherFromNorMessagingService_Throws()
|
||||
{
|
||||
// Neither sender identity supplied => the build rejects it so an SMS config is
|
||||
// never left with no way to send (mirrors the UI + delivery-adapter validation).
|
||||
var parse = Parse(SmsUpdate(), "--id", "4", "--account-sid", "ACnone");
|
||||
|
||||
Assert.Throws<ArgumentException>(() => NotificationCommands.BuildUpdateSmsConfigCommand(parse));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmsCommands_ResolveViaRegistry()
|
||||
{
|
||||
|
||||
@@ -86,16 +86,19 @@ public class UpdateCommandContractTests
|
||||
[Fact]
|
||||
public void SmsUpdate_CoreFieldsRequired()
|
||||
{
|
||||
// `notification sms update` is a whole-replace of the SMS provider config, so its
|
||||
// identity + non-secret core fields must be Required — a missing --account-sid or
|
||||
// --from-number would otherwise send null and wipe a stored value. --auth-token
|
||||
// stays optional (preserve-if-omitted; empty == omitted, never "clear").
|
||||
// --id and --account-sid are unconditionally Required. --from-number is NOT Required:
|
||||
// it is either-or with --messaging-service-sid (Twilio Messaging-Service-only configs),
|
||||
// so the builder validates "at least one sender identity" instead of the option layer.
|
||||
// --auth-token stays optional (preserve-if-omitted; empty == omitted, never "clear").
|
||||
var update = UpdateCommand(NotificationCommands.Build(Url, Format, Username, Password), "sms", "update");
|
||||
AssertRequired(update, "--id", "--account-sid", "--from-number");
|
||||
AssertRequired(update, "--id", "--account-sid");
|
||||
|
||||
var authToken = update.Options.SingleOrDefault(o => o.Name == "--auth-token");
|
||||
Assert.True(authToken != null, "sms update is missing option '--auth-token'.");
|
||||
Assert.False(authToken!.Required, "sms update '--auth-token' must be optional (preserve-if-omitted).");
|
||||
foreach (var name in new[] { "--from-number", "--auth-token" })
|
||||
{
|
||||
var option = update.Options.SingleOrDefault(o => o.Name == name);
|
||||
Assert.True(option != null, $"sms update is missing option '{name}'.");
|
||||
Assert.False(option!.Required, $"sms update '{name}' must be conditionally validated / optional, not Required.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -126,6 +126,39 @@ public class SmsConfigurationPageTests : BunitContext
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavingNewConfig_MessagingServiceSidOnly_NoFromNumber_Saves()
|
||||
{
|
||||
// UI-Med-2: a Twilio Messaging-Service-only config is valid with no From number.
|
||||
// The either-or validation must accept it and persist a null FromNumber.
|
||||
var repo = RepoWith();
|
||||
Services.AddSingleton(repo);
|
||||
WireAuth();
|
||||
|
||||
var cut = Render<SmsConfigurationPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("No SMS configuration set."));
|
||||
|
||||
cut.FindAll("button").First(b => b.TextContent.Contains("Add SMS configuration")).Click();
|
||||
|
||||
// Account SID + Messaging Service SID, leaving From Number (index 1) blank.
|
||||
cut.FindAll("input[type=text]")[0].Change("ACmsg_account"); // Account SID
|
||||
cut.FindAll("input[type=text]")[2].Change("MGmessaging_service"); // Messaging Service SID
|
||||
cut.FindAll("input[type=password]").First().Change("new-token");
|
||||
|
||||
cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
repo.Received().AddSmsConfigurationAsync(
|
||||
Arg.Is<SmsConfiguration>(c =>
|
||||
c.AccountSid == "ACmsg_account" &&
|
||||
c.FromNumber == null &&
|
||||
c.MessagingServiceSid == "MGmessaging_service" &&
|
||||
c.AuthToken == "new-token"));
|
||||
repo.Received().SaveChangesAsync();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavingEdit_WithBlankAuthToken_PreservesExistingToken()
|
||||
{
|
||||
|
||||
@@ -1705,6 +1705,34 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Equal("ACnew", existing.AccountSid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSmsConfig_MessagingServiceSidOnly_PersistsNullFromNumber()
|
||||
{
|
||||
// UI-Med-2: a Twilio Messaging-Service-only config has no From number. The handler
|
||||
// must accept and persist a null FromNumber alongside a MessagingServiceSid.
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
|
||||
{
|
||||
Id = 1,
|
||||
AuthToken = "old-secret",
|
||||
};
|
||||
notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSmsConfigCommand(1, "ACnew", FromNumber: null, MessagingServiceSid: "MGonly"),
|
||||
"Administrator");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.Null(existing.FromNumber);
|
||||
Assert.Equal("MGonly", existing.MessagingServiceSid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CuratedHandlerFailure_SurfacesTheCuratedMessage()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user