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
@@ -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()
{