feat(sms): Twilio SmsNotificationDeliveryAdapter + classifier + options + DI (S3)
This commit is contained in:
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classification of a Twilio-REST SMS delivery failure. This decides whether a
|
||||||
|
/// failure is retried (the whole notification is re-queued) or surfaced as permanent
|
||||||
|
/// (the notification is parked), so it is part of the system's correctness-relevant
|
||||||
|
/// behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public enum SmsErrorClass
|
||||||
|
{
|
||||||
|
/// <summary>Cancellation or an unrecognised exception — caller decides.</summary>
|
||||||
|
Unknown,
|
||||||
|
|
||||||
|
/// <summary>Retryable failure (HTTP 5xx / 408 / 429, transport/timeout error).</summary>
|
||||||
|
Transient,
|
||||||
|
|
||||||
|
/// <summary>Non-retryable failure (other 4xx — bad creds, invalid number) — must not be retried.</summary>
|
||||||
|
Permanent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies a Twilio-REST SMS delivery failure. Mirrors
|
||||||
|
/// <see cref="ZB.MOM.WW.ScadaBridge.NotificationService.SmtpErrorClassifier"/>'s
|
||||||
|
/// enum + static-method shape so the SMS adapter routes every failure through one
|
||||||
|
/// local policy (no cross-project reference to the External System Gateway). The
|
||||||
|
/// classification is keyed off the numeric HTTP status code (Twilio returns rich
|
||||||
|
/// status codes) and the typed transport exceptions rather than locale-dependent
|
||||||
|
/// substring matching.
|
||||||
|
/// <para>
|
||||||
|
/// Transient: HTTP 5xx, 408, 429; <see cref="HttpRequestException"/>,
|
||||||
|
/// <see cref="TimeoutException"/> and <see cref="TaskCanceledException"/> that are
|
||||||
|
/// NOT a caller-requested cancellation. Everything else (other 4xx — 400 invalid /
|
||||||
|
/// unsubscribed number, 401/403 bad credentials) is permanent.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class SmsErrorClassifier
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies an HTTP response status code. Twilio accepts a message with a 2xx;
|
||||||
|
/// 5xx / 408 / 429 are transient (provider-side / rate-limited); any other 4xx is
|
||||||
|
/// permanent (the request itself is wrong and retrying cannot fix it).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="statusCode">The HTTP status code returned by the Twilio Messages endpoint.</param>
|
||||||
|
/// <returns>The <see cref="SmsErrorClass"/> describing the failure (never <see cref="SmsErrorClass.Unknown"/> for a status code).</returns>
|
||||||
|
public static SmsErrorClass Classify(HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
var code = (int)statusCode;
|
||||||
|
|
||||||
|
if (code == 408 || code == 429 || (code >= 500 && code < 600))
|
||||||
|
{
|
||||||
|
return SmsErrorClass.Transient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any non-2xx status that is not 408/429/5xx is a permanent client error.
|
||||||
|
return SmsErrorClass.Permanent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies a transport-layer exception thrown while issuing the Twilio request.
|
||||||
|
/// A caller-requested cancellation is never treated as a transient SMS error.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ex">The exception thrown by the HTTP send.</param>
|
||||||
|
/// <param name="cancellationToken">
|
||||||
|
/// The token governing the send; a requested cancellation classifies as
|
||||||
|
/// <see cref="SmsErrorClass.Unknown"/> so the caller can re-throw it.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The <see cref="SmsErrorClass"/> describing whether the failure is transient or unknown.</returns>
|
||||||
|
public static SmsErrorClass Classify(Exception ex, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(ex);
|
||||||
|
|
||||||
|
// A deliberate cancellation is not an SMS error at all — TaskCanceledException
|
||||||
|
// derives from OperationCanceledException, so this guard catches both an
|
||||||
|
// HttpClient request-timeout cancel and a caller cancel; only the latter is
|
||||||
|
// honoured here so the caller can re-throw.
|
||||||
|
if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return SmsErrorClass.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A connection failure, a request-level timeout (HttpClient surfaces its own
|
||||||
|
// timeout as a TaskCanceledException with no caller cancel) and an explicit
|
||||||
|
// TimeoutException are all retryable — the message has not been rejected.
|
||||||
|
if (ex is HttpRequestException or TimeoutException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
return SmsErrorClass.Transient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SmsErrorClass.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience predicate: true when the HTTP status code classifies as
|
||||||
|
/// <see cref="SmsErrorClass.Transient"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="statusCode">The HTTP status code to classify.</param>
|
||||||
|
/// <returns><c>true</c> when <see cref="Classify(HttpStatusCode)"/> returns <see cref="SmsErrorClass.Transient"/>.</returns>
|
||||||
|
public static bool IsTransient(HttpStatusCode statusCode)
|
||||||
|
=> Classify(statusCode) == SmsErrorClass.Transient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience predicate: true when the exception classifies as
|
||||||
|
/// <see cref="SmsErrorClass.Transient"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ex">The exception to classify.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token passed to <see cref="Classify(Exception, CancellationToken)"/>.</param>
|
||||||
|
/// <returns><c>true</c> when <see cref="Classify(Exception, CancellationToken)"/> returns <see cref="SmsErrorClass.Transient"/>.</returns>
|
||||||
|
public static bool IsTransient(Exception ex, CancellationToken cancellationToken)
|
||||||
|
=> Classify(ex, cancellationToken) == SmsErrorClass.Transient;
|
||||||
|
}
|
||||||
+370
@@ -0,0 +1,370 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.NotificationService;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SMS channel delivery adapter for the central notification outbox (T9 pivot).
|
||||||
|
///
|
||||||
|
/// Calls Twilio directly over its REST API — no SDK — honouring the project's
|
||||||
|
/// no-new-NuGet-package rule. The adapter resolves the notification's list, keeps
|
||||||
|
/// only SMS-eligible recipients (non-empty <see cref="NotificationRecipient.PhoneNumber"/>),
|
||||||
|
/// loads the central <see cref="SmsConfiguration"/>, composes a plain-text body, and
|
||||||
|
/// issues one <c>POST .../Messages.json</c> per recipient (Twilio has no BCC). Each
|
||||||
|
/// response is classified via <see cref="SmsErrorClassifier"/> and rolled up into the
|
||||||
|
/// outbox's three-way <see cref="DeliveryOutcome"/> per design D6.
|
||||||
|
/// <para>
|
||||||
|
/// Mirrors the Email adapter's structure (load list → recipients → config, build
|
||||||
|
/// message, classify outcome). "Accepted by Twilio" (HTTP 2xx) is treated as
|
||||||
|
/// Delivered, exactly as the Email adapter treats "accepted by the SMTP server";
|
||||||
|
/// true delivery confirmation needs a status-callback webhook (out of scope).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The Twilio Auth Token is NEVER allowed to appear in a returned
|
||||||
|
/// <see cref="DeliveryOutcome.Error"/> or in any log: every outbound error string is
|
||||||
|
/// passed through <see cref="CredentialRedactor.Scrub"/> with the token before it is
|
||||||
|
/// logged or returned.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SmsNotificationDeliveryAdapter : INotificationDeliveryAdapter
|
||||||
|
{
|
||||||
|
/// <summary>Provider default Twilio REST base URL, used when the config row leaves <c>ApiBaseUrl</c> unset.</summary>
|
||||||
|
private const string DefaultApiBaseUrl = "https://api.twilio.com";
|
||||||
|
|
||||||
|
/// <summary>The named <see cref="HttpClient"/> registered for Twilio in <c>ServiceCollectionExtensions</c>.</summary>
|
||||||
|
public const string HttpClientName = "Twilio";
|
||||||
|
|
||||||
|
private readonly INotificationRepository _repository;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ILogger<SmsNotificationDeliveryAdapter> _logger;
|
||||||
|
private readonly SmsOptions _options;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of <see cref="SmsNotificationDeliveryAdapter"/>.</summary>
|
||||||
|
/// <param name="repository">Repository for resolving notification list recipients and the SMS configuration.</param>
|
||||||
|
/// <param name="httpClientFactory">Factory creating the named <c>"Twilio"</c> HTTP client per delivery attempt.</param>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="options">SMS options providing the documented message-length cap and timeout fallback.</param>
|
||||||
|
public SmsNotificationDeliveryAdapter(
|
||||||
|
INotificationRepository repository,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<SmsNotificationDeliveryAdapter> logger,
|
||||||
|
IOptions<SmsOptions> options)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_logger = logger;
|
||||||
|
// SmsOptions supplies the documented body cap and the timeout fallback used
|
||||||
|
// when a deployed SmsConfiguration row leaves ConnectionTimeoutSeconds unset.
|
||||||
|
_options = options?.Value ?? new SmsOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public NotificationType Type => NotificationType.Sms;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<DeliveryOutcome> DeliverAsync(
|
||||||
|
Notification notification, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(notification);
|
||||||
|
|
||||||
|
var list = await _repository.GetListByNameAsync(notification.ListName, cancellationToken);
|
||||||
|
if (list == null)
|
||||||
|
{
|
||||||
|
return DeliveryOutcome.Permanent(
|
||||||
|
$"Notification list '{notification.ListName}' not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken);
|
||||||
|
var phoneNumbers = recipients
|
||||||
|
.Where(r => !string.IsNullOrWhiteSpace(r.PhoneNumber))
|
||||||
|
.Select(r => r.PhoneNumber!.Trim())
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
if (phoneNumbers.Count == 0)
|
||||||
|
{
|
||||||
|
return DeliveryOutcome.Permanent(
|
||||||
|
$"Notification list '{notification.ListName}' has no SMS recipients");
|
||||||
|
}
|
||||||
|
|
||||||
|
var smsConfig = await _repository.GetSmsConfigurationAsync(cancellationToken);
|
||||||
|
if (smsConfig == null)
|
||||||
|
{
|
||||||
|
return DeliveryOutcome.Permanent("No SMS configuration available");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Account SID + Auth Token are mandatory (they form the Basic-auth header
|
||||||
|
// and the SID is on the URL path). A From number is required UNLESS a
|
||||||
|
// Messaging Service SID is supplied, in which case From is optional. None of
|
||||||
|
// these can be fixed by retrying, so a missing field is a permanent failure.
|
||||||
|
if (string.IsNullOrWhiteSpace(smsConfig.AccountSid)
|
||||||
|
|| string.IsNullOrWhiteSpace(smsConfig.AuthToken))
|
||||||
|
{
|
||||||
|
return DeliveryOutcome.Permanent(
|
||||||
|
"SMS configuration is incomplete: AccountSid and AuthToken are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasMessagingService = !string.IsNullOrWhiteSpace(smsConfig.MessagingServiceSid);
|
||||||
|
if (!hasMessagingService && string.IsNullOrWhiteSpace(smsConfig.FromNumber))
|
||||||
|
{
|
||||||
|
return DeliveryOutcome.Permanent(
|
||||||
|
"SMS configuration is incomplete: a FromNumber or MessagingServiceSid is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
var authToken = smsConfig.AuthToken!;
|
||||||
|
var body = ComposeBody(notification.Subject, notification.Body);
|
||||||
|
|
||||||
|
var baseUrl = string.IsNullOrWhiteSpace(smsConfig.ApiBaseUrl)
|
||||||
|
? DefaultApiBaseUrl
|
||||||
|
: smsConfig.ApiBaseUrl!.TrimEnd('/');
|
||||||
|
var requestUri = $"{baseUrl}/2010-04-01/Accounts/{smsConfig.AccountSid}/Messages.json";
|
||||||
|
|
||||||
|
// Config-row value overrides the SmsOptions fallback for the per-request timeout.
|
||||||
|
var timeoutSeconds = smsConfig.ConnectionTimeoutSeconds > 0
|
||||||
|
? smsConfig.ConnectionTimeoutSeconds
|
||||||
|
: _options.ConnectionTimeoutSeconds;
|
||||||
|
|
||||||
|
var authHeader = new AuthenticationHeaderValue(
|
||||||
|
"Basic",
|
||||||
|
Convert.ToBase64String(Encoding.ASCII.GetBytes($"{smsConfig.AccountSid}:{authToken}")));
|
||||||
|
|
||||||
|
var httpClient = _httpClientFactory.CreateClient(HttpClientName);
|
||||||
|
|
||||||
|
var accepted = new List<string>();
|
||||||
|
var permanentlyFailed = new List<string>();
|
||||||
|
string? firstTransientError = null;
|
||||||
|
|
||||||
|
foreach (var phone in phoneNumbers)
|
||||||
|
{
|
||||||
|
// A caller-requested cancellation propagates; it is neither a success nor
|
||||||
|
// a delivery failure (mirrors the Email adapter).
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var attempt = await SendOneAsync(
|
||||||
|
httpClient, requestUri, authHeader, smsConfig, hasMessagingService,
|
||||||
|
phone, body, timeoutSeconds, authToken, cancellationToken);
|
||||||
|
|
||||||
|
switch (attempt.Class)
|
||||||
|
{
|
||||||
|
case SmsErrorClass.Transient:
|
||||||
|
firstTransientError ??= attempt.Detail;
|
||||||
|
break;
|
||||||
|
case SmsErrorClass.Permanent:
|
||||||
|
permanentlyFailed.Add(phone);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
accepted.Add(phone);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RollUp(notification.ListName, accepted, permanentlyFailed, firstTransientError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues one Twilio POST for a single recipient and classifies the result.
|
||||||
|
/// Transport exceptions are caught and classified here; a caller-requested
|
||||||
|
/// cancellation is re-thrown so it propagates out of <see cref="DeliverAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<SmsAttempt> SendOneAsync(
|
||||||
|
HttpClient httpClient,
|
||||||
|
string requestUri,
|
||||||
|
AuthenticationHeaderValue authHeader,
|
||||||
|
SmsConfiguration smsConfig,
|
||||||
|
bool hasMessagingService,
|
||||||
|
string toNumber,
|
||||||
|
string body,
|
||||||
|
int timeoutSeconds,
|
||||||
|
string authToken,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Per-request timeout layered over the caller's token via a linked CTS, so a
|
||||||
|
// slow Twilio response is classified transient (HttpClient surfaces the cancel
|
||||||
|
// as a TaskCanceledException with no caller-cancel) while a genuine caller
|
||||||
|
// cancel still propagates.
|
||||||
|
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
|
||||||
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cancellationToken, timeoutCts.Token);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var form = new List<KeyValuePair<string, string>>
|
||||||
|
{
|
||||||
|
new("To", toNumber),
|
||||||
|
new("Body", body),
|
||||||
|
};
|
||||||
|
if (hasMessagingService)
|
||||||
|
{
|
||||||
|
form.Add(new KeyValuePair<string, string>(
|
||||||
|
"MessagingServiceSid", smsConfig.MessagingServiceSid!));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
form.Add(new KeyValuePair<string, string>("From", smsConfig.FromNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
|
||||||
|
{
|
||||||
|
Content = new FormUrlEncodedContent(form),
|
||||||
|
};
|
||||||
|
request.Headers.Authorization = authHeader;
|
||||||
|
|
||||||
|
using var response = await httpClient.SendAsync(request, linkedCts.Token);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new SmsAttempt(SmsErrorClass.Unknown, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cls = SmsErrorClassifier.Classify(response.StatusCode);
|
||||||
|
var detail = CredentialRedactor.Scrub(
|
||||||
|
$"Twilio returned HTTP {(int)response.StatusCode} ({response.StatusCode}) for {toNumber}",
|
||||||
|
authToken);
|
||||||
|
|
||||||
|
if (cls == SmsErrorClass.Transient)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Transient SMS failure delivering to {Recipient}: {Detail}", toNumber, detail);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Permanent SMS failure delivering to {Recipient}: {Detail}", toNumber, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SmsAttempt(cls, detail);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// A caller-requested cancellation propagates; the per-request timeout
|
||||||
|
// cancel (timeoutCts) is NOT a caller cancel and falls through to the
|
||||||
|
// transient classifier below.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (SmsErrorClassifier.IsTransient(ex, cancellationToken))
|
||||||
|
{
|
||||||
|
var detail = CredentialRedactor.Scrub(
|
||||||
|
$"SMS transport failure for {toNumber} ({ex.GetType().Name}): {ex.Message}",
|
||||||
|
authToken);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Transient SMS failure delivering to {Recipient} ({ExceptionType}): {Detail}",
|
||||||
|
toNumber, ex.GetType().Name, detail);
|
||||||
|
return new SmsAttempt(SmsErrorClass.Transient, detail);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// An unclassified failure is treated as permanent: retrying a malformed
|
||||||
|
// request burns Twilio calls (mirrors the Email adapter's default-permanent
|
||||||
|
// stance for unclassified failures).
|
||||||
|
var detail = CredentialRedactor.Scrub(
|
||||||
|
$"SMS delivery failed for {toNumber} ({ex.GetType().Name}): {ex.Message}",
|
||||||
|
authToken);
|
||||||
|
_logger.LogError(
|
||||||
|
"Unclassified SMS failure delivering to {Recipient} ({ExceptionType}): {Detail}",
|
||||||
|
toNumber, ex.GetType().Name, detail);
|
||||||
|
return new SmsAttempt(SmsErrorClass.Permanent, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rolls the per-recipient outcomes up into a single notification-level outcome
|
||||||
|
/// per design D6: any transient → Transient (whole notification retries); else any
|
||||||
|
/// accepted → Success (the permanently-failed numbers are noted, NOT parked); else
|
||||||
|
/// (all permanent / zero accepted) → Permanent.
|
||||||
|
/// </summary>
|
||||||
|
private DeliveryOutcome RollUp(
|
||||||
|
string listName,
|
||||||
|
IReadOnlyList<string> accepted,
|
||||||
|
IReadOnlyList<string> permanentlyFailed,
|
||||||
|
string? firstTransientError)
|
||||||
|
{
|
||||||
|
// Any transient failure parks the WHOLE notification for retry at the fixed
|
||||||
|
// interval; numbers already accepted on this attempt get re-texted on retry
|
||||||
|
// (the same "re-send to all" characteristic the Email/BCC path already has).
|
||||||
|
if (firstTransientError != null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"SMS notification to list '{List}' has a transient failure; the notification will retry. {Detail}",
|
||||||
|
listName, firstTransientError);
|
||||||
|
return DeliveryOutcome.Transient(firstTransientError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No transient failures. If anything was accepted, do NOT park even when some
|
||||||
|
// numbers permanently failed — record the bad numbers and report success.
|
||||||
|
if (accepted.Count > 0)
|
||||||
|
{
|
||||||
|
var targets = string.Join(", ", accepted);
|
||||||
|
if (permanentlyFailed.Count > 0)
|
||||||
|
{
|
||||||
|
var note =
|
||||||
|
$"Delivered to {accepted.Count} recipient(s); " +
|
||||||
|
$"{permanentlyFailed.Count} permanently failed: {string.Join(", ", permanentlyFailed)}";
|
||||||
|
_logger.LogWarning(
|
||||||
|
"SMS notification to list '{List}' partially delivered: {Note}", listName, note);
|
||||||
|
// ResolvedTargets carries the accepted numbers AND the failed-number note
|
||||||
|
// so the audit row preserves the permanently-failed recipients.
|
||||||
|
return DeliveryOutcome.Success($"{targets} [{note}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeliveryOutcome.Success(targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing accepted and no transient failures — every recipient permanently
|
||||||
|
// failed. Park the notification.
|
||||||
|
var error = permanentlyFailed.Count > 0
|
||||||
|
? $"All SMS recipients permanently failed: {string.Join(", ", permanentlyFailed)}"
|
||||||
|
: "SMS delivery produced no accepted recipients";
|
||||||
|
_logger.LogError(
|
||||||
|
"SMS notification to list '{List}' failed permanently: {Error}", listName, error);
|
||||||
|
return DeliveryOutcome.Permanent(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes the plain-text SMS body: <c>Subject</c> + newline + <c>Body</c>
|
||||||
|
/// (skipping whichever is empty), truncated to <see cref="SmsOptions.MaxMessageLength"/>
|
||||||
|
/// with a trailing ellipsis when over the cap. SMS has no subject line.
|
||||||
|
/// </summary>
|
||||||
|
private string ComposeBody(string? subject, string? body)
|
||||||
|
{
|
||||||
|
var parts = new List<string>(2);
|
||||||
|
if (!string.IsNullOrWhiteSpace(subject))
|
||||||
|
{
|
||||||
|
parts.Add(subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
parts.Add(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
var composed = string.Join("\n", parts);
|
||||||
|
|
||||||
|
var max = _options.MaxMessageLength;
|
||||||
|
if (composed.Length <= max)
|
||||||
|
{
|
||||||
|
return composed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate to the cap and append an ellipsis (counted inside the cap so the
|
||||||
|
// result never exceeds MaxMessageLength). The ellipsis is a single char.
|
||||||
|
const string ellipsis = "…";
|
||||||
|
if (max <= ellipsis.Length)
|
||||||
|
{
|
||||||
|
return composed[..max];
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Concat(composed.AsSpan(0, max - ellipsis.Length), ellipsis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The classified outcome of a single per-recipient Twilio attempt.</summary>
|
||||||
|
/// <param name="Class">
|
||||||
|
/// <see cref="SmsErrorClass.Unknown"/> = accepted (HTTP 2xx); otherwise the failure class.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Detail">A redacted, human-readable failure description, or null on accept.</param>
|
||||||
|
private readonly record struct SmsAttempt(SmsErrorClass Class, string? Detail);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
|
||||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||||
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
|
||||||
@@ -15,6 +16,9 @@ public static class ServiceCollectionExtensions
|
|||||||
/// <summary>Configuration section bound to <see cref="NotificationOutboxOptions"/>.</summary>
|
/// <summary>Configuration section bound to <see cref="NotificationOutboxOptions"/>.</summary>
|
||||||
public const string OptionsSection = "ScadaBridge:NotificationOutbox";
|
public const string OptionsSection = "ScadaBridge:NotificationOutbox";
|
||||||
|
|
||||||
|
/// <summary>Configuration section bound to <see cref="SmsOptions"/>.</summary>
|
||||||
|
public const string SmsOptionsSection = "ScadaBridge:Sms";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers the Notification Outbox services: the <see cref="NotificationOutboxOptions"/>
|
/// Registers the Notification Outbox services: the <see cref="NotificationOutboxOptions"/>
|
||||||
/// binding and the channel delivery adapters.
|
/// binding and the channel delivery adapters.
|
||||||
@@ -32,6 +36,13 @@ public static class ServiceCollectionExtensions
|
|||||||
/// directly. The <see cref="NotificationOutboxActor"/> resolves the adapters from a fresh
|
/// directly. The <see cref="NotificationOutboxActor"/> resolves the adapters from a fresh
|
||||||
/// scope per dispatch sweep rather than holding them, so no scoped adapter is captured by
|
/// scope per dispatch sweep rather than holding them, so no scoped adapter is captured by
|
||||||
/// the singleton actor.
|
/// the singleton actor.
|
||||||
|
///
|
||||||
|
/// The <see cref="SmsNotificationDeliveryAdapter"/> (Twilio-REST) is registered the same
|
||||||
|
/// way alongside the Email adapter, so both resolve from
|
||||||
|
/// <c>GetServices<INotificationDeliveryAdapter>()</c>. It also binds + validates
|
||||||
|
/// <see cref="SmsOptions"/> (<c>ScadaBridge:Sms</c>) and the named <c>"Twilio"</c>
|
||||||
|
/// <see cref="HttpClient"/>; unlike the Email adapter it needs no NotificationService
|
||||||
|
/// machinery (a single outbound HTTPS POST per recipient).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">The DI service collection to register notification outbox services into.</param>
|
/// <param name="services">The DI service collection to register notification outbox services into.</param>
|
||||||
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
||||||
@@ -42,13 +53,33 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddOptions<NotificationOutboxOptions>()
|
services.AddOptions<NotificationOutboxOptions>()
|
||||||
.BindConfiguration(OptionsSection);
|
.BindConfiguration(OptionsSection);
|
||||||
|
|
||||||
// Scoped: the adapter holds a scoped INotificationRepository. Registered both under
|
// Bind + validate SmsOptions (ScadaBridge:Sms): the SMS adapter's body cap and
|
||||||
// the interface (so the dispatch sweep can enumerate every channel adapter) and as
|
// per-request timeout fallback. ValidateOnStart surfaces a bad option at host
|
||||||
// the concrete type (so callers and tests can resolve it directly).
|
// startup rather than at first delivery; the validator is registered via
|
||||||
|
// TryAddEnumerable so a re-call of AddNotificationOutbox does not double-register it.
|
||||||
|
services.AddOptions<SmsOptions>()
|
||||||
|
.BindConfiguration(SmsOptionsSection)
|
||||||
|
.ValidateOnStart();
|
||||||
|
services.TryAddEnumerable(
|
||||||
|
ServiceDescriptor.Singleton<IValidateOptions<SmsOptions>, SmsOptionsValidator>());
|
||||||
|
|
||||||
|
// Named HTTP client backing the Twilio-REST SMS adapter. Pooled handler lifetime
|
||||||
|
// is managed by IHttpClientFactory; the adapter applies the per-request timeout
|
||||||
|
// via a linked CTS (so the client's own Timeout stays at its default).
|
||||||
|
services.AddHttpClient(SmsNotificationDeliveryAdapter.HttpClientName);
|
||||||
|
|
||||||
|
// Scoped: the adapters hold a scoped INotificationRepository. Each is registered
|
||||||
|
// both under the interface (so the dispatch sweep can enumerate every channel
|
||||||
|
// adapter — both Email and SMS must resolve from GetServices<INotificationDeliveryAdapter>())
|
||||||
|
// and as the concrete type (so callers and tests can resolve it directly).
|
||||||
services.AddScoped<EmailNotificationDeliveryAdapter>();
|
services.AddScoped<EmailNotificationDeliveryAdapter>();
|
||||||
services.AddScoped<INotificationDeliveryAdapter>(
|
services.AddScoped<INotificationDeliveryAdapter>(
|
||||||
sp => sp.GetRequiredService<EmailNotificationDeliveryAdapter>());
|
sp => sp.GetRequiredService<EmailNotificationDeliveryAdapter>());
|
||||||
|
|
||||||
|
services.AddScoped<SmsNotificationDeliveryAdapter>();
|
||||||
|
services.AddScoped<INotificationDeliveryAdapter>(
|
||||||
|
sp => sp.GetRequiredService<SmsNotificationDeliveryAdapter>());
|
||||||
|
|
||||||
// KPI history (M6): the recorder singleton enumerates every IKpiSampleSource each
|
// KPI history (M6): the recorder singleton enumerates every IKpiSampleSource each
|
||||||
// sampling pass to snapshot the outbox delivery KPIs into the central history store.
|
// sampling pass to snapshot the outbox delivery KPIs into the central history store.
|
||||||
// TryAddEnumerable is idempotent — no double-registration if AddNotificationOutbox
|
// TryAddEnumerable is idempotent — no double-registration if AddNotificationOutbox
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for the SMS (Twilio-REST) notification delivery adapter,
|
||||||
|
/// bound from the <c>ScadaBridge:Sms</c> configuration section.
|
||||||
|
///
|
||||||
|
/// SMS connection settings are primarily carried by the deployed
|
||||||
|
/// <c>SmsConfiguration</c> entity. These values supply the documented fallbacks /
|
||||||
|
/// caps used by the central Notification Outbox's
|
||||||
|
/// <c>SmsNotificationDeliveryAdapter</c>: <see cref="MaxMessageLength"/> caps the
|
||||||
|
/// composed body, and <see cref="ConnectionTimeoutSeconds"/> is the per-request
|
||||||
|
/// timeout used when the deployed <c>SmsConfiguration.ConnectionTimeoutSeconds</c>
|
||||||
|
/// field is left unset (non-positive) — a value present on the row always takes
|
||||||
|
/// precedence.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SmsOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum length of the composed SMS body; longer bodies are truncated with an
|
||||||
|
/// ellipsis before the Twilio POST. Default 1600 (Twilio's per-message maximum).
|
||||||
|
/// Must be strictly positive.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxMessageLength { get; set; } = 1600;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-request connection/response timeout (seconds) used when the deployed
|
||||||
|
/// <c>SmsConfiguration.ConnectionTimeoutSeconds</c> is unset (non-positive).
|
||||||
|
/// Default 30s. Must be strictly positive.
|
||||||
|
/// </summary>
|
||||||
|
public int ConnectionTimeoutSeconds { get; set; } = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates <see cref="SmsOptions"/> on startup. A non-positive
|
||||||
|
/// <see cref="SmsOptions.MaxMessageLength"/> would truncate every body to nothing,
|
||||||
|
/// and a non-positive <see cref="SmsOptions.ConnectionTimeoutSeconds"/> would make
|
||||||
|
/// the per-request timeout meaningless, so both are required to be strictly positive.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SmsOptionsValidator : IValidateOptions<SmsOptions>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValidateOptionsResult Validate(string? name, SmsOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
if (options.MaxMessageLength <= 0)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"ScadaBridge:Sms:{nameof(SmsOptions.MaxMessageLength)} ({options.MaxMessageLength}) " +
|
||||||
|
"must be > 0; it caps the composed SMS body length.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.ConnectionTimeoutSeconds <= 0)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"ScadaBridge:Sms:{nameof(SmsOptions.ConnectionTimeoutSeconds)} ({options.ConnectionTimeoutSeconds}) " +
|
||||||
|
"must be > 0; it is the per-request Twilio timeout fallback.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return failures.Count > 0
|
||||||
|
? ValidateOptionsResult.Fail(failures)
|
||||||
|
: ValidateOptionsResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -10,6 +10,9 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Akka" />
|
<PackageReference Include="Akka" />
|
||||||
<PackageReference Include="Akka.Cluster.Tools" />
|
<PackageReference Include="Akka.Cluster.Tools" />
|
||||||
|
<!-- SMS delivery adapter (Twilio-REST) uses IHttpClientFactory / AddHttpClient.
|
||||||
|
Version comes from Directory.Packages.props (central package management). -->
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+445
@@ -0,0 +1,445 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the Twilio-REST SMS outbox delivery adapter (T9 pivot) — list/recipient/
|
||||||
|
/// SMS-config resolution, the per-recipient Twilio POST, the per-attempt
|
||||||
|
/// <see cref="SmsErrorClassifier"/> classification, the D6 roll-up, and the hard
|
||||||
|
/// invariant that the Twilio Auth Token never leaks into a returned outcome.
|
||||||
|
/// </summary>
|
||||||
|
public class SmsNotificationDeliveryAdapterTests
|
||||||
|
{
|
||||||
|
private const string AuthToken = "super-secret-auth-token-abcdef0123456789";
|
||||||
|
|
||||||
|
private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory = Substitute.For<IHttpClientFactory>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A scriptable <see cref="HttpMessageHandler"/> that returns a queued sequence of
|
||||||
|
/// responses (one per recipient POST) and records every outbound request so the
|
||||||
|
/// tests can assert the URL / auth header / form body.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ScriptedHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>> _responders;
|
||||||
|
|
||||||
|
public List<HttpRequestMessage> Requests { get; } = new();
|
||||||
|
public List<string> RequestBodies { get; } = new();
|
||||||
|
|
||||||
|
private ScriptedHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, Task<HttpResponseMessage>>> responders)
|
||||||
|
{
|
||||||
|
_responders = new Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>>(responders);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One queued response per status code (one per recipient POST).</summary>
|
||||||
|
public static ScriptedHttpMessageHandler ForStatuses(params HttpStatusCode[] statusCodes) =>
|
||||||
|
new(statusCodes.Select(code =>
|
||||||
|
new Func<HttpRequestMessage, Task<HttpResponseMessage>>(
|
||||||
|
_ => Task.FromResult(new HttpResponseMessage(code)
|
||||||
|
{
|
||||||
|
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||||
|
}))));
|
||||||
|
|
||||||
|
/// <summary>Custom per-request responders (e.g. to throw a transport exception).</summary>
|
||||||
|
public static ScriptedHttpMessageHandler ForResponders(
|
||||||
|
params Func<HttpRequestMessage, Task<HttpResponseMessage>>[] responders) => new(responders);
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Requests.Add(request);
|
||||||
|
RequestBodies.Add(request.Content == null
|
||||||
|
? string.Empty
|
||||||
|
: await request.Content.ReadAsStringAsync(cancellationToken));
|
||||||
|
|
||||||
|
if (_responders.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No scripted response left for request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _responders.Dequeue()(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SmsNotificationDeliveryAdapter CreateAdapter(HttpMessageHandler handler, SmsOptions? options = null)
|
||||||
|
{
|
||||||
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(handler));
|
||||||
|
return new SmsNotificationDeliveryAdapter(
|
||||||
|
_repository,
|
||||||
|
_httpClientFactory,
|
||||||
|
NullLogger<SmsNotificationDeliveryAdapter>.Instance,
|
||||||
|
Options.Create(options ?? new SmsOptions()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Notification MakeNotification(string listName = "ops-team")
|
||||||
|
{
|
||||||
|
return new Notification(
|
||||||
|
Guid.NewGuid().ToString(),
|
||||||
|
NotificationType.Sms,
|
||||||
|
listName,
|
||||||
|
"Subject",
|
||||||
|
"Body",
|
||||||
|
"site-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupList(
|
||||||
|
string name = "ops-team",
|
||||||
|
IReadOnlyList<string>? phones = null,
|
||||||
|
SmsConfiguration? config = null)
|
||||||
|
{
|
||||||
|
phones ??= new[] { "+15551112222" };
|
||||||
|
var list = new NotificationList(name) { Id = 1, Type = NotificationType.Sms };
|
||||||
|
var recipients = phones
|
||||||
|
.Select((p, i) => { var r = NotificationRecipient.ForSms($"R{i}", p); r.Id = i + 1; r.NotificationListId = 1; return r; })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_repository.GetListByNameAsync(name, Arg.Any<CancellationToken>()).Returns(list);
|
||||||
|
_repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>()).Returns(recipients);
|
||||||
|
_repository.GetSmsConfigurationAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(config ?? new SmsConfiguration("AC_account_sid", "+15550000000")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthToken = AuthToken,
|
||||||
|
ApiBaseUrl = "https://fake-twilio.test",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Type_IsSms()
|
||||||
|
{
|
||||||
|
Assert.Equal(NotificationType.Sms, CreateAdapter(ScriptedHttpMessageHandler.ForStatuses()).Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_SingleRecipient201_ReturnsSuccessWithResolvedTargets()
|
||||||
|
{
|
||||||
|
SetupList(phones: new[] { "+15551112222" });
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||||
|
Assert.NotNull(outcome.ResolvedTargets);
|
||||||
|
Assert.Contains("+15551112222", outcome.ResolvedTargets);
|
||||||
|
Assert.Null(outcome.Error);
|
||||||
|
|
||||||
|
// One POST to the Twilio Messages endpoint with the account SID on the path,
|
||||||
|
// a Basic auth header, and a form-encoded To / From / Body.
|
||||||
|
var request = Assert.Single(handler.Requests);
|
||||||
|
Assert.Equal(HttpMethod.Post, request.Method);
|
||||||
|
Assert.Equal(
|
||||||
|
"https://fake-twilio.test/2010-04-01/Accounts/AC_account_sid/Messages.json",
|
||||||
|
request.RequestUri!.ToString());
|
||||||
|
Assert.Equal("Basic", request.Headers.Authorization!.Scheme);
|
||||||
|
var body = Assert.Single(handler.RequestBodies);
|
||||||
|
Assert.Contains("To=", body);
|
||||||
|
Assert.Contains("From=", body);
|
||||||
|
Assert.Contains("Body=", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_MultiRecipient201_AllAccepted_ReturnsSuccess()
|
||||||
|
{
|
||||||
|
SetupList(phones: new[] { "+15551112222", "+15553334444" });
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created, HttpStatusCode.Created);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||||
|
Assert.Contains("+15551112222", outcome.ResolvedTargets);
|
||||||
|
Assert.Contains("+15553334444", outcome.ResolvedTargets);
|
||||||
|
Assert.Equal(2, handler.Requests.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_UsesMessagingServiceSid_WhenFromNumberAbsent()
|
||||||
|
{
|
||||||
|
var config = new SmsConfiguration("AC_account_sid", "")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthToken = AuthToken,
|
||||||
|
MessagingServiceSid = "MG_messaging_service",
|
||||||
|
ApiBaseUrl = "https://fake-twilio.test",
|
||||||
|
};
|
||||||
|
SetupList(config: config);
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||||
|
var body = Assert.Single(handler.RequestBodies);
|
||||||
|
Assert.Contains("MessagingServiceSid=MG_messaging_service", body);
|
||||||
|
Assert.DoesNotContain("From=", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_500_ReturnsTransient()
|
||||||
|
{
|
||||||
|
SetupList();
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.InternalServerError);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||||
|
Assert.NotNull(outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_429_ReturnsTransient()
|
||||||
|
{
|
||||||
|
SetupList();
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses((HttpStatusCode)429);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_HttpRequestException_ReturnsTransient()
|
||||||
|
{
|
||||||
|
SetupList();
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForResponders(
|
||||||
|
_ => throw new HttpRequestException("connection refused"));
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_Timeout_ReturnsTransient()
|
||||||
|
{
|
||||||
|
SetupList();
|
||||||
|
// HttpClient surfaces a per-request timeout as a TaskCanceledException with no
|
||||||
|
// caller cancel; the classifier treats that as transient. Simulate it directly.
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForResponders(
|
||||||
|
_ => throw new TaskCanceledException("The request was canceled due to timeout."));
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_400_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
SetupList();
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.BadRequest);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
Assert.NotNull(outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_401_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
SetupList();
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Unauthorized);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_MixAcceptedAndPermanentBad_ReturnsSuccessWithBadNumberNoted()
|
||||||
|
{
|
||||||
|
SetupList(phones: new[] { "+15551112222", "+1BADNUMBER" });
|
||||||
|
// First number accepted (201), second permanently rejected (400).
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created, HttpStatusCode.BadRequest);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
// D6: anything got through → Success (do NOT park); the bad number is noted.
|
||||||
|
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||||
|
Assert.Contains("+15551112222", outcome.ResolvedTargets);
|
||||||
|
Assert.Contains("+1BADNUMBER", outcome.ResolvedTargets);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_MixAcceptedAndTransient_ReturnsTransient()
|
||||||
|
{
|
||||||
|
SetupList(phones: new[] { "+15551112222", "+15553334444" });
|
||||||
|
// First accepted, second transient (503): D6 says any transient → whole notification retries.
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(
|
||||||
|
HttpStatusCode.Created, HttpStatusCode.ServiceUnavailable);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_ListNotFound_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
_repository.GetListByNameAsync("missing", Arg.Any<CancellationToken>())
|
||||||
|
.Returns((NotificationList?)null);
|
||||||
|
var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification("missing"));
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
Assert.Contains("missing", outcome.Error);
|
||||||
|
Assert.Contains("not found", outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_NoPhoneRecipients_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
var list = new NotificationList("ops-team") { Id = 1, Type = NotificationType.Sms };
|
||||||
|
// A recipient with only an email and no phone is not SMS-eligible.
|
||||||
|
_repository.GetListByNameAsync("ops-team", Arg.Any<CancellationToken>()).Returns(list);
|
||||||
|
_repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<NotificationRecipient>
|
||||||
|
{
|
||||||
|
NotificationRecipient.ForEmail("Alice", "alice@example.com"),
|
||||||
|
});
|
||||||
|
var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
Assert.Contains("no SMS recipients", outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_NoSmsConfig_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
var list = new NotificationList("ops-team") { Id = 1, Type = NotificationType.Sms };
|
||||||
|
_repository.GetListByNameAsync("ops-team", Arg.Any<CancellationToken>()).Returns(list);
|
||||||
|
_repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<NotificationRecipient> { NotificationRecipient.ForSms("R", "+15551112222") });
|
||||||
|
_repository.GetSmsConfigurationAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns((SmsConfiguration?)null);
|
||||||
|
var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
Assert.Contains("SMS configuration", outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_IncompleteConfig_MissingAuthToken_ReturnsPermanent()
|
||||||
|
{
|
||||||
|
var config = new SmsConfiguration("AC_account_sid", "+15550000000")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthToken = null, // missing secret
|
||||||
|
ApiBaseUrl = "https://fake-twilio.test",
|
||||||
|
};
|
||||||
|
SetupList(config: config);
|
||||||
|
var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
|
||||||
|
Assert.Contains("incomplete", outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_DefaultsToTwilioBaseUrl_WhenApiBaseUrlAbsent()
|
||||||
|
{
|
||||||
|
var config = new SmsConfiguration("AC_account_sid", "+15550000000")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthToken = AuthToken,
|
||||||
|
ApiBaseUrl = null, // falls back to https://api.twilio.com
|
||||||
|
};
|
||||||
|
SetupList(config: config);
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
var request = Assert.Single(handler.Requests);
|
||||||
|
Assert.StartsWith("https://api.twilio.com/2010-04-01/Accounts/", request.RequestUri!.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_AuthToken_NeverAppearsInReturnedError()
|
||||||
|
{
|
||||||
|
SetupList();
|
||||||
|
// A permanent failure (400) so the outcome carries an Error string.
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.BadRequest);
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.NotNull(outcome.Error);
|
||||||
|
Assert.DoesNotContain(AuthToken, outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_AuthToken_NeverAppearsOnTransportException()
|
||||||
|
{
|
||||||
|
SetupList();
|
||||||
|
// The exception message echoes the auth token; the adapter must scrub it.
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForResponders(
|
||||||
|
_ => throw new HttpRequestException($"auth failed with token {AuthToken}"));
|
||||||
|
var adapter = CreateAdapter(handler);
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(MakeNotification());
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
|
||||||
|
Assert.NotNull(outcome.Error);
|
||||||
|
Assert.DoesNotContain(AuthToken, outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_LongBody_TruncatedToMaxMessageLength()
|
||||||
|
{
|
||||||
|
SetupList();
|
||||||
|
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
|
||||||
|
var adapter = CreateAdapter(handler, new SmsOptions { MaxMessageLength = 20 });
|
||||||
|
|
||||||
|
var longNotification = new Notification(
|
||||||
|
Guid.NewGuid().ToString(), NotificationType.Sms, "ops-team",
|
||||||
|
"Subject", new string('x', 500), "site-1");
|
||||||
|
|
||||||
|
var outcome = await adapter.DeliverAsync(longNotification);
|
||||||
|
|
||||||
|
Assert.Equal(DeliveryResult.Success, outcome.Result);
|
||||||
|
// The encoded form body's Body value must not exceed the cap (form-encoding
|
||||||
|
// length aside, the raw text was truncated to 20 chars incl. ellipsis).
|
||||||
|
var body = Assert.Single(handler.RequestBodies);
|
||||||
|
Assert.Contains("Body=", body);
|
||||||
|
// The 500-char run cannot survive a 20-char cap.
|
||||||
|
Assert.DoesNotContain(new string('x', 100), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deliver_CallerCancellation_Propagates()
|
||||||
|
{
|
||||||
|
SetupList();
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created));
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
() => adapter.DeliverAsync(MakeNotification(), cts.Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,15 +99,48 @@ public class ServiceRegistrationTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddNotificationOutbox_RegistersEmailAdapter_AsINotificationDeliveryAdapter()
|
public void AddNotificationOutbox_RegistersSmsDeliveryAdapter()
|
||||||
|
{
|
||||||
|
using var provider = BuildProvider();
|
||||||
|
using var scope = provider.CreateScope();
|
||||||
|
|
||||||
|
var adapter = scope.ServiceProvider.GetRequiredService<SmsNotificationDeliveryAdapter>();
|
||||||
|
|
||||||
|
Assert.NotNull(adapter);
|
||||||
|
Assert.Equal(NotificationType.Sms, adapter.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddNotificationOutbox_RegistersBothAdapters_AsINotificationDeliveryAdapter()
|
||||||
{
|
{
|
||||||
using var provider = BuildProvider();
|
using var provider = BuildProvider();
|
||||||
using var scope = provider.CreateScope();
|
using var scope = provider.CreateScope();
|
||||||
|
|
||||||
var adapters = scope.ServiceProvider.GetServices<INotificationDeliveryAdapter>().ToList();
|
var adapters = scope.ServiceProvider.GetServices<INotificationDeliveryAdapter>().ToList();
|
||||||
|
|
||||||
var email = Assert.Single(adapters);
|
// Both the Email and SMS channel adapters must resolve from the enumerable so the
|
||||||
Assert.IsType<EmailNotificationDeliveryAdapter>(email);
|
// dispatch sweep can route a notification to the adapter matching its Type.
|
||||||
Assert.Equal(NotificationType.Email, email.Type);
|
Assert.Equal(2, adapters.Count);
|
||||||
|
|
||||||
|
// The set of advertised channel Types is exactly {Email, Sms}.
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { NotificationType.Email, NotificationType.Sms },
|
||||||
|
adapters.Select(a => a.Type).OrderBy(t => t).ToArray());
|
||||||
|
|
||||||
|
// And the concrete adapter implementations are the expected pair.
|
||||||
|
Assert.Contains(adapters, a => a is EmailNotificationDeliveryAdapter);
|
||||||
|
Assert.Contains(adapters, a => a is SmsNotificationDeliveryAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddNotificationOutbox_RegistersSmsOptions_WithDefaults()
|
||||||
|
{
|
||||||
|
using var provider = BuildProvider();
|
||||||
|
|
||||||
|
var options = provider.GetRequiredService<IOptions<SmsOptions>>().Value;
|
||||||
|
|
||||||
|
Assert.NotNull(options);
|
||||||
|
Assert.Equal(1600, options.MaxMessageLength);
|
||||||
|
Assert.Equal(30, options.ConnectionTimeoutSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user