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)); } }