Skip to content

Commit

Permalink
Added Twilio adapter for sending SMS reliably
Browse files Browse the repository at this point in the history
  • Loading branch information
jezzsantos committed Nov 30, 2024
1 parent 887b288 commit b0eaf8f
Show file tree
Hide file tree
Showing 34 changed files with 984 additions and 61 deletions.
172 changes: 172 additions & 0 deletions src/AncillaryApplication.UnitTests/TwilioApplicationSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using Application.Interfaces;
using Application.Resources.Shared;
using Application.Services.Shared;
using Common;
using Common.Extensions;
using Moq;
using UnitTesting.Common;
using Xunit;

namespace AncillaryApplication.UnitTests;

[Trait("Category", "Unit")]
public class TwilioApplicationSpec
{
private readonly Mock<IAncillaryApplication> _ancillaryApplication;
private readonly TwilioApplication _application;
private readonly Mock<ICallerContext> _caller;
private readonly Mock<IWebhookNotificationAuditService> _webhookNotificationAuditService;

public TwilioApplicationSpec()
{
_caller = new Mock<ICallerContext>();
var recorder = new Mock<IRecorder>();
_ancillaryApplication = new Mock<IAncillaryApplication>();
_webhookNotificationAuditService = new Mock<IWebhookNotificationAuditService>();
_webhookNotificationAuditService.Setup(wns => wns.CreateAuditAsync(It.IsAny<ICallerContext>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new WebhookNotificationAudit
{
Id = "anauditid",
Source = "asource",
EventId = "aneventid",
EventType = "aneventtype",
Status = WebhookNotificationStatus.Received
});
_webhookNotificationAuditService.Setup(wns => wns.MarkAsProcessedAsync(It.IsAny<ICallerContext>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new WebhookNotificationAudit
{
Id = "anauditid",
Source = "asource",
EventId = "aneventid",
EventType = "aneventtype",
Status = WebhookNotificationStatus.Processed
});

_application = new TwilioApplication(recorder.Object, _ancillaryApplication.Object,
_webhookNotificationAuditService.Object);
}

[Fact]
public async Task WhenNotifyWebhookEventWithUnhandledEvent_ThenReturnsOk()
{
var eventData = new TwilioEventData
{
MessageStatus = TwilioMessageStatus.Queued,
MessageSid = "amessagesid",
ErrorCode = null,
RawDlrDoneDate = null
};

var result = await _application.NotifyWebhookEvent(_caller.Object, eventData, CancellationToken.None);

result.Should().BeSuccess();
_webhookNotificationAuditService.Verify(wns => wns.CreateAuditAsync(_caller.Object,
TwilioConstants.AuditSourceName, It.Is<string>(s => s.StartsWith("amessagesid")),
TwilioMessageStatus.Queued.ToString(),
eventData.ToJson(false, StringExtensions.JsonCasing.Pascal, false), It.IsAny<CancellationToken>()));
_webhookNotificationAuditService.Verify(
wns => wns.MarkAsProcessedAsync(_caller.Object, It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
_webhookNotificationAuditService.Verify(
wns => wns.MarkAsFailedProcessingAsync(_caller.Object, It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}

[Fact]
public async Task WhenNotifyWebhookEventWithDeliveredEvent_ThenConfirms()
{
var eventData = new TwilioEventData
{
MessageStatus = TwilioMessageStatus.Delivered,
MessageSid = "amessagesid",
ErrorCode = null,
RawDlrDoneDate = 2411271234
};
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveredAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok);

var result = await _application.NotifyWebhookEvent(_caller.Object, eventData, CancellationToken.None);

result.Should().BeSuccess();
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveredAsync(_caller.Object, "areceiptid",
new DateTime(2024, 11, 27, 12, 34, 00, DateTimeKind.Utc), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok);
_webhookNotificationAuditService.Verify(wns => wns.CreateAuditAsync(_caller.Object,
TwilioConstants.AuditSourceName,
It.Is<string>(s => s.StartsWith("amessagesid")), TwilioMessageStatus.Delivered.ToString(),
eventData.ToJson(false, StringExtensions.JsonCasing.Pascal, false), It.IsAny<CancellationToken>()));
_webhookNotificationAuditService.Verify(
wns => wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny<CancellationToken>()));
_webhookNotificationAuditService.Verify(
wns => wns.MarkAsFailedProcessingAsync(_caller.Object, It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}

[Fact]
public async Task WhenNotifyWebhookEventWithFailedEventButNoErrorCode_ThenReturnsOk()
{
var eventData = new TwilioEventData
{
MessageStatus = TwilioMessageStatus.Failed,
MessageSid = "amessagesid",
ErrorCode = null,
RawDlrDoneDate = null
};

_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveredAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok);

var result = await _application.NotifyWebhookEvent(_caller.Object, eventData, CancellationToken.None);

result.Should().BeSuccess();
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveryFailedAsync(_caller.Object,
It.Is<string>(s => s.StartsWith("amessagesid")),
DateTime.UnixEpoch.AddSeconds(1), "none", It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok);
_webhookNotificationAuditService.Verify(wns => wns.CreateAuditAsync(_caller.Object,
TwilioConstants.AuditSourceName,
It.Is<string>(s => s.StartsWith("amessagesid")), TwilioMessageStatus.Failed.ToString(),
eventData.ToJson(false, StringExtensions.JsonCasing.Pascal, false), It.IsAny<CancellationToken>()));
_webhookNotificationAuditService.Verify(
wns => wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny<CancellationToken>()));
_webhookNotificationAuditService.Verify(
wns => wns.MarkAsFailedProcessingAsync(_caller.Object, It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}

[Fact]
public async Task WhenNotifyWebhookEventWithFailedEventWithErrorCode_ThenConfirms()
{
var eventData = new TwilioEventData
{
MessageStatus = TwilioMessageStatus.Failed,
MessageSid = "amessagesid",
ErrorCode = "anerrorcode",
RawDlrDoneDate = null
};
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveredAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok);

var result = await _application.NotifyWebhookEvent(_caller.Object, eventData, CancellationToken.None);

result.Should().BeSuccess();
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveryFailedAsync(_caller.Object, "areceiptid",
DateTime.UnixEpoch.AddSeconds(1), "anerrorcode", It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok);
_webhookNotificationAuditService.Verify(wns => wns.CreateAuditAsync(_caller.Object,
TwilioConstants.AuditSourceName,
It.Is<string>(s => s.StartsWith("amessagesid")), TwilioMessageStatus.Failed.ToString(),
eventData.ToJson(false, StringExtensions.JsonCasing.Pascal, false), It.IsAny<CancellationToken>()));
_webhookNotificationAuditService.Verify(
wns => wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny<CancellationToken>()));
_webhookNotificationAuditService.Verify(
wns => wns.MarkAsFailedProcessingAsync(_caller.Object, It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
}
11 changes: 11 additions & 0 deletions src/AncillaryApplication/ITwilioApplication.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Application.Interfaces;
using Application.Resources.Shared;
using Common;

namespace AncillaryApplication;

public interface ITwilioApplication
{
Task<Result<Error>> NotifyWebhookEvent(ICallerContext caller, TwilioEventData eventData,
CancellationToken cancellationToken);
}
7 changes: 4 additions & 3 deletions src/AncillaryApplication/MailgunApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public async Task<Result<Error>> NotifyWebhookEvent(ICallerContext caller, Mailg
if (created.IsFailure)
{
_recorder.TraceError(caller.ToCall(),
"Failed to audit Mailgun webhook event {Event} with {Code}: {Message}", eventType, created.Error.Code,
"Failed to audit Mailgun webhook event {Event} with {ErrorCode}: {Message}", eventType,
created.Error.Code,
created.Error.Message);
return created.Error;
}
Expand Down Expand Up @@ -91,7 +92,7 @@ private async Task<Result<Error>> ConfirmEmailDeliveryAsync(ICallerContext calle
if (delivered.IsFailure)
{
_recorder.TraceError(caller.ToCall(),
"Failed to confirm delivery for Mailgun receipt {Receipt}, with {Code}: {Message}",
"Failed to confirm delivery for Mailgun receipt {Receipt}, with {ErrorCode}: {Message}",
receiptId, delivered.Error.Code, delivered.Error.Message);

var updated =
Expand Down Expand Up @@ -122,7 +123,7 @@ private async Task<Result<Error>> ConfirmEmailDeliveryFailedAsync(ICallerContext
if (delivered.IsFailure)
{
_recorder.TraceError(caller.ToCall(),
"Failed to confirm failed delivery for Mailgun receipt {Receipt}, with {Code}: {Message}",
"Failed to confirm failed delivery for Mailgun receipt {Receipt}, with {ErrorCode}: {Message}",
receiptId, delivered.Error.Code, delivered.Error.Message);

var updated =
Expand Down
152 changes: 152 additions & 0 deletions src/AncillaryApplication/TwilioApplication.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using System.Globalization;
using Application.Common.Extensions;
using Application.Interfaces;
using Application.Resources.Shared;
using Application.Services.Shared;
using Common;
using Common.Extensions;

namespace AncillaryApplication;

public class TwilioApplication : ITwilioApplication
{
private readonly IAncillaryApplication _ancillaryApplication;
private readonly IRecorder _recorder;
private readonly IWebhookNotificationAuditService _webHookNotificationAuditService;

public TwilioApplication(IRecorder recorder, IAncillaryApplication ancillaryApplication,
IWebhookNotificationAuditService webHookNotificationAuditService)
{
_recorder = recorder;
_ancillaryApplication = ancillaryApplication;
_webHookNotificationAuditService = webHookNotificationAuditService;
}

public async Task<Result<Error>> NotifyWebhookEvent(ICallerContext caller, TwilioEventData eventData,
CancellationToken cancellationToken)
{
var eventId = $"{eventData.MessageSid}-{Guid.NewGuid():N}";
var eventStatus = eventData.MessageStatus;
var @event = eventStatus.ToEnumOrDefault(TwilioMessageStatus.Unknown);
_recorder.TraceInformation(caller.ToCall(), "Twilio webhook event received: {Event} for {Status}", eventId,
@event);

var created = await _webHookNotificationAuditService.CreateAuditAsync(caller, TwilioConstants.AuditSourceName,
eventId, eventStatus.ToString(), eventData.ToJson(false), cancellationToken);
if (created.IsFailure)
{
_recorder.TraceError(caller.ToCall(),
"Failed to audit Twilio webhook event {Event} for {Status} with {ErrorCode}: {Message}", eventId,
eventStatus, created.Error.Code, created.Error.Message);
return created.Error;
}

var audit = created.Value;
switch (@event)
{
case TwilioMessageStatus.Delivered:
{
var deliveredAt = eventData.RawDlrDoneDate.HasValue
? eventData.RawDlrDoneDate.Value.FromTwilioDateLong()
: DateTime.UtcNow;
var receiptId = eventData.MessageSid;

return await ConfirmSmsDeliveryAsync(caller, audit, receiptId, deliveredAt, cancellationToken);
}

case TwilioMessageStatus.Failed:
{
var failedAt = DateTime.UtcNow;
var reason = eventData.ErrorCode ?? "none";
var receiptId = eventData.MessageSid;

return await ConfirmSmsDeliveryFailedAsync(caller, audit, receiptId, failedAt, reason,
cancellationToken);
}

default:
_recorder.TraceInformation(caller.ToCall(), "Twilio webhook event ignored: {Event} for {Status}",
eventId, @event);
return Result.Ok;
}
}

private async Task<Result<Error>> ConfirmSmsDeliveryAsync(ICallerContext caller, WebhookNotificationAudit audit,
string receiptId, DateTime deliveredAt, CancellationToken cancellationToken)
{
var delivered =
await _ancillaryApplication.ConfirmSmsDeliveredAsync(caller, receiptId, deliveredAt, cancellationToken);
if (delivered.IsFailure)
{
_recorder.TraceError(caller.ToCall(),
"Failed to confirm delivery for Twilio receipt {Receipt}, with {ErrorCode}: {Message}",
receiptId, delivered.Error.Code, delivered.Error.Message);

var updated =
await _webHookNotificationAuditService.MarkAsFailedProcessingAsync(caller, audit.Id, cancellationToken);
if (updated.IsFailure)
{
return updated.Error;
}

return delivered.Error;
}

var saved = await _webHookNotificationAuditService.MarkAsProcessedAsync(caller, audit.Id, cancellationToken);
if (saved.IsFailure)
{
return saved.Error;
}

return Result.Ok;
}

private async Task<Result<Error>> ConfirmSmsDeliveryFailedAsync(ICallerContext caller,
WebhookNotificationAudit audit, string receiptId, DateTime failedAt, string reason,
CancellationToken cancellationToken)
{
var delivered = await _ancillaryApplication.ConfirmSmsDeliveryFailedAsync(caller,
receiptId, failedAt, reason, cancellationToken);
if (delivered.IsFailure)
{
_recorder.TraceError(caller.ToCall(),
"Failed to confirm failed delivery for Twilio receipt {Receipt}, with {ErrorCode}: {Message}",
receiptId, delivered.Error.Code, delivered.Error.Message);

var updated =
await _webHookNotificationAuditService.MarkAsFailedProcessingAsync(caller, audit.Id, cancellationToken);
if (updated.IsFailure)
{
return updated.Error;
}

return delivered.Error;
}

var saved = await _webHookNotificationAuditService.MarkAsProcessedAsync(caller, audit.Id, cancellationToken);
if (saved.IsFailure)
{
return saved.Error;
}

return Result.Ok;
}
}

public static class TwilioApplicationConversionExtensions
{
public static DateTime FromTwilioDateLong(this long twilioDate)
{
return DateTime.ParseExact(twilioDate.ToString().PadLeft(10), "yyMMddHHmm",
CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
}

public static long ToTwilioDateLong(this DateTime dateTime)
{
return dateTime
.ToNearestMinute()
.ToString("yyMMddHHmm")
.PadLeft(10)
.ToLong();
}
}
Loading

0 comments on commit b0eaf8f

Please sign in to comment.