feat(sms): Twilio SmsNotificationDeliveryAdapter + classifier + options + DI (S3)

This commit is contained in:
Joseph Doherty
2026-06-19 10:14:52 -04:00
parent 609bdb37ef
commit a1d484a5ff
7 changed files with 1069 additions and 7 deletions
@@ -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));
}
}