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:
Joseph Doherty
2026-06-19 15:19:40 -04:00
parent a9393c8913
commit 33e1802e6d
14 changed files with 2133 additions and 28 deletions
@@ -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]