feat(sms): CLI list --type/--phones + notification sms group + channel-aware recipients (S6)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
using System.CommandLine;
|
using System.CommandLine;
|
||||||
using System.CommandLine.Parsing;
|
using System.CommandLine.Parsing;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||||
|
|
||||||
@@ -24,10 +25,43 @@ public static class NotificationCommands
|
|||||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption));
|
command.Add(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
command.Add(BuildSms(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// Notification list create/update options (static so the parsed values can
|
||||||
|
// be read back both from the SetAction and from the testable Build* helpers).
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
private static readonly Option<string> ListCreateNameOption =
|
||||||
|
new("--name") { Description = "Notification list name", Required = true };
|
||||||
|
private static readonly Option<string?> ListCreateEmailsOption =
|
||||||
|
new("--emails") { Description = "Comma-separated recipient emails (required for --type email; rejected for --type sms)" };
|
||||||
|
private static readonly Option<string?> ListCreatePhonesOption =
|
||||||
|
new("--phones") { Description = "Comma-separated recipient phone numbers in E.164 (required for --type sms; rejected for --type email)" };
|
||||||
|
private static readonly Option<string> ListCreateTypeOption = CreateListTypeOption();
|
||||||
|
|
||||||
|
private static readonly Option<int> ListUpdateIdOption =
|
||||||
|
new("--id") { Description = "Notification list ID", Required = true };
|
||||||
|
private static readonly Option<string> ListUpdateNameOption =
|
||||||
|
new("--name") { Description = "List name", Required = true };
|
||||||
|
private static readonly Option<string?> ListUpdateEmailsOption =
|
||||||
|
new("--emails") { Description = "Comma-separated recipient emails (required for --type email; rejected for --type sms)" };
|
||||||
|
private static readonly Option<string?> ListUpdatePhonesOption =
|
||||||
|
new("--phones") { Description = "Comma-separated recipient phone numbers in E.164 (required for --type sms; rejected for --type email)" };
|
||||||
|
private static readonly Option<string> ListUpdateTypeOption = CreateListTypeOption();
|
||||||
|
|
||||||
|
private static Option<string> CreateListTypeOption()
|
||||||
|
{
|
||||||
|
var option = new Option<string>("--type")
|
||||||
|
{
|
||||||
|
Description = "Delivery channel: email or sms (case-insensitive; default email)",
|
||||||
|
DefaultValueFactory = _ => "email",
|
||||||
|
};
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
{
|
{
|
||||||
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
||||||
@@ -44,27 +78,50 @@ public static class NotificationCommands
|
|||||||
|
|
||||||
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
{
|
{
|
||||||
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
|
||||||
var nameOption = new Option<string>("--name") { Description = "List name", Required = true };
|
|
||||||
var emailsOption = new Option<string>("--emails") { Description = "Comma-separated recipient emails", Required = true };
|
|
||||||
|
|
||||||
var cmd = new Command("update") { Description = "Update a notification list" };
|
var cmd = new Command("update") { Description = "Update a notification list" };
|
||||||
cmd.Add(idOption);
|
cmd.Add(ListUpdateIdOption);
|
||||||
cmd.Add(nameOption);
|
cmd.Add(ListUpdateNameOption);
|
||||||
cmd.Add(emailsOption);
|
cmd.Add(ListUpdateTypeOption);
|
||||||
|
cmd.Add(ListUpdateEmailsOption);
|
||||||
|
cmd.Add(ListUpdatePhonesOption);
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
UpdateNotificationListCommand command;
|
||||||
var name = result.GetValue(nameOption)!;
|
try
|
||||||
var emailsRaw = result.GetValue(emailsOption)!;
|
{
|
||||||
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
command = BuildUpdateNotificationListCommand(result);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
result, urlOption, formatOption, usernameOption, passwordOption, command);
|
||||||
new UpdateNotificationListCommand(id, name, emails));
|
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <see cref="UpdateNotificationListCommand"/> from a parsed
|
||||||
|
/// <c>notification list update</c> invocation, applying the channel-aware
|
||||||
|
/// recipient validation. Throws <see cref="ArgumentException"/> when the
|
||||||
|
/// channel and the supplied recipient flags are inconsistent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="result">The parsed command-line result.</param>
|
||||||
|
/// <returns>A validated <see cref="UpdateNotificationListCommand"/>.</returns>
|
||||||
|
internal static UpdateNotificationListCommand BuildUpdateNotificationListCommand(ParseResult result)
|
||||||
|
{
|
||||||
|
var id = result.GetValue(ListUpdateIdOption);
|
||||||
|
var name = result.GetValue(ListUpdateNameOption)!;
|
||||||
|
var (type, emails, phones) = ResolveListChannel(
|
||||||
|
result.GetValue(ListUpdateTypeOption)!,
|
||||||
|
result.GetValue(ListUpdateEmailsOption),
|
||||||
|
result.GetValue(ListUpdatePhonesOption));
|
||||||
|
return new UpdateNotificationListCommand(id, name, emails, type, phones);
|
||||||
|
}
|
||||||
|
|
||||||
private static Command BuildSmtp(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
private static Command BuildSmtp(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
{
|
{
|
||||||
var group = new Command("smtp") { Description = "Manage SMTP configuration" };
|
var group = new Command("smtp") { Description = "Manage SMTP configuration" };
|
||||||
@@ -145,6 +202,80 @@ public static class NotificationCommands
|
|||||||
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials);
|
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Command BuildSms(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
|
{
|
||||||
|
var group = new Command("sms") { Description = "Manage SMS (Twilio) configuration" };
|
||||||
|
|
||||||
|
var listCmd = new Command("list") { Description = "List SMS configurations (Auth Token shown as a presence flag only)" };
|
||||||
|
listCmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
|
result, urlOption, formatOption, usernameOption, passwordOption, new ListSmsConfigsCommand());
|
||||||
|
});
|
||||||
|
group.Add(listCmd);
|
||||||
|
|
||||||
|
var updateCmd = new Command("update") { Description = "Update SMS configuration" };
|
||||||
|
updateCmd.Add(SmsIdOption);
|
||||||
|
updateCmd.Add(SmsAccountSidOption);
|
||||||
|
updateCmd.Add(SmsFromNumberOption);
|
||||||
|
updateCmd.Add(SmsMessagingServiceSidOption);
|
||||||
|
updateCmd.Add(SmsApiBaseUrlOption);
|
||||||
|
updateCmd.Add(SmsAuthTokenOption);
|
||||||
|
updateCmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
|
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
BuildUpdateSmsConfigCommand(result));
|
||||||
|
});
|
||||||
|
group.Add(updateCmd);
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMS update options are static so the parsed values can be read back both
|
||||||
|
// from the SetAction and from BuildUpdateSmsConfigCommand (used by tests).
|
||||||
|
private static readonly Option<int> SmsIdOption =
|
||||||
|
new("--id") { Description = "SMS config ID", Required = true };
|
||||||
|
private static readonly Option<string> SmsAccountSidOption =
|
||||||
|
new("--account-sid") { Description = "Twilio Account SID", Required = true };
|
||||||
|
private static readonly Option<string> SmsFromNumberOption =
|
||||||
|
new("--from-number") { Description = "Sender phone number (E.164)", Required = true };
|
||||||
|
private static readonly Option<string?> SmsMessagingServiceSidOption =
|
||||||
|
new("--messaging-service-sid")
|
||||||
|
{
|
||||||
|
Description = "Twilio Messaging Service SID (optional; OMITTING IT CLEARS the stored value)",
|
||||||
|
};
|
||||||
|
private static readonly Option<string?> SmsApiBaseUrlOption =
|
||||||
|
new("--api-base-url")
|
||||||
|
{
|
||||||
|
Description = "API base URL override (optional; preserves existing if omitted)",
|
||||||
|
};
|
||||||
|
private static readonly Option<string?> SmsAuthTokenOption =
|
||||||
|
new("--auth-token")
|
||||||
|
{
|
||||||
|
Description = "Twilio Auth Token (optional; PRESERVES the stored token if omitted; never printed back)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <see cref="UpdateSmsConfigCommand"/> from a parsed <c>sms update</c>
|
||||||
|
/// invocation. Note the asymmetric preserve-vs-clear semantics enforced server-side:
|
||||||
|
/// omitting <c>--auth-token</c> / <c>--api-base-url</c> maps to null so the handler
|
||||||
|
/// PRESERVES the existing values, whereas omitting <c>--messaging-service-sid</c> also
|
||||||
|
/// maps to null but the handler OVERWRITES (clears) the stored value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="result">The parsed command-line result from the <c>sms update</c> invocation.</param>
|
||||||
|
/// <returns>An <see cref="UpdateSmsConfigCommand"/> populated from the parsed result.</returns>
|
||||||
|
internal static UpdateSmsConfigCommand BuildUpdateSmsConfigCommand(ParseResult result)
|
||||||
|
{
|
||||||
|
var id = result.GetValue(SmsIdOption);
|
||||||
|
var accountSid = result.GetValue(SmsAccountSidOption)!;
|
||||||
|
var fromNumber = result.GetValue(SmsFromNumberOption)!;
|
||||||
|
var messagingServiceSid = result.GetValue(SmsMessagingServiceSidOption);
|
||||||
|
var apiBaseUrl = result.GetValue(SmsApiBaseUrlOption);
|
||||||
|
var authToken = result.GetValue(SmsAuthTokenOption);
|
||||||
|
return new UpdateSmsConfigCommand(id, accountSid, fromNumber, messagingServiceSid, apiBaseUrl, authToken);
|
||||||
|
}
|
||||||
|
|
||||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
{
|
{
|
||||||
var cmd = new Command("list") { Description = "List all notification lists" };
|
var cmd = new Command("list") { Description = "List all notification lists" };
|
||||||
@@ -158,24 +289,103 @@ public static class NotificationCommands
|
|||||||
|
|
||||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
{
|
{
|
||||||
var nameOption = new Option<string>("--name") { Description = "Notification list name", Required = true };
|
|
||||||
var emailsOption = new Option<string>("--emails") { Description = "Comma-separated recipient emails", Required = true };
|
|
||||||
|
|
||||||
var cmd = new Command("create") { Description = "Create a notification list" };
|
var cmd = new Command("create") { Description = "Create a notification list" };
|
||||||
cmd.Add(nameOption);
|
cmd.Add(ListCreateNameOption);
|
||||||
cmd.Add(emailsOption);
|
cmd.Add(ListCreateTypeOption);
|
||||||
|
cmd.Add(ListCreateEmailsOption);
|
||||||
|
cmd.Add(ListCreatePhonesOption);
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
var name = result.GetValue(nameOption)!;
|
CreateNotificationListCommand command;
|
||||||
var emailsRaw = result.GetValue(emailsOption)!;
|
try
|
||||||
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
{
|
||||||
|
command = BuildCreateNotificationListCommand(result);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
result, urlOption, formatOption, usernameOption, passwordOption, command);
|
||||||
new CreateNotificationListCommand(name, emails));
|
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <see cref="CreateNotificationListCommand"/> from a parsed
|
||||||
|
/// <c>notification list create</c> invocation, applying the channel-aware
|
||||||
|
/// recipient validation. Throws <see cref="ArgumentException"/> when the
|
||||||
|
/// channel and the supplied recipient flags are inconsistent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="result">The parsed command-line result.</param>
|
||||||
|
/// <returns>A validated <see cref="CreateNotificationListCommand"/>.</returns>
|
||||||
|
internal static CreateNotificationListCommand BuildCreateNotificationListCommand(ParseResult result)
|
||||||
|
{
|
||||||
|
var name = result.GetValue(ListCreateNameOption)!;
|
||||||
|
var (type, emails, phones) = ResolveListChannel(
|
||||||
|
result.GetValue(ListCreateTypeOption)!,
|
||||||
|
result.GetValue(ListCreateEmailsOption),
|
||||||
|
result.GetValue(ListCreatePhonesOption));
|
||||||
|
return new CreateNotificationListCommand(name, emails, type, phones);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the <c>--type</c> value (case-insensitive) and validates that the supplied
|
||||||
|
/// recipient flags match the channel: <c>email</c> requires <c>--emails</c> and rejects
|
||||||
|
/// <c>--phones</c>; <c>sms</c> requires <c>--phones</c> and rejects <c>--emails</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="typeRaw">The raw <c>--type</c> value.</param>
|
||||||
|
/// <param name="emailsRaw">The raw <c>--emails</c> value, or null when omitted.</param>
|
||||||
|
/// <param name="phonesRaw">The raw <c>--phones</c> value, or null when omitted.</param>
|
||||||
|
/// <returns>The resolved channel and recipient lists (the off-channel list is empty).</returns>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when the type is unknown or the recipient flags are inconsistent with the channel.</exception>
|
||||||
|
private static (NotificationType Type, IReadOnlyList<string> Emails, IReadOnlyList<string>? Phones) ResolveListChannel(
|
||||||
|
string typeRaw, string? emailsRaw, string? phonesRaw)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<NotificationType>(typeRaw, ignoreCase: true, out var type))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid --type '{typeRaw}'. Expected 'email' or 'sms'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var emails = SplitRecipients(emailsRaw);
|
||||||
|
var phones = SplitRecipients(phonesRaw);
|
||||||
|
|
||||||
|
if (type == NotificationType.Email)
|
||||||
|
{
|
||||||
|
if (phones.Count > 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("--phones is not valid for --type email; use --emails.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emails.Count == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("--emails is required for --type email.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (type, emails, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sms
|
||||||
|
if (emails.Count > 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("--emails is not valid for --type sms; use --phones.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phones.Count == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("--phones is required for --type sms.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (type, Array.Empty<string>(), phones);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> SplitRecipients(string? raw) =>
|
||||||
|
string.IsNullOrWhiteSpace(raw)
|
||||||
|
? Array.Empty<string>()
|
||||||
|
: raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||||
|
|
||||||
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
{
|
{
|
||||||
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
||||||
|
|||||||
@@ -985,31 +985,47 @@ scadabridge --url <url> notification list
|
|||||||
|
|
||||||
#### `notification create`
|
#### `notification create`
|
||||||
|
|
||||||
Create a notification list with one or more recipients.
|
Create a notification list with one or more recipients. The `--type` flag selects the
|
||||||
|
delivery channel and decides which recipient flag is required: `email` (the default)
|
||||||
|
uses `--emails`; `sms` uses `--phones`. Supplying the wrong recipient flag for the
|
||||||
|
channel is rejected before the command is sent.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# Email list (default channel)
|
||||||
scadabridge --url <url> notification create --name <string> --emails <email1,email2,...>
|
scadabridge --url <url> notification create --name <string> --emails <email1,email2,...>
|
||||||
|
|
||||||
|
# SMS list
|
||||||
|
scadabridge --url <url> notification create --name <string> --type sms --phones <e164a,e164b,...>
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Required | Description |
|
| Option | Required | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| `--name` | yes | Notification list name |
|
| `--name` | yes | Notification list name |
|
||||||
| `--emails` | yes | Comma-separated list of recipient email addresses |
|
| `--type` | no | Delivery channel: `email` or `sms` (case-insensitive; default `email`) |
|
||||||
|
| `--emails` | conditional | Comma-separated recipient email addresses. **Required for `--type email`; rejected for `--type sms`.** |
|
||||||
|
| `--phones` | conditional | Comma-separated recipient phone numbers in E.164. **Required for `--type sms`; rejected for `--type email`.** |
|
||||||
|
|
||||||
#### `notification update`
|
#### `notification update`
|
||||||
|
|
||||||
Update a notification list. An update **replaces** the whole entity — every required
|
Update a notification list. An update **replaces** the whole entity — every required
|
||||||
field below must be supplied, even if unchanged.
|
field below must be supplied, even if unchanged. As with `create`, `--type` selects the
|
||||||
|
channel and decides whether `--emails` or `--phones` is required.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# Email list
|
||||||
scadabridge --url <url> notification update --id <int> --name <string> --emails <email1,email2,...>
|
scadabridge --url <url> notification update --id <int> --name <string> --emails <email1,email2,...>
|
||||||
|
|
||||||
|
# SMS list
|
||||||
|
scadabridge --url <url> notification update --id <int> --name <string> --type sms --phones <e164a,e164b,...>
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Required | Description |
|
| Option | Required | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| `--id` | yes | Notification list ID |
|
| `--id` | yes | Notification list ID |
|
||||||
| `--name` | yes | List name |
|
| `--name` | yes | List name |
|
||||||
| `--emails` | yes | Comma-separated list of recipient email addresses |
|
| `--type` | no | Delivery channel: `email` or `sms` (case-insensitive; default `email`) |
|
||||||
|
| `--emails` | conditional | Comma-separated recipient email addresses. **Required for `--type email`; rejected for `--type sms`.** |
|
||||||
|
| `--phones` | conditional | Comma-separated recipient phone numbers in E.164. **Required for `--type sms`; rejected for `--type email`.** |
|
||||||
|
|
||||||
#### `notification delete`
|
#### `notification delete`
|
||||||
|
|
||||||
@@ -1049,6 +1065,32 @@ scadabridge --url <url> notification smtp update --id <int> --server <string> --
|
|||||||
| `--tls-mode` | no | TLS mode: `None`, `StartTLS`, or `SSL` (preserves existing if omitted) |
|
| `--tls-mode` | no | TLS mode: `None`, `StartTLS`, or `SSL` (preserves existing if omitted) |
|
||||||
| `--credentials` | no | SMTP credentials — `username:password` for Basic, or client secret for OAuth2 (preserves existing if omitted) |
|
| `--credentials` | no | SMTP credentials — `username:password` for Basic, or client secret for OAuth2 (preserves existing if omitted) |
|
||||||
|
|
||||||
|
#### `notification sms list`
|
||||||
|
|
||||||
|
Show the current SMS (Twilio) configuration. The Twilio **Auth Token is never returned**
|
||||||
|
— the listing reports it only as a `hasAuthToken` presence flag.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> notification sms list
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `notification sms update`
|
||||||
|
|
||||||
|
Update the SMS (Twilio) configuration.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> notification sms update --id <int> --account-sid <string> --from-number <string> [--messaging-service-sid <string>] [--api-base-url <string>] [--auth-token <string>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--id` | yes | SMS config ID |
|
||||||
|
| `--account-sid` | yes | Twilio Account SID |
|
||||||
|
| `--from-number` | yes | Sender phone number (E.164) |
|
||||||
|
| `--messaging-service-sid` | no | Twilio Messaging Service SID. **Omitting it CLEARS the stored value** (the update overwrites it). |
|
||||||
|
| `--api-base-url` | no | API base URL override (preserves existing if omitted) |
|
||||||
|
| `--auth-token` | no | Twilio Auth Token. **Omitting it PRESERVES the stored token.** Never printed back. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `security` — Security settings
|
### `security` — Security settings
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|||||||
|
|
||||||
public record ListNotificationListsCommand;
|
public record ListNotificationListsCommand;
|
||||||
public record GetNotificationListCommand(int NotificationListId);
|
public record GetNotificationListCommand(int NotificationListId);
|
||||||
public record CreateNotificationListCommand(string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email);
|
public record CreateNotificationListCommand(string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList<string>? RecipientPhones = null);
|
||||||
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email);
|
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList<string>? RecipientPhones = null);
|
||||||
public record DeleteNotificationListCommand(int NotificationListId);
|
public record DeleteNotificationListCommand(int NotificationListId);
|
||||||
public record ListSmtpConfigsCommand;
|
public record ListSmtpConfigsCommand;
|
||||||
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null);
|
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null);
|
||||||
|
|||||||
@@ -1686,9 +1686,9 @@ public class ManagementActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
var repo = sp.GetRequiredService<INotificationRepository>();
|
var repo = sp.GetRequiredService<INotificationRepository>();
|
||||||
var list = new NotificationList(cmd.Name) { Type = cmd.Type };
|
var list = new NotificationList(cmd.Name) { Type = cmd.Type };
|
||||||
foreach (var email in cmd.RecipientEmails)
|
foreach (var recipient in BuildRecipients(cmd.Type, cmd.RecipientEmails, cmd.RecipientPhones))
|
||||||
{
|
{
|
||||||
list.Recipients.Add(new NotificationRecipient(email, email));
|
list.Recipients.Add(recipient);
|
||||||
}
|
}
|
||||||
await repo.AddNotificationListAsync(list);
|
await repo.AddNotificationListAsync(list);
|
||||||
await repo.SaveChangesAsync();
|
await repo.SaveChangesAsync();
|
||||||
@@ -1710,12 +1710,10 @@ public class ManagementActor : ReceiveActor
|
|||||||
await repo.DeleteRecipientAsync(r.Id);
|
await repo.DeleteRecipientAsync(r.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var email in cmd.RecipientEmails)
|
foreach (var recipient in BuildRecipients(cmd.Type, cmd.RecipientEmails, cmd.RecipientPhones))
|
||||||
{
|
{
|
||||||
await repo.AddRecipientAsync(new NotificationRecipient(email, email)
|
recipient.NotificationListId = cmd.NotificationListId;
|
||||||
{
|
await repo.AddRecipientAsync(recipient);
|
||||||
NotificationListId = cmd.NotificationListId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await repo.UpdateNotificationListAsync(list);
|
await repo.UpdateNotificationListAsync(list);
|
||||||
@@ -1733,6 +1731,33 @@ public class ManagementActor : ReceiveActor
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SMS Notifications (S6): build the recipient set for a notification list according
|
||||||
|
/// to its delivery channel. Email lists map each <paramref name="recipientEmails"/>
|
||||||
|
/// entry to an <see cref="NotificationRecipient.ForEmail"/> recipient; SMS lists map
|
||||||
|
/// each <paramref name="recipientPhones"/> entry to an <see cref="NotificationRecipient.ForSms"/>
|
||||||
|
/// recipient. The off-channel source list is ignored so an Email list never stores a
|
||||||
|
/// phone in EmailAddress (and vice versa).
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<NotificationRecipient> BuildRecipients(
|
||||||
|
NotificationType type, IReadOnlyList<string> recipientEmails, IReadOnlyList<string>? recipientPhones)
|
||||||
|
{
|
||||||
|
if (type == NotificationType.Sms)
|
||||||
|
{
|
||||||
|
foreach (var phone in recipientPhones ?? Array.Empty<string>())
|
||||||
|
{
|
||||||
|
yield return NotificationRecipient.ForSms(phone, phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var email in recipientEmails)
|
||||||
|
{
|
||||||
|
yield return NotificationRecipient.ForEmail(email, email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MgmtSvc-020: project an SmtpConfiguration to a credential-free shape so the
|
/// MgmtSvc-020: project an SmtpConfiguration to a credential-free shape so the
|
||||||
/// stored Credentials (SMTP password / OAuth2 client secret) never leaves this
|
/// stored Credentials (SMTP password / OAuth2 client secret) never leaves this
|
||||||
|
|||||||
@@ -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]
|
[Fact]
|
||||||
public void NotificationUpdate_CoreFieldsRequired()
|
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]
|
[Fact]
|
||||||
public void ApiMethodUpdate_CoreFieldsRequired()
|
public void ApiMethodUpdate_CoreFieldsRequired()
|
||||||
|
|||||||
@@ -1365,6 +1365,43 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
Assert.Equal(Commons.Types.Enums.NotificationType.Sms, added!.Type);
|
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]
|
[Fact]
|
||||||
public void CreateNotificationList_DefaultType_IsEmail()
|
public void CreateNotificationList_DefaultType_IsEmail()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user