diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsErrorClassifier.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsErrorClassifier.cs new file mode 100644 index 00000000..70a7eda0 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsErrorClassifier.cs @@ -0,0 +1,112 @@ +using System.Net; + +namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery; + +/// +/// 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. +/// +public enum SmsErrorClass +{ + /// Cancellation or an unrecognised exception — caller decides. + Unknown, + + /// Retryable failure (HTTP 5xx / 408 / 429, transport/timeout error). + Transient, + + /// Non-retryable failure (other 4xx — bad creds, invalid number) — must not be retried. + Permanent, +} + +/// +/// Classifies a Twilio-REST SMS delivery failure. Mirrors +/// '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. +/// +/// Transient: HTTP 5xx, 408, 429; , +/// and that are +/// NOT a caller-requested cancellation. Everything else (other 4xx — 400 invalid / +/// unsubscribed number, 401/403 bad credentials) is permanent. +/// +/// +public static class SmsErrorClassifier +{ + /// + /// 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). + /// + /// The HTTP status code returned by the Twilio Messages endpoint. + /// The describing the failure (never for a status code). + 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; + } + + /// + /// Classifies a transport-layer exception thrown while issuing the Twilio request. + /// A caller-requested cancellation is never treated as a transient SMS error. + /// + /// The exception thrown by the HTTP send. + /// + /// The token governing the send; a requested cancellation classifies as + /// so the caller can re-throw it. + /// + /// The describing whether the failure is transient or unknown. + 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; + } + + /// + /// Convenience predicate: true when the HTTP status code classifies as + /// . + /// + /// The HTTP status code to classify. + /// true when returns . + public static bool IsTransient(HttpStatusCode statusCode) + => Classify(statusCode) == SmsErrorClass.Transient; + + /// + /// Convenience predicate: true when the exception classifies as + /// . + /// + /// The exception to classify. + /// Cancellation token passed to . + /// true when returns . + public static bool IsTransient(Exception ex, CancellationToken cancellationToken) + => Classify(ex, cancellationToken) == SmsErrorClass.Transient; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsNotificationDeliveryAdapter.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsNotificationDeliveryAdapter.cs new file mode 100644 index 00000000..655ba73b --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/SmsNotificationDeliveryAdapter.cs @@ -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; + +/// +/// 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 ), +/// loads the central , composes a plain-text body, and +/// issues one POST .../Messages.json per recipient (Twilio has no BCC). Each +/// response is classified via and rolled up into the +/// outbox's three-way per design D6. +/// +/// 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). +/// +/// +/// The Twilio Auth Token is NEVER allowed to appear in a returned +/// or in any log: every outbound error string is +/// passed through with the token before it is +/// logged or returned. +/// +/// +public sealed class SmsNotificationDeliveryAdapter : INotificationDeliveryAdapter +{ + /// Provider default Twilio REST base URL, used when the config row leaves ApiBaseUrl unset. + private const string DefaultApiBaseUrl = "https://api.twilio.com"; + + /// The named registered for Twilio in ServiceCollectionExtensions. + public const string HttpClientName = "Twilio"; + + private readonly INotificationRepository _repository; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly SmsOptions _options; + + /// Initializes a new instance of . + /// Repository for resolving notification list recipients and the SMS configuration. + /// Factory creating the named "Twilio" HTTP client per delivery attempt. + /// Logger instance. + /// SMS options providing the documented message-length cap and timeout fallback. + public SmsNotificationDeliveryAdapter( + INotificationRepository repository, + IHttpClientFactory httpClientFactory, + ILogger logger, + IOptions 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(); + } + + /// + public NotificationType Type => NotificationType.Sms; + + /// + public async Task 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(); + var permanentlyFailed = new List(); + 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); + } + + /// + /// 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 . + /// + private async Task 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> + { + new("To", toNumber), + new("Body", body), + }; + if (hasMessagingService) + { + form.Add(new KeyValuePair( + "MessagingServiceSid", smsConfig.MessagingServiceSid!)); + } + else + { + form.Add(new KeyValuePair("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); + } + } + + /// + /// 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. + /// + private DeliveryOutcome RollUp( + string listName, + IReadOnlyList accepted, + IReadOnlyList 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); + } + + /// + /// Composes the plain-text SMS body: Subject + newline + Body + /// (skipping whichever is empty), truncated to + /// with a trailing ellipsis when over the cap. SMS has no subject line. + /// + private string ComposeBody(string? subject, string? body) + { + var parts = new List(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); + } + + /// The classified outcome of a single per-recipient Twilio attempt. + /// + /// = accepted (HTTP 2xx); otherwise the failure class. + /// + /// A redacted, human-readable failure description, or null on accept. + private readonly record struct SmsAttempt(SmsErrorClass Class, string? Detail); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs index 7aedcb95..5e817e30 100644 --- a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi; using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery; using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi; @@ -15,6 +16,9 @@ public static class ServiceCollectionExtensions /// Configuration section bound to . public const string OptionsSection = "ScadaBridge:NotificationOutbox"; + /// Configuration section bound to . + public const string SmsOptionsSection = "ScadaBridge:Sms"; + /// /// Registers the Notification Outbox services: the /// binding and the channel delivery adapters. @@ -32,6 +36,13 @@ public static class ServiceCollectionExtensions /// directly. The resolves the adapters from a fresh /// scope per dispatch sweep rather than holding them, so no scoped adapter is captured by /// the singleton actor. + /// + /// The (Twilio-REST) is registered the same + /// way alongside the Email adapter, so both resolve from + /// GetServices<INotificationDeliveryAdapter>(). It also binds + validates + /// (ScadaBridge:Sms) and the named "Twilio" + /// ; unlike the Email adapter it needs no NotificationService + /// machinery (a single outbound HTTPS POST per recipient). /// /// The DI service collection to register notification outbox services into. /// The same instance for chaining. @@ -42,13 +53,33 @@ public static class ServiceCollectionExtensions services.AddOptions() .BindConfiguration(OptionsSection); - // Scoped: the adapter holds a scoped INotificationRepository. Registered both under - // the interface (so the dispatch sweep can enumerate every channel adapter) and as - // the concrete type (so callers and tests can resolve it directly). + // Bind + validate SmsOptions (ScadaBridge:Sms): the SMS adapter's body cap and + // per-request timeout fallback. ValidateOnStart surfaces a bad option at host + // 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() + .BindConfiguration(SmsOptionsSection) + .ValidateOnStart(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, 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()) + // and as the concrete type (so callers and tests can resolve it directly). services.AddScoped(); services.AddScoped( sp => sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped( + sp => sp.GetRequiredService()); + // KPI history (M6): the recorder singleton enumerates every IKpiSampleSource each // sampling pass to snapshot the outbox delivery KPIs into the central history store. // TryAddEnumerable is idempotent — no double-registration if AddNotificationOutbox diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/SmsOptions.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/SmsOptions.cs new file mode 100644 index 00000000..d91749c8 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/SmsOptions.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox; + +/// +/// Configuration options for the SMS (Twilio-REST) notification delivery adapter, +/// bound from the ScadaBridge:Sms configuration section. +/// +/// SMS connection settings are primarily carried by the deployed +/// SmsConfiguration entity. These values supply the documented fallbacks / +/// caps used by the central Notification Outbox's +/// SmsNotificationDeliveryAdapter: caps the +/// composed body, and is the per-request +/// timeout used when the deployed SmsConfiguration.ConnectionTimeoutSeconds +/// field is left unset (non-positive) — a value present on the row always takes +/// precedence. +/// +public sealed class SmsOptions +{ + /// + /// 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. + /// + public int MaxMessageLength { get; set; } = 1600; + + /// + /// Per-request connection/response timeout (seconds) used when the deployed + /// SmsConfiguration.ConnectionTimeoutSeconds is unset (non-positive). + /// Default 30s. Must be strictly positive. + /// + public int ConnectionTimeoutSeconds { get; set; } = 30; +} + +/// +/// Validates on startup. A non-positive +/// would truncate every body to nothing, +/// and a non-positive would make +/// the per-request timeout meaningless, so both are required to be strictly positive. +/// +public sealed class SmsOptionsValidator : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, SmsOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var failures = new List(); + + 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; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj index 8498f29d..c18250c8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj @@ -10,6 +10,9 @@ + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs new file mode 100644 index 00000000..c185ed9f --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/Delivery/SmsNotificationDeliveryAdapterTests.cs @@ -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; + +/// +/// Tests for the Twilio-REST SMS outbox delivery adapter (T9 pivot) — list/recipient/ +/// SMS-config resolution, the per-recipient Twilio POST, the per-attempt +/// classification, the D6 roll-up, and the hard +/// invariant that the Twilio Auth Token never leaks into a returned outcome. +/// +public class SmsNotificationDeliveryAdapterTests +{ + private const string AuthToken = "super-secret-auth-token-abcdef0123456789"; + + private readonly INotificationRepository _repository = Substitute.For(); + private readonly IHttpClientFactory _httpClientFactory = Substitute.For(); + + /// + /// A scriptable 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. + /// + private sealed class ScriptedHttpMessageHandler : HttpMessageHandler + { + private readonly Queue>> _responders; + + public List Requests { get; } = new(); + public List RequestBodies { get; } = new(); + + private ScriptedHttpMessageHandler(IEnumerable>> responders) + { + _responders = new Queue>>(responders); + } + + /// One queued response per status code (one per recipient POST). + public static ScriptedHttpMessageHandler ForStatuses(params HttpStatusCode[] statusCodes) => + new(statusCodes.Select(code => + new Func>( + _ => Task.FromResult(new HttpResponseMessage(code) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + })))); + + /// Custom per-request responders (e.g. to throw a transport exception). + public static ScriptedHttpMessageHandler ForResponders( + params Func>[] responders) => new(responders); + + protected override async Task 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()).Returns(_ => new HttpClient(handler)); + return new SmsNotificationDeliveryAdapter( + _repository, + _httpClientFactory, + NullLogger.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? 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()).Returns(list); + _repository.GetRecipientsByListIdAsync(1, Arg.Any()).Returns(recipients); + _repository.GetSmsConfigurationAsync(Arg.Any()) + .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()) + .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()).Returns(list); + _repository.GetRecipientsByListIdAsync(1, Arg.Any()) + .Returns(new List + { + 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()).Returns(list); + _repository.GetRecipientsByListIdAsync(1, Arg.Any()) + .Returns(new List { NotificationRecipient.ForSms("R", "+15551112222") }); + _repository.GetSmsConfigurationAsync(Arg.Any()) + .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( + () => adapter.DeliverAsync(MakeNotification(), cts.Token)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs index 1ffa7ede..a9e42359 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ServiceRegistrationTests.cs @@ -99,15 +99,48 @@ public class ServiceRegistrationTests } [Fact] - public void AddNotificationOutbox_RegistersEmailAdapter_AsINotificationDeliveryAdapter() + public void AddNotificationOutbox_RegistersSmsDeliveryAdapter() + { + using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + + var adapter = scope.ServiceProvider.GetRequiredService(); + + Assert.NotNull(adapter); + Assert.Equal(NotificationType.Sms, adapter.Type); + } + + [Fact] + public void AddNotificationOutbox_RegistersBothAdapters_AsINotificationDeliveryAdapter() { using var provider = BuildProvider(); using var scope = provider.CreateScope(); var adapters = scope.ServiceProvider.GetServices().ToList(); - var email = Assert.Single(adapters); - Assert.IsType(email); - Assert.Equal(NotificationType.Email, email.Type); + // Both the Email and SMS channel adapters must resolve from the enumerable so the + // dispatch sweep can route a notification to the adapter matching its 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>().Value; + + Assert.NotNull(options); + Assert.Equal(1600, options.MaxMessageLength); + Assert.Equal(30, options.ConnectionTimeoutSeconds); } }