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)
{
// Pin to the real named-client name so tests catch accidental renames.
_httpClientFactory
.CreateClient(SmsNotificationDeliveryAdapter.HttpClientName)
.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);
// The factory must be asked for the exact named client the adapter is documented
// to use, not an arbitrary string.
_httpClientFactory.Received(1).CreateClient(SmsNotificationDeliveryAdapter.HttpClientName);
}
[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_IncompleteConfig_NoFromAndNoMessagingServiceSid_ReturnsPermanent()
{
// AccountSid + AuthToken are present but BOTH FromNumber and MessagingServiceSid
// are absent — the adapter cannot build a valid Twilio request. This is a
// configuration error that cannot be resolved by retrying; expect Permanent.
var config = new SmsConfiguration("AC_account_sid", "") // empty FromNumber
{
Id = 1,
AuthToken = AuthToken,
MessagingServiceSid = null, // no fallback either
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.
// NOTE: the HTTP-error detail string ("Twilio returned HTTP 400…") is built
// without ever embedding the credential, so the primary guarantee here is
// documentation of intent. The CredentialRedactor.Scrub call still runs as
// defense-in-depth in case the detail format ever changes.
var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.BadRequest);
var adapter = CreateAdapter(handler);
var outcome = await adapter.DeliverAsync(MakeNotification());
Assert.NotNull(outcome.Error);
// Bare token must not appear.
Assert.DoesNotContain(AuthToken, outcome.Error);
// The "AccountSid:AuthToken" composite (the Basic-auth credential before
// base64 encoding) must not appear — tests the defense-in-depth Scrub.
const string accountSid = "AC_account_sid";
var composite = $"{accountSid}:{AuthToken}";
Assert.DoesNotContain(composite, outcome.Error);
// The base64-encoded form of the composite must not appear either.
var base64Credential = Convert.ToBase64String(
System.Text.Encoding.ASCII.GetBytes(composite));
Assert.DoesNotContain(base64Credential, 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));
}
}