feat(sms): Twilio SmsNotificationDeliveryAdapter + classifier + options + DI (S3)
This commit is contained in:
+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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user