feat(sms): CLI list --type/--phones + notification sms group + channel-aware recipients (S6)
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the SMS surface of the <c>notification</c> command group (SMS Notifications S6):
|
||||
/// the channel-aware <c>--type</c> / <c>--phones</c> flags on <c>notification list create|update</c>,
|
||||
/// and the new <c>notification sms list|update</c> group. Pins the validation rules, the
|
||||
/// per-channel command construction, and that the Twilio Auth Token is never echoed back.
|
||||
/// </summary>
|
||||
public class NotificationSmsCommandTests
|
||||
{
|
||||
private static readonly Option<string> Url = new("--url") { Recursive = true };
|
||||
private static readonly Option<string> Username = new("--username") { Recursive = true };
|
||||
private static readonly Option<string> Password = new("--password") { Recursive = true };
|
||||
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
|
||||
|
||||
private static Command Notification() => NotificationCommands.Build(Url, Format, Username, Password);
|
||||
|
||||
private static Command ListCreate() =>
|
||||
Notification().Subcommands.Single(c => c.Name == "create");
|
||||
|
||||
private static Command ListUpdate() =>
|
||||
Notification().Subcommands.Single(c => c.Name == "update");
|
||||
|
||||
private static Command SmsGroup() =>
|
||||
Notification().Subcommands.Single(c => c.Name == "sms");
|
||||
|
||||
private static Command SmsUpdate() =>
|
||||
SmsGroup().Subcommands.Single(c => c.Name == "update");
|
||||
|
||||
// System.CommandLine's Parse takes a string[] (no params overload here), so route
|
||||
// every invocation through a helper that wraps the variadic args into an array.
|
||||
private static System.CommandLine.ParseResult Parse(Command command, params string[] args)
|
||||
=> command.Parse(args);
|
||||
|
||||
// ---- list create/update: --type + --phones ----------------------------
|
||||
|
||||
[Fact]
|
||||
public void ListCreate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
|
||||
{
|
||||
var parse = Parse(ListCreate(),
|
||||
"--name", "Ops SMS", "--type", "sms", "--phones", "+15551230000,+15551230001");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildCreateNotificationListCommand(parse);
|
||||
|
||||
Assert.Equal("Ops SMS", cmd.Name);
|
||||
Assert.Equal(NotificationType.Sms, cmd.Type);
|
||||
Assert.Equal(new[] { "+15551230000", "+15551230001" }, cmd.RecipientPhones);
|
||||
Assert.Empty(cmd.RecipientEmails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCreate_TypeIsCaseInsensitive()
|
||||
{
|
||||
var parse = Parse(ListCreate(),
|
||||
"--name", "Ops SMS", "--type", "SMS", "--phones", "+15551230000");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildCreateNotificationListCommand(parse);
|
||||
Assert.Equal(NotificationType.Sms, cmd.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCreate_DefaultType_IsEmail()
|
||||
{
|
||||
var parse = Parse(ListCreate(), "--name", "Ops", "--emails", "ops@example.com");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildCreateNotificationListCommand(parse);
|
||||
|
||||
Assert.Equal(NotificationType.Email, cmd.Type);
|
||||
Assert.Equal(new[] { "ops@example.com" }, cmd.RecipientEmails);
|
||||
Assert.Null(cmd.RecipientPhones);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCreate_EmailTypeWithPhones_Throws()
|
||||
{
|
||||
var parse = Parse(ListCreate(),
|
||||
"--name", "Ops", "--type", "email", "--emails", "ops@example.com", "--phones", "+15551230000");
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(
|
||||
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
|
||||
Assert.Contains("--phones", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCreate_SmsTypeWithoutPhones_Throws()
|
||||
{
|
||||
var parse = Parse(ListCreate(), "--name", "Ops SMS", "--type", "sms");
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(
|
||||
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
|
||||
Assert.Contains("--phones", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCreate_EmailTypeWithoutEmails_Throws()
|
||||
{
|
||||
var parse = Parse(ListCreate(), "--name", "Ops", "--type", "email");
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(
|
||||
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
|
||||
Assert.Contains("--emails", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCreate_SmsTypeWithEmails_Throws()
|
||||
{
|
||||
var parse = Parse(ListCreate(),
|
||||
"--name", "Ops SMS", "--type", "sms", "--emails", "ops@example.com");
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(
|
||||
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
|
||||
Assert.Contains("--emails", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCreate_UnknownType_Throws()
|
||||
{
|
||||
var parse = Parse(ListCreate(),
|
||||
"--name", "Ops", "--type", "carrier-pigeon", "--emails", "ops@example.com");
|
||||
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => NotificationCommands.BuildCreateNotificationListCommand(parse));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListUpdate_SmsTypeWithPhones_BuildsSmsCommandWithRecipientPhones()
|
||||
{
|
||||
var parse = Parse(ListUpdate(),
|
||||
"--id", "7", "--name", "Ops SMS", "--type", "sms", "--phones", "+15551230000");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildUpdateNotificationListCommand(parse);
|
||||
|
||||
Assert.Equal(7, cmd.NotificationListId);
|
||||
Assert.Equal(NotificationType.Sms, cmd.Type);
|
||||
Assert.Equal(new[] { "+15551230000" }, cmd.RecipientPhones);
|
||||
Assert.Empty(cmd.RecipientEmails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListUpdate_EmailTypeWithPhones_Throws()
|
||||
{
|
||||
var parse = Parse(ListUpdate(),
|
||||
"--id", "7", "--name", "Ops", "--type", "email",
|
||||
"--emails", "ops@example.com", "--phones", "+15551230000");
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(
|
||||
() => NotificationCommands.BuildUpdateNotificationListCommand(parse));
|
||||
Assert.Contains("--phones", ex.Message);
|
||||
}
|
||||
|
||||
// ---- notification sms group -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Notification_HasSmsGroupWithListAndUpdate()
|
||||
{
|
||||
var subNames = SmsGroup().Subcommands.Select(c => c.Name).ToHashSet();
|
||||
Assert.Contains("list", subNames);
|
||||
Assert.Contains("update", subNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmsUpdate_WithAllFlags_ProducesCommandCarryingThem()
|
||||
{
|
||||
var parse = Parse(SmsUpdate(),
|
||||
"--id", "1", "--account-sid", "ACnew", "--from-number", "+15551110000",
|
||||
"--messaging-service-sid", "MGnew", "--api-base-url", "https://new.example.com",
|
||||
"--auth-token", "new-secret");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildUpdateSmsConfigCommand(parse);
|
||||
|
||||
Assert.Equal(1, cmd.SmsConfigId);
|
||||
Assert.Equal("ACnew", cmd.AccountSid);
|
||||
Assert.Equal("+15551110000", cmd.FromNumber);
|
||||
Assert.Equal("MGnew", cmd.MessagingServiceSid);
|
||||
Assert.Equal("https://new.example.com", cmd.ApiBaseUrl);
|
||||
Assert.Equal("new-secret", cmd.AuthToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmsUpdate_WithoutOptionalFlags_ProducesCommandWithNulls()
|
||||
{
|
||||
var parse = Parse(SmsUpdate(),
|
||||
"--id", "2", "--account-sid", "AConly", "--from-number", "+15552220000");
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
var cmd = NotificationCommands.BuildUpdateSmsConfigCommand(parse);
|
||||
|
||||
Assert.Equal(2, cmd.SmsConfigId);
|
||||
// messaging-service-sid maps to null (handler CLEARS it); auth-token / api-base-url
|
||||
// map to null (handler PRESERVES them) — all three optional here.
|
||||
Assert.Null(cmd.MessagingServiceSid);
|
||||
Assert.Null(cmd.ApiBaseUrl);
|
||||
Assert.Null(cmd.AuthToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmsUpdate_OptionalFlags_AreNotRequired()
|
||||
{
|
||||
var update = SmsUpdate();
|
||||
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 SmsCommands_ResolveViaRegistry()
|
||||
{
|
||||
// The CLI calls GetCommandName for every command it sends; both SMS commands
|
||||
// must round-trip through the management command registry.
|
||||
Assert.Equal(typeof(ListSmsConfigsCommand),
|
||||
ManagementCommandRegistry.Resolve(ManagementCommandRegistry.GetCommandName(typeof(ListSmsConfigsCommand))));
|
||||
Assert.Equal(typeof(UpdateSmsConfigCommand),
|
||||
ManagementCommandRegistry.Resolve(ManagementCommandRegistry.GetCommandName(typeof(UpdateSmsConfigCommand))));
|
||||
}
|
||||
|
||||
// ---- Auth Token is never rendered -------------------------------------
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Console-capturing companion to <see cref="NotificationSmsCommandTests"/> — pins that the
|
||||
/// Twilio Auth Token is never echoed to the rendered output. Lives in the shared "Console"
|
||||
/// collection so the <see cref="Console.SetOut"/> redirection does not race the other
|
||||
/// console-capturing test classes.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class NotificationSmsAuthTokenRenderingTests
|
||||
{
|
||||
[Fact]
|
||||
public void SmsConfigListResponse_RenderedAsJson_DoesNotContainAuthToken()
|
||||
{
|
||||
// The server projects the AuthToken away to a HasAuthToken presence flag
|
||||
// (SmsConfigPublicShape). The CLI renders that projected response verbatim,
|
||||
// so a rendered SMS-config list never surfaces the secret token value.
|
||||
var projected = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
AccountSid = "ACxxxx",
|
||||
FromNumber = "+15550000000",
|
||||
HasAuthToken = true,
|
||||
},
|
||||
};
|
||||
|
||||
var original = Console.Out;
|
||||
using var sw = new StringWriter();
|
||||
try
|
||||
{
|
||||
Console.SetOut(sw);
|
||||
OutputFormatter.WriteJson(projected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(original);
|
||||
}
|
||||
|
||||
var rendered = sw.ToString();
|
||||
Assert.Contains("hasAuthToken", rendered);
|
||||
Assert.DoesNotContain("authToken\"", rendered);
|
||||
Assert.DoesNotContain("secret", rendered, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,21 @@ public class UpdateCommandContractTests
|
||||
|
||||
[Fact]
|
||||
public void NotificationUpdate_CoreFieldsRequired()
|
||||
=> AssertRequired(UpdateCommand(NotificationCommands.Build(Url, Format, Username, Password), "update"), "--name", "--emails");
|
||||
{
|
||||
// Only --name is unconditionally required. The recipient flags are channel-
|
||||
// conditional (SMS Notifications S6): --emails is required for --type email and
|
||||
// --phones for --type sms, so neither can be flagged Required at the option level
|
||||
// — the create/update builders validate the channel/recipient pairing instead.
|
||||
var update = UpdateCommand(NotificationCommands.Build(Url, Format, Username, Password), "update");
|
||||
AssertRequired(update, "--name");
|
||||
|
||||
foreach (var name in new[] { "--emails", "--phones" })
|
||||
{
|
||||
var option = update.Options.SingleOrDefault(o => o.Name == name);
|
||||
Assert.True(option != null, $"notification update is missing option '{name}'.");
|
||||
Assert.False(option!.Required, $"notification update option '{name}' must be conditionally validated, not Required.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiMethodUpdate_CoreFieldsRequired()
|
||||
|
||||
@@ -1365,6 +1365,43 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Equal(Commons.Types.Enums.NotificationType.Sms, added!.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateNotificationList_WithSmsTypeAndPhones_PersistsForSmsRecipients()
|
||||
{
|
||||
// S6: an SMS list builds recipients from RecipientPhones via NotificationRecipient.ForSms
|
||||
// — PhoneNumber set, EmailAddress null (the off-channel RecipientEmails list is ignored).
|
||||
var notifRepo = Substitute.For<INotificationRepository>();
|
||||
Commons.Entities.Notifications.NotificationList? added = null;
|
||||
notifRepo.When(r => r.AddNotificationListAsync(
|
||||
Arg.Any<Commons.Entities.Notifications.NotificationList>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => added = ci.Arg<Commons.Entities.Notifications.NotificationList>());
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new CreateNotificationListCommand(
|
||||
"Ops SMS",
|
||||
Array.Empty<string>(),
|
||||
Commons.Types.Enums.NotificationType.Sms,
|
||||
new[] { "+15551230000", "+15551230001" }),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.NotNull(added);
|
||||
Assert.Equal(Commons.Types.Enums.NotificationType.Sms, added!.Type);
|
||||
Assert.Equal(2, added.Recipients.Count);
|
||||
Assert.All(added.Recipients, r =>
|
||||
{
|
||||
Assert.Null(r.EmailAddress);
|
||||
Assert.NotNull(r.PhoneNumber);
|
||||
});
|
||||
Assert.Equal(new[] { "+15551230000", "+15551230001" },
|
||||
added.Recipients.Select(r => r.PhoneNumber).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateNotificationList_DefaultType_IsEmail()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user